claude-inbox 0.13.0 → 0.13.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: f8b8a6141fcc9efc19386143e9412d4ebab669e73350ee42205e6a07fd05b290
4
- data.tar.gz: 412882713110008db6ff1d96fe8b786e5d61e82ea9b349202c12560f290a86b3
3
+ metadata.gz: 36fa381f8d03173896c6b44dff47534ed54119c9ff529833a9783c7f5c524506
4
+ data.tar.gz: '0386da94a6ffa5259bbad165857821f44b082dc5a64d957beae352c46dc61100'
5
5
  SHA512:
6
- metadata.gz: f87a453630132a470dc22b345e8f611ae0e58f04bc877c52ee1c53c4358a306377529a10ae8d807058dc32e1c9d039fc2480dd962a89d382a16e00d35faf6908
7
- data.tar.gz: de74aeac784fdc777336fdaa4db38cfae93e7458873809e2c3e52c0a5a69dc53d2391cec86d73e2d52882ad9634c4567553f3d6151e2c40c571a7eca0abc8db5
6
+ metadata.gz: bb933d5723ee131285824d2faea2570762854a4695da2ecd98716d5b9d981b2b36aece7abbda8acf92fa063b47f52f40ac2931136ef5fecee194f0fda7cdc72c
7
+ data.tar.gz: 3593591f4192056035fdc13e3480a26824794be46df0d8aa5b72fdb5c84a4b6b039d658b4279ec64cdf35ec94c2d65533117321c01bdd9b3e06a4267571a83b6
data/exe/claude-inbox CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # Claude's files are UTF-8 whatever the locale says; under LANG=C a read of
5
+ # ~/.claude.json would otherwise come back US-ASCII and fail to parse.
6
+ Encoding.default_external = Encoding::UTF_8
7
+
4
8
  require "claude_inbox"
5
9
  require "claude_inbox/cli"
6
10
 
@@ -87,7 +87,7 @@ module ClaudeInbox
87
87
  end
88
88
 
89
89
  def run
90
- @booted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
90
+ @booted_at = monotonic
91
91
  install_traps
92
92
  @terminal.enter
93
93
  @poller.start
@@ -264,7 +264,7 @@ module ClaudeInbox
264
264
  end
265
265
 
266
266
  def render
267
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
267
+ t0 = monotonic
268
268
  now = Time.now
269
269
  all = @store.sections(now)
270
270
  @snapshot.write(all, now)
@@ -283,7 +283,7 @@ module ClaudeInbox
283
283
  frame = frame.plain unless @color # --no-color: decided here and nowhere else
284
284
  placed(frame)
285
285
  @terminal.paint(frame.lines)
286
- dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
286
+ dt = monotonic - t0
287
287
  Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
288
288
  end
289
289
 
@@ -309,11 +309,13 @@ module ClaudeInbox
309
309
  # already says how to retry.
310
310
  def loading_for
311
311
  return nil if @last_poll || @error || !@booted_at
312
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - @booted_at
312
+ monotonic - @booted_at
313
313
  end
314
314
 
315
315
  def notice(msg)
316
316
  @notice = [msg, Time.now + 4]
317
317
  end
318
+
319
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
318
320
  end
319
321
  end
@@ -28,11 +28,11 @@ module ClaudeInbox
28
28
  .split("\n")
29
29
  end
30
30
 
31
+ private
32
+
31
33
  # How wide the box wants to be; the frame caps it at the terminal.
32
34
  def fit = self.class::WIDTH
33
35
 
34
- private
35
-
36
36
  def key(name) = @theme.cyan_bold(name)
37
37
 
38
38
  # A key and what it does.
@@ -83,10 +83,6 @@ module ClaudeInbox
83
83
 
84
84
  def title = " #{kind.to_s.capitalize} pull request "
85
85
 
86
- # Titles vary, so the box grows to the longest line plus some air,
87
- # and never shrinks below a plain dialog.
88
- def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
89
-
90
86
  def lines(width, _caret)
91
87
  (@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
92
88
  ["", " #{hint(KEYS.fetch(kind), "#{kind} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
@@ -102,6 +98,10 @@ module ClaudeInbox
102
98
 
103
99
  private
104
100
 
101
+ # Titles vary, so the box grows to the longest line plus some air,
102
+ # and never shrinks below a plain dialog.
103
+ def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
104
+
105
105
  # The list's badge, then the title. A bare #190 is ambiguous only
106
106
  # once the list spans repos.
107
107
  def label(pr)
@@ -33,8 +33,11 @@ module ClaudeInbox
33
33
  def run
34
34
  return false unless @lock.take(role: "headless")
35
35
  @poller.start
36
- Thread.new { watch_for_updates } if @updater
36
+ updates = Thread.new { watch_for_updates } if @updater
37
37
  loop { @queue.pop }
38
+ ensure
39
+ @poller.stop
40
+ updates&.kill
38
41
  end
39
42
 
40
43
  private
@@ -12,12 +12,11 @@ require_relative "text_buffer"
12
12
  require_relative "theme"
13
13
 
14
14
  module ClaudeInbox
15
- # State and key handling for the "new session" modal. Pure: returns what
16
- # happened so the App can act on it. Rendering returns plain lines; the
17
- # App wraps them in a box.
15
+ # The `n` form a new session is composed in. Pure: it says what happened
16
+ # and the App acts on it.
18
17
  class NewSessionForm
19
18
  # `value` is a TextBuffer on :text and :multiline fields and the picked
20
- # string on :choice ones — `kind` says which.
19
+ # string on :choice ones.
21
20
  Field = Struct.new(:key, :label, :kind, :value, :choices)
22
21
 
23
22
  # A directory the Directory field offers, shown by its shortest
@@ -71,11 +70,9 @@ module ClaudeInbox
71
70
 
72
71
  # => :cancel | :start | :start_and_attach | :changed
73
72
  def press(name, raw)
74
- # `:start`/`:start_and_attach` leave the form up until the spawn is
75
- # known to have worked, so a failure (untrusted directory, `claude`
76
- # missing, ...) can hand the composed prompt back instead of losing
77
- # it; there is no way to cancel a spawn already under way, so every
78
- # key is swallowed until the App reports back.
73
+ # A start leaves the form up until the spawn has worked, so a failure
74
+ # can hand the prompt back. A spawn under way can't be cancelled, so
75
+ # every key is swallowed until the App reports back.
79
76
  return :changed if @busy
80
77
  return confirm_discard_press(name, raw) if @confirm_discard
81
78
  @error = nil
@@ -149,8 +146,7 @@ module ClaudeInbox
149
146
  @defaults = Settings.defaults(cwd, home: @home)
150
147
  end
151
148
 
152
- # Full-screen body: a tall prompt editor, then one row per setting with
153
- # every choice visible. Exactly `height` lines.
149
+ # The whole body, exactly `height` lines.
154
150
  def screen(width, height)
155
151
  inner_w = width - 4
156
152
  fixed = 3 + 2 + 1 + (@fields.size - 1) + 1
@@ -189,11 +185,8 @@ module ClaudeInbox
189
185
  @theme.hints(keys)
190
186
  end
191
187
 
192
- # The App's `claude --bg` call failed after `:start`/`:start_and_attach`
193
- # already handed the form off — an untrusted directory, a stale binary,
194
- # a permissions error. The composed prompt is still here, so the form
195
- # reopens on it with the reason shown instead of the App just logging
196
- # the failure and losing the draft.
188
+ # The App's `claude --bg` call failed after a start. The form shows why,
189
+ # over the prompt it kept.
197
190
  def submission_failed(message)
198
191
  @busy = false
199
192
  @error = message
@@ -7,9 +7,8 @@ require_relative "theme"
7
7
  require_relative "store"
8
8
 
9
9
  module ClaudeInbox
10
- # sections -> Array<String> (one entry per terminal row, each exactly
11
- # `width` columns wide) plus a parallel Array of selectable items.
12
- # Pure: no terminal, no IO, no clock beyond the `now` it is handed.
10
+ # Turns the sections and a View into a Frame, every line exactly `width`
11
+ # columns. Pure: no terminal, no IO, no clock beyond the `now` it is handed.
13
12
  class Renderer
14
13
  Frame = Struct.new(:lines, :items, :top) do
15
14
  # The same frame for a terminal drawn without color.
@@ -141,7 +140,7 @@ module ClaudeInbox
141
140
  bar = "█" * filled + "░" * (10 - filled)
142
141
  left = w.resets_at.to_i - now.to_i if resets && w.resets_at
143
142
  figure = left&.positive? ? "#{w.percent}% · #{Text.age(left)} left" : "#{w.percent}%"
144
- "#{@p.dim(w.label)} #{@theme.public_send(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
143
+ "#{@p.dim(w.label)} #{@theme.tint(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
145
144
  end.join(" ")
146
145
  end
147
146
 
@@ -312,7 +311,7 @@ module ClaudeInbox
312
311
  chrome = 1 + 1 + 1 + 1 + 1 + 2 + Text.width(meta) + 2 + 1
313
312
  # Project is only cut once the label has given up all its space too,
314
313
  # so the row can never exceed `width` and fall into Text.pad's blind
315
- # tail-chop (which used to land mid-project-name with no ellipsis).
314
+ # tail-chop.
316
315
  project_text = Text.truncate(s.project, [width - chrome, 0].max)
317
316
  project = quiet ? @p.dim(project_text) : @theme.cyan(project_text)
318
317
 
@@ -416,8 +415,8 @@ module ClaudeInbox
416
415
  end
417
416
 
418
417
  # "working" while the agent is thinking, "idle" once it has stopped and
419
- # only the work it kicked off is still open — with that work named either
420
- # way, since "working · 2 agents" is the answer to "working on what?".
418
+ # only the work it kicked off is still open. Either way the work is
419
+ # named, since "working · 2 agents" answers "working on what?".
421
420
  def working_badge(s)
422
421
  return @theme.yellow("waiting#{": #{s.waiting_for}" if s.waiting_for}") if s.status == "waiting"
423
422
  label = s.job_state&.in_flight_label
@@ -431,7 +430,6 @@ module ClaudeInbox
431
430
 
432
431
  # ----- modal --------------------------------------------------------------
433
432
 
434
- # Centre a block of lines over the frame.
435
433
  def overlay(lines, block, width)
436
434
  block_w = block.map { |l| Text.width(l) }.max || 0
437
435
  left = [(width - block_w) / 2, 0].max
@@ -4,13 +4,9 @@ require_relative "settings"
4
4
 
5
5
  module ClaudeInbox
6
6
  # The slash commands `claude` would offer at its own prompt, read from the
7
- # same places it reads them: `.claude/commands/*.md` and
8
- # `.claude/skills/*/SKILL.md` under the project and the home directory,
9
- # the skills and commands of the plugins enabled there (named
10
- # `plugin:name`), and the skills claude.ai syncs down
11
- # (`anthropic-skills:name`). Built-ins live inside the CLI and are not
12
- # listed; typing one still works, since the prompt goes to `claude --bg`
13
- # verbatim.
7
+ # same places it reads them (docs/cli-quirks.md). Built-ins live inside
8
+ # the CLI and are not listed; typing one still works, since the prompt
9
+ # goes to `claude --bg` verbatim.
14
10
  #
15
11
  # Pure: filesystem in, plain values out. Nothing here runs `claude`.
16
12
  module SlashCommands
@@ -61,7 +57,6 @@ module ClaudeInbox
61
57
  end
62
58
  end
63
59
 
64
- # "name", or "plugin:name" when it comes from a plugin.
65
60
  def qualify(prefix, name) = [prefix, name].compact.join(":")
66
61
 
67
62
  # `installed_plugins.json` maps "name@marketplace" to where the plugin
@@ -77,8 +72,6 @@ module ClaudeInbox
77
72
  end
78
73
  end
79
74
 
80
- # claude.ai's synced skills sit in one bucket per account under
81
- # ~/.claude/skills/synced and answer to the anthropic-skills prefix.
82
75
  def synced(home)
83
76
  Dir.glob(File.join(home, ".claude", "skills", "synced", "*", "")).flat_map do |bucket|
84
77
  skills(bucket, prefix: SYNCED_PLUGIN)
@@ -54,10 +54,6 @@ module ClaudeInbox
54
54
  # Active rather than Settled, until the state changes again.
55
55
  def acknowledged? = unchanged_since?(entry&.acknowledged_at)
56
56
 
57
- # Finished or needing you, so it carries a read or unread mark. Only a
58
- # session you can attach to counts, since nothing else ever clears it.
59
- def markable? = session.actionable? && (session.finished? || session.needs_you?)
60
-
61
57
  # Finished or needing you since you last attached.
62
58
  def unread?
63
59
  return false unless markable?
@@ -122,6 +118,10 @@ module ClaudeInbox
122
118
 
123
119
  private
124
120
 
121
+ # Finished or needing you, so it carries a read or unread mark. Only a
122
+ # session you can attach to counts, since nothing else ever clears it.
123
+ def markable? = session.actionable? && (session.finished? || session.needs_you?)
124
+
125
125
  def unchanged_since?(at) = at && entry.state_since.to_i <= at.to_i
126
126
  end
127
127
  end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- # Runs a helper command with stdout/stderr captured, in its own session so
5
- # it is not associated with our tty. Terminal.app (and others) put the
6
- # "active process" of the tty in the tab title; without this the title
7
- # flips to "claude" or "ps" on every poll.
4
+ # Runs a helper command with its output captured, in a session of its own
5
+ # so it never reaches the tab title (docs/cli-quirks.md).
8
6
  module Subprocess
9
7
  Result = Struct.new(:out, :err, :status) do
10
8
  def success? = status.success?
@@ -6,10 +6,8 @@ require "tty-screen"
6
6
  require_relative "painter"
7
7
 
8
8
  module ClaudeInbox
9
- # The screen the inbox draws on: the alt screen and the mouse and wheel
10
- # modes that come and go with it, raw mode on the input, the cached size,
11
- # and the Painter that diffs frames onto it. `release` hands the whole
12
- # thing to a child that wants the tty and takes it back afterwards.
9
+ # The screen the inbox draws on, lent with `release` to a child that
10
+ # wants the tty.
13
11
  class Terminal
14
12
  ALT_ON = "\e[?1049h"
15
13
  ALT_OFF = "\e[?1049l"
@@ -19,11 +17,8 @@ module ClaudeInbox
19
17
  # know the mode ignore it and keep scrolling their own history.
20
18
  WHEEL_KEYS_ON = "\e[?1007h"
21
19
  WHEEL_KEYS_OFF = "\e[?1007l"
22
- # Real mouse reporting: button events plus the SGR encoding, so clicks
23
- # and wheel ticks arrive as escape sequences we parse ourselves (Mouse)
24
- # instead of the terminal only ever translating the wheel to arrow
25
- # keys. Terminals that don't understand either mode just ignore it and
26
- # fall back to WHEEL_KEYS_ON's translation, or their own scrollback.
20
+ # Button events in the SGR encoding, which Mouse parses. A terminal that
21
+ # knows neither mode falls back to WHEEL_KEYS_ON, or its own scrollback.
27
22
  MOUSE_ON = "\e[?1000h\e[?1006h"
28
23
  MOUSE_OFF = "\e[?1006l\e[?1000l"
29
24
  # Bracketed paste: what is pasted arrives fenced off from what is typed
@@ -54,7 +49,7 @@ module ClaudeInbox
54
49
  resized
55
50
  end
56
51
 
57
- # Safe to call twice, and from at_exit: the second call is a no-op.
52
+ # Safe to call twice, and from at_exit.
58
53
  def restore
59
54
  return if @restored
60
55
  @restored = true
@@ -35,6 +35,8 @@ module ClaudeInbox
35
35
 
36
36
  def initialize(text = "")
37
37
  @widths = Hash.new { |h, s| h[s] = Text.width(s) }
38
+ @images = 0
39
+ @pastes = 0
38
40
  replace(text)
39
41
  end
40
42
 
@@ -77,11 +79,11 @@ module ClaudeInbox
77
79
 
78
80
  # Chips are numbered per kind and never reused, so a second image is
79
81
  # `[Image #2]` even after the first was deleted, as Claude Code does.
80
- def attach(path) = place(Image.new(@images = (@images || 0) + 1, path))
82
+ def attach(path) = place(Image.new(@images += 1, path))
81
83
 
82
84
  def paste(text)
83
85
  return insert(text) if text.size <= FOLD_CHARS && text.count("\n") < FOLD_LINES
84
- place(Pasted.new(@pastes = (@pastes || 0) + 1, text))
86
+ place(Pasted.new(@pastes += 1, text))
85
87
  end
86
88
 
87
89
  # Handles one keypress: readline's editing keys, plus printable text.
@@ -133,7 +133,7 @@ module ClaudeInbox
133
133
  # clone follows the fork.
134
134
  def fetch_upstream
135
135
  remote = git("config", "branch.main.remote") or raise Error, "main tracks no remote"
136
- fetched = @run.call("git", "-C", @root, "fetch", "--quiet", remote)
136
+ fetched = run_git("fetch", "--quiet", remote)
137
137
  raise Error, "git fetch #{remote} failed: #{fetched.err.strip}" unless fetched.success?
138
138
  git("rev-parse", "main@{upstream}") or raise Error, "main tracks no remote"
139
139
  end
@@ -142,12 +142,14 @@ module ClaudeInbox
142
142
  branch = git("symbolic-ref", "--short", "HEAD")
143
143
  return Applied.new(success: false, output: "not on main (on #{branch})") unless branch == "main"
144
144
  return Applied.new(success: false, output: "the working tree is dirty") unless git("status", "--porcelain") == ""
145
- merged = @run.call("git", "-C", @root, "merge", "--ff-only", "--quiet", "main@{upstream}")
145
+ merged = run_git("merge", "--ff-only", "--quiet", "main@{upstream}")
146
146
  Applied.from(merged)
147
147
  end
148
148
 
149
+ def run_git(*args) = @run.call("git", "-C", @root, *args)
150
+
149
151
  def git(*args)
150
- result = @run.call("git", "-C", @root, *args)
152
+ result = run_git(*args)
151
153
  result.out.strip if result.success?
152
154
  end
153
155
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "strscan"
4
4
  require "unicode/display_width"
5
+ require_relative "text"
5
6
 
6
7
  module ClaudeInbox
7
8
  # A deliberately small cursor-addressed screen model that turns captured
@@ -22,7 +23,7 @@ module ClaudeInbox
22
23
  end
23
24
 
24
25
  def feed(str)
25
- ss = StringScanner.new(str.dup.force_encoding("UTF-8").scrub)
26
+ ss = StringScanner.new(Text.utf8(str))
26
27
  until ss.eos?
27
28
  if ss.scan(CSI)
28
29
  csi(ss[1], ss[2])
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.13.0"
4
+ VERSION = "0.13.1"
5
5
  end
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.13.0
4
+ version: 0.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: optparse
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.5'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: tty-cursor
14
28
  requirement: !ruby/object:Gem::Requirement