claude-inbox 0.3.0 → 0.4.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -164
  3. data/exe/claude-inbox +44 -0
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +11 -7
  6. data/lib/claude_inbox/app.rb +48 -14
  7. data/lib/claude_inbox/headless.rb +31 -0
  8. data/lib/claude_inbox/job_state.rb +4 -4
  9. data/lib/claude_inbox/keymap.rb +0 -2
  10. data/lib/claude_inbox/mod.rb +71 -0
  11. data/lib/claude_inbox/new_session_form.rb +18 -17
  12. data/lib/claude_inbox/painter.rb +3 -4
  13. data/lib/claude_inbox/paste.rb +2 -0
  14. data/lib/claude_inbox/poller.rb +48 -25
  15. data/lib/claude_inbox/pull_requests.rb +31 -17
  16. data/lib/claude_inbox/rate_limits.rb +1 -3
  17. data/lib/claude_inbox/remote/http.rb +10 -7
  18. data/lib/claude_inbox/remote/listener.rb +2 -3
  19. data/lib/claude_inbox/remote/start.rb +2 -3
  20. data/lib/claude_inbox/renderer.rb +4 -4
  21. data/lib/claude_inbox/settings.rb +1 -0
  22. data/lib/claude_inbox/slash_commands.rb +1 -0
  23. data/lib/claude_inbox/snapshot.rb +48 -0
  24. data/lib/claude_inbox/store/entry.rb +0 -2
  25. data/lib/claude_inbox/store/row.rb +7 -3
  26. data/lib/claude_inbox/store.rb +24 -9
  27. data/lib/claude_inbox/switch.rb +40 -0
  28. data/lib/claude_inbox/text_buffer.rb +84 -33
  29. data/lib/claude_inbox/writer_lock.rb +65 -0
  30. data/lib/claude_inbox.rb +1 -10
  31. data/mod/.claude-plugin/plugin.json +20 -0
  32. data/mod/hooks/hooks.json +1 -0
  33. data/mod/hooks/register.tsx +470 -0
  34. data/mod/tests/contract.test.tsx +23 -0
  35. data/mod/tests/pane.test.tsx +199 -0
  36. data/mod/tests/snapshot.fixture.ts +47 -0
  37. data/mod/tests/snooze.test.tsx +31 -0
  38. data/mod/tsconfig.json +3 -0
  39. data/mod/types/index.d.ts +30 -0
  40. metadata +16 -1
@@ -32,7 +32,7 @@ module ClaudeInbox
32
32
  # the JSON body, and the headers that status calls for. `note`, when
33
33
  # set, is what the refusal is logged as in place of its message.
34
34
  class Error < StandardError
35
- attr_reader :status, :headers, :details, :note
35
+ attr_reader :status, :headers, :note
36
36
 
37
37
  def initialize(status, message, headers: {}, note: nil, **details)
38
38
  super(message)
@@ -43,6 +43,10 @@ module ClaudeInbox
43
43
  end
44
44
 
45
45
  def body = {error: Http.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Http.utf8(v) }}
46
+
47
+ private
48
+
49
+ attr_reader :details
46
50
  end
47
51
 
48
52
  # A request head. `headers` are keyed in lower case, and a repeated
@@ -50,12 +54,11 @@ module ClaudeInbox
50
54
  # pass for the first. The bytes read past the head stay here for the
51
55
  # body.
52
56
  class Request
53
- attr_reader :verb, :path, :query, :version, :headers
57
+ attr_reader :verb, :path, :headers
54
58
 
55
- def initialize(verb, target, version, headers, rest)
59
+ def initialize(verb, target, headers, rest)
56
60
  @verb = verb
57
- @path, @query = target.split("?", 2)
58
- @version = version
61
+ @path = target.split("?", 2).first
59
62
  @headers = headers
60
63
  @rest = rest
61
64
  end
@@ -88,13 +91,13 @@ module ClaudeInbox
88
91
  def self.read_head(io, deadline:)
89
92
  rest = +"".b
90
93
  line = read_line(io, rest, deadline)
91
- request = line.match(%r{\A(\S+) (/\S*) HTTP/(1\.[01])\z})
94
+ request = line.match(%r{\A(\S+) (/\S*) HTTP/1\.[01]\z})
92
95
  raise Error.new(400, "not an HTTP/1.x request line") unless request && request[1].match?(TOKEN)
93
96
  headers = {}
94
97
  count = 0
95
98
  loop do
96
99
  line = read_line(io, rest, deadline)
97
- return Request.new(request[1], request[2], request[3], headers, rest) if line.empty?
100
+ return Request.new(request[1], request[2], headers, rest) if line.empty?
98
101
  raise Error.new(400, "more than #{MAX_HEADERS} headers") if (count += 1) > MAX_HEADERS
99
102
  name, value = line.split(":", 2)
100
103
  raise Error.new(400, "malformed header") unless value && name.match?(TOKEN)
@@ -109,7 +109,7 @@ module ClaudeInbox
109
109
  def self.disabled = new(client: nil, store: nil, queue: nil, pairing: nil, port: nil)
110
110
 
111
111
  # `start_options` are Start's own.
112
- def initialize(client:, store:, queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
112
+ def initialize(queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
113
113
  lock_path: LOCK_PATH, fixture: false, retry_every: RETRY_EVERY, **start_options)
114
114
  @queue = queue
115
115
  @pairing = pairing
@@ -119,8 +119,7 @@ module ClaudeInbox
119
119
  @lock_path = lock_path
120
120
  @fixture = fixture
121
121
  @retry_every = retry_every
122
- @start = Start.new(client: client, store: store, queue: queue, allowed_modes: allowed_modes,
123
- fixture: fixture, **start_options)
122
+ @start = Start.new(queue: queue, allowed_modes: allowed_modes, fixture: fixture, **start_options)
124
123
  @mutex = Mutex.new
125
124
  @state = :off
126
125
  @recent = []
@@ -27,14 +27,13 @@ module ClaudeInbox
27
27
  KEYS_KEPT = 16
28
28
 
29
29
  def initialize(client:, store:, queue:, allowed_modes:, fixture: false, images_dir: Images::DEFAULT_DIR,
30
- jobs_dir: JobState::DEFAULT_DIR, trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
30
+ trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
31
31
  @client = client
32
32
  @store = store
33
33
  @queue = queue
34
34
  @allowed_modes = allowed_modes
35
35
  @fixture = fixture
36
36
  @images_dir = images_dir
37
- @jobs_dir = jobs_dir
38
37
  @trust = trust
39
38
  @settings = settings
40
39
  @bridge_wait = bridge_wait
@@ -192,7 +191,7 @@ module ClaudeInbox
192
191
  def remote_url(id, wait:)
193
192
  deadline = Http.monotonic + wait
194
193
  loop do
195
- url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @jobs_dir)).remote_url
194
+ url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @client.jobs_dir)).remote_url
196
195
  return url if url || Http.monotonic >= deadline
197
196
  sleep 0.2
198
197
  end
@@ -81,14 +81,14 @@ module ClaudeInbox
81
81
 
82
82
  def frame(sections, view)
83
83
  return full_screen(sections, view) if view.screen
84
- width, height, now, selected = view.width, view.height, view.now, view.selected
84
+ width, height, selected = view.width, view.height, view.selected
85
85
  list_w = width_for_list(width, view.peek)
86
86
  view_h = Renderer.body_height(height)
87
87
  body, items =
88
88
  if view.loading
89
89
  [loading_state(list_w, view_h, view.loading, view.tick), []]
90
90
  else
91
- body_lines(sections, list_w, view, now)
91
+ body_lines(sections, list_w, view)
92
92
  end
93
93
 
94
94
  top = clamp_top(view.top, body.size, view_h, items, selected)
@@ -243,7 +243,7 @@ module ClaudeInbox
243
243
 
244
244
  # ----- body ---------------------------------------------------------------
245
245
 
246
- def body_lines(sections, width, view, now)
246
+ def body_lines(sections, width, view)
247
247
  lines = []
248
248
  items = []
249
249
  if sections.all.empty?
@@ -260,7 +260,7 @@ module ClaudeInbox
260
260
  next
261
261
  end
262
262
  rows.each do |row|
263
- row_lines(row, name, selected, width, now, view.tick).each_with_index do |l, i|
263
+ row_lines(row, name, selected, width, view.now, view.tick).each_with_index do |l, i|
264
264
  lines << l
265
265
  items << ((i.zero? && row.selectable?) ? Item.new(:row, row, name) : nil)
266
266
  end
@@ -47,5 +47,6 @@ module ClaudeInbox
47
47
  end
48
48
  nil
49
49
  end
50
+ private_class_method :remote_control, :pick
50
51
  end
51
52
  end
@@ -112,5 +112,6 @@ module ClaudeInbox
112
112
  s = s[1..-2] if quoted
113
113
  s.gsub("''", "'")
114
114
  end
115
+ private_class_method :from_dir, :skills, :commands, :qualify, :plugins, :synced, :frontmatter, :unquote
115
116
  end
116
117
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+
5
+ module ClaudeInbox
6
+ # The sections as the screen shows them, written to a file for another
7
+ # front end to read, so the triage rules run once, here. Rewritten when
8
+ # the rows change and every HEARTBEAT seconds regardless, so a reader can
9
+ # tell a quiet inbox from one that has quit.
10
+ class Snapshot
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "snapshot.json")
12
+ HEARTBEAT = 5
13
+
14
+ def self.disabled = new(path: nil)
15
+
16
+ def initialize(path: DEFAULT_PATH)
17
+ @path = path
18
+ @body = nil
19
+ @written_at = nil
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Called from the poller's threads and the screen's alike.
24
+ def write(sections, now)
25
+ return unless @path
26
+ body = sections.to_h.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }
27
+ @mutex.synchronize do
28
+ return if body == @body && now.to_i - @written_at < HEARTBEAT
29
+ @body = body
30
+ @written_at = now.to_i
31
+ Records.save(@path, {"version" => 1, "written_at" => @written_at, "sections" => body})
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def row_hash(row)
38
+ s = row.session
39
+ pr = s.pr
40
+ {
41
+ "id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
42
+ "waiting" => s.waiting_on_work? || nil, "terminal" => s.terminal? || nil,
43
+ "cwd" => s.cwd, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
44
+ "pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
45
+ }.compact
46
+ end
47
+ end
48
+ end
@@ -69,8 +69,6 @@ module ClaudeInbox
69
69
  # When `claude rm` last refused this session, so the Reaper backs off.
70
70
  def reap_failed_at = @h["reap_failed_at"]
71
71
 
72
- def reap_error = @h["reap_error"]
73
-
74
72
  def stale?(now) = last_seen && now.to_i - last_seen > PRUNE_AFTER
75
73
 
76
74
  def observe(session, now)
@@ -66,12 +66,16 @@ module ClaudeInbox
66
66
  # Resolved PRs settle a `blocked` row too: asking "anything need
67
67
  # changing?" leaves it blocked, and merging answers it. `failed` stays
68
68
  # in view even if its PR landed; a PR nobody knows the state of yet
69
- # (no gh, offline) is ignored.
69
+ # (no gh, offline) is ignored. So is one resolved before the session
70
+ # started: reviewing an old PR, or the CLI resolving a bare PR number
71
+ # against the wrong repo, would otherwise hide a session waiting on you.
72
+ # Until gh says when a PR was resolved, it holds the row in view.
70
73
  def settled?
71
74
  return true if hand_settled?
72
75
  return false if UNSETTLEABLE.include?(session.effective_state)
73
- known = session.prs.select(&:known?)
74
- known.any? && known.all?(&:resolved?)
76
+ started = session.started_at.to_i
77
+ ours = session.prs.select(&:known?).reject { |pr| pr.resolved_before?(started) }
78
+ ours.any? && ours.all? { |pr| pr.resolved_since?(started) }
75
79
  end
76
80
 
77
81
  # Idle time is the only clock, not the Settled section: the PR rule holds
@@ -20,6 +20,7 @@ module ClaudeInbox
20
20
  # should see it, but after REAP_AFTER of not seeing it, you never will.
21
21
  UNREAPABLE = %w[working].freeze
22
22
  SECTIONS = %i[pinned needs_you active snoozed settled].freeze
23
+ NotWritable = Class.new(StandardError)
23
24
  # Sections long enough to be worth hiding behind a fold toggle.
24
25
  FOLDABLE_SECTIONS = %i[snoozed settled].freeze
25
26
 
@@ -56,9 +57,15 @@ module ClaudeInbox
56
57
 
57
58
  DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "state.json")
58
59
 
59
- def initialize(path: DEFAULT_PATH, clock: -> { Time.now })
60
+ # A store that is not `writable` reads the file but never saves it: the
61
+ # second inbox open at once, which reads along with the first rather
62
+ # than overwriting what it and the pane's requests settled, and sends
63
+ # its own edits to the first through the `relay` as requests.
64
+ def initialize(path: DEFAULT_PATH, clock: -> { Time.now }, writable: true, relay: nil)
60
65
  @path = path
61
66
  @clock = clock
67
+ @writable = writable
68
+ @relay = relay
62
69
  @mutex = Mutex.new
63
70
  @sessions = []
64
71
  @hidden = Set.new
@@ -74,7 +81,7 @@ module ClaudeInbox
74
81
  @hidden &= keys
75
82
  @forgotten &= keys
76
83
  @sessions = sessions.reject { |s| @hidden.include?(s.key) || @forgotten.include?(s.key) }
77
- @entries = self.class.merge_entries(@entries, @sessions, @clock.call)
84
+ @entries = self.class.merge_entries(@writable ? @entries : load, @sessions, @clock.call)
78
85
  save
79
86
  end
80
87
  end
@@ -85,17 +92,19 @@ module ClaudeInbox
85
92
 
86
93
  def sessions = @mutex.synchronize { @sessions.dup }
87
94
 
88
- def snooze(id, choice) = edit(id) { |e| e.snooze(choice, @clock.call) }
95
+ def writable? = @writable
89
96
 
90
- def wake(id) = edit(id) { |e| e.wake(@clock.call) }
97
+ def snooze(id, choice) = edit(id, "snooze", choice: choice.to_s) { |e| e.snooze(choice, @clock.call) }
91
98
 
92
- def acknowledge(id) = edit(id) { |e| e.acknowledge(@clock.call) }
99
+ def wake(id) = edit(id, "wake") { |e| e.wake(@clock.call) }
93
100
 
94
- def settle(id) = edit(id) { |e| e.settle(@clock.call) }
101
+ def acknowledge(id) = edit(id, "acknowledge") { |e| e.acknowledge(@clock.call) }
102
+
103
+ def settle(id) = edit(id, "settle") { |e| e.settle(@clock.call) }
95
104
 
96
105
  def mark_reap_failed(id, message) = edit(id) { |e| e.mark_reap_failed(@clock.call, message) }
97
106
 
98
- def toggle_pin(id) = edit(id) { |e| e.toggle_pin(@clock.call) }
107
+ def toggle_pin(id) = edit(id, "pin") { |e| e.toggle_pin(@clock.call) }
99
108
 
100
109
  def set_alias(id, name) = edit(id) { |e| e.alias = name }
101
110
 
@@ -144,11 +153,17 @@ module ClaudeInbox
144
153
 
145
154
  private
146
155
 
147
- def edit(id)
156
+ # A store that cannot save still makes the change here, for the screen,
157
+ # and asks the writer for it by `action` through the relay, so the next
158
+ # poll finds it done rather than undone. An edit with no request form
159
+ # would only be undone, so it is refused outright.
160
+ def edit(id, action = nil, **detail)
161
+ raise NotWritable, "another claude-inbox owns state.json" if !@writable && action.nil?
148
162
  @mutex.synchronize do
149
163
  yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
150
164
  save
151
165
  end
166
+ @relay&.request(action, id, **detail) if !@writable && action
152
167
  end
153
168
 
154
169
  def load
@@ -156,7 +171,7 @@ module ClaudeInbox
156
171
  end
157
172
 
158
173
  def save
159
- return unless @path
174
+ return unless @path && @writable
160
175
  Records.save(@path, {"version" => 1, "sessions" => @entries})
161
176
  end
162
177
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+
5
+ module ClaudeInbox
6
+ # A request from another front end to attach to a different session: one
7
+ # file, written by the asker and taken by the inbox attached to the asker's
8
+ # own session. Only that inbox can act on it, which is why the asker gives
9
+ # up on a request nobody takes, and why the request names who asked.
10
+ class Switch
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "switch.json")
12
+
13
+ def self.disabled = new(path: nil)
14
+
15
+ def initialize(path: DEFAULT_PATH)
16
+ @path = path
17
+ end
18
+
19
+ def request(id, from, now) = @path && Records.save(@path, {"id" => id, "from" => from, "at" => now.to_i})
20
+
21
+ # Whether the session `from` has asked to go elsewhere.
22
+ def requested_for?(from)
23
+ return false if @path.nil? || from.nil? || !File.exist?(@path)
24
+ Records.read(@path)["from"] == from
25
+ end
26
+
27
+ # The requested id if it is one of `attachable`, and the request is gone either way.
28
+ def take(attachable)
29
+ id = Records.read(@path)["id"]
30
+ clear
31
+ attachable.include?(id) ? id : nil
32
+ end
33
+
34
+ def clear
35
+ File.delete(@path) if @path
36
+ rescue Errno::ENOENT
37
+ nil
38
+ end
39
+ end
40
+ end
@@ -9,20 +9,34 @@ module ClaudeInbox
9
9
  #
10
10
  # Positions count cells, not bytes or characters. A cell is a grapheme
11
11
  # cluster — prompts and session names carry emoji, and character indexes
12
- # cut them in half — or a Chip, an attached image that shows as one
13
- # `[Image #1]` token and moves and deletes as one unit, the way Claude
14
- # Code's own prompt treats a pasted image.
12
+ # cut them in half — or a chip: an attached Image or a long Pasted text,
13
+ # each shown as one token (`[Image #1]`, `[Pasted text #1 +40 lines]`)
14
+ # that moves and deletes as one unit, the way Claude Code's own prompt
15
+ # treats them.
15
16
  #
16
17
  # b = TextBuffer.new("ab")
17
18
  # b.press(:left, "\e[D")
18
19
  # b.press("X", "X")
19
20
  # b.to_s # => "aXb"
20
21
  class TextBuffer
21
- Chip = Struct.new(:n, :path) do
22
+ Image = Struct.new(:n, :path) do
22
23
  def to_s = "[Image ##{n}]"
23
24
  end
24
25
 
26
+ Pasted = Struct.new(:n, :text) do
27
+ def to_s
28
+ lines = text.count("\n")
29
+ "[Pasted text ##{n} #{(lines > 0) ? "+#{lines} lines" : "#{text.size} chars"}]"
30
+ end
31
+ end
32
+
33
+ # Past either, a paste folds into a Pasted chip: laid out in full it
34
+ # buries the prompt around it, and wrapping it costs every frame.
35
+ FOLD_CHARS = 800
36
+ FOLD_LINES = 10
37
+
25
38
  def initialize(text = "")
39
+ @widths = Hash.new { |h, s| h[s] = Text.width(s) }
26
40
  @g = text.grapheme_clusters
27
41
  @cursor = @g.size
28
42
  end
@@ -31,13 +45,17 @@ module ClaudeInbox
31
45
 
32
46
  def empty? = @g.empty?
33
47
 
34
- # Grapheme offset of the cursor. Rendering goes through #row / #view;
35
- # this is here for callers that need to reason about position (tests).
36
- attr_reader :cursor
37
-
38
- def chips = @g.grep(Chip)
39
-
40
- def expand = @g.map { |c| c.is_a?(Chip) ? yield(c) : c }.join
48
+ # The text as it will be sent: a Pasted chip unfolds to its text, and
49
+ # the block says what each Image becomes.
50
+ def expand
51
+ @g.map { |c|
52
+ case c
53
+ when Image then yield(c)
54
+ when Pasted then c.text
55
+ else c
56
+ end
57
+ }.join
58
+ end
41
59
 
42
60
  # Swaps the whole text out and parks the cursor at the end — what
43
61
  # completion wants after it extends a path.
@@ -62,13 +80,13 @@ module ClaudeInbox
62
80
  @cursor += g.size
63
81
  end
64
82
 
65
- # Numbered after the chips already in the text, so a second image is
83
+ # Chips are numbered per kind and never reused, so a second image is
66
84
  # `[Image #2]` even after the first was deleted, as Claude Code does.
67
- def attach(path)
68
- chip = Chip.new(@next_chip = (@next_chip || 0) + 1, path)
69
- @g.insert(@cursor, chip)
70
- @cursor += 1
71
- chip
85
+ def attach(path) = place(Image.new(@images = (@images || 0) + 1, path))
86
+
87
+ def paste(text)
88
+ return insert(text) if text.size <= FOLD_CHARS && text.count("\n") < FOLD_LINES
89
+ place(Pasted.new(@pastes = (@pastes || 0) + 1, text))
72
90
  end
73
91
 
74
92
  # Handles one keypress — readline's editing keys, plus printable text —
@@ -98,12 +116,18 @@ module ClaudeInbox
98
116
  # gets plain truncated text instead.
99
117
  def row(width, cursor: nil)
100
118
  return Text.truncate(to_s, width) unless cursor
101
- first = 0
102
- first += 1 while width_of(@g[first...@cursor]) > width - 1
119
+ first = @cursor
120
+ used = 0
121
+ while first > 0 && used + cell_width(@g[first - 1]) <= width - 1
122
+ first -= 1
123
+ used += cell_width(@g[first])
124
+ end
103
125
  cells = []
126
+ used = 0
104
127
  @g[first..].each do |c|
105
- break if width_of(cells) + width_of([c]) > width
128
+ break if used + cell_width(c) > width
106
129
  cells << c
130
+ used += cell_width(c)
107
131
  end
108
132
  paint(cells, @cursor - first, cursor)
109
133
  end
@@ -129,12 +153,14 @@ module ClaudeInbox
129
153
 
130
154
  private
131
155
 
132
- def width_of(cells) = cells.sum { |c| Text.width(c.to_s) }
156
+ def width_of(cells) = cells.sum { |c| cell_width(c) }
157
+
158
+ def cell_width(cell) = @widths[cell.to_s]
133
159
 
134
160
  def paint(cells, offset, cursor, chip = nil)
135
161
  out = cells.each_with_index.map do |c, i|
136
162
  s = c.to_s
137
- s = chip.call(s) if chip && c.is_a?(Chip)
163
+ s = chip.call(s) if chip && !c.is_a?(String)
138
164
  (i == offset) ? cursor.call(s) : s
139
165
  end
140
166
  out << cursor.call(" ") if offset && offset >= cells.size
@@ -160,29 +186,48 @@ module ClaudeInbox
160
186
 
161
187
  # Text.wrap on cells, keeping the trailing spaces the cursor may sit on:
162
188
  # a chip is one cell however wide its label, so the wrap has to measure
163
- # cells rather than a joined string.
189
+ # cells rather than a joined string. Widths are kept running rather than
190
+ # re-measured per word: this runs every frame, over the whole prompt.
164
191
  def segments(cells, width)
165
- return [cells] if width_of(cells) <= width
166
- words = cells.slice_when { |c, _| c == " " }.to_a
167
192
  lines = []
168
193
  line = []
169
- words.each do |word|
170
- if !line.empty? && width_of(line) + width_of(word.reverse.drop_while { |c| c == " " }) > width
194
+ used = 0
195
+ cells.slice_when { |c, _| c == " " }.each do |word|
196
+ word_w = width_of(word)
197
+ bare_w = (word.last == " ") ? word_w - 1 : word_w
198
+ if !line.empty? && used + bare_w > width
171
199
  lines << line
172
200
  line = []
201
+ used = 0
173
202
  end
174
- while width_of(word) > width
175
- cut = word.size - 1
176
- cut -= 1 while cut > 0 && width_of(word[0...cut]) > width
177
- lines << word[0...cut]
178
- word = word[cut..]
203
+ at = 0
204
+ while word_w > width
205
+ cut, cut_w = fit(word, at, width)
206
+ lines << word[at...at + cut]
207
+ at += cut
208
+ word_w -= cut_w
179
209
  end
180
- line += word
210
+ line.concat(word[at..])
211
+ used += word_w
181
212
  end
182
213
  lines << line unless line.empty?
183
214
  lines
184
215
  end
185
216
 
217
+ # How many cells of `word` from `at` fit in `width`, and their width.
218
+ # Always at least one, so a glyph wider than the box still moves on.
219
+ def fit(word, at, width)
220
+ count = 0
221
+ used = 0
222
+ (at...word.size).each do |i|
223
+ w = cell_width(word[i])
224
+ break if count > 0 && used + w > width
225
+ count += 1
226
+ used += w
227
+ end
228
+ [count, used]
229
+ end
230
+
186
231
  # The newline itself belongs to the line it ends, so a cursor sitting on
187
232
  # it lands past the end of that line rather than at the start of the next.
188
233
  def logical_lines
@@ -200,6 +245,12 @@ module ClaudeInbox
200
245
 
201
246
  def cursor_row(rows) = rows.rindex { |_, start| start <= @cursor } || 0
202
247
 
248
+ def place(chip)
249
+ @g.insert(@cursor, chip)
250
+ @cursor += 1
251
+ chip
252
+ end
253
+
203
254
  def delete(at, length)
204
255
  return if at < 0 || length <= 0
205
256
  @g.slice!(at, length)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module ClaudeInbox
6
+ # One snapshot writer per machine: the inbox you can see, else a headless
7
+ # one. The file names the holder's role beside its pid, so an inbox takes
8
+ # the lock over from a headless process, which it ends, and never from
9
+ # another inbox; a headless process that finds the lock held exits.
10
+ class WriterLock
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "writer.lock")
12
+ EVICT_TRIES = 20
13
+
14
+ def self.disabled = new(path: nil)
15
+
16
+ def initialize(path: DEFAULT_PATH)
17
+ @path = path
18
+ @file = nil
19
+ end
20
+
21
+ # True once held. Only a headless holder is evicted, and only by an inbox.
22
+ def take(role:)
23
+ return true unless @path
24
+ FileUtils.mkdir_p(File.dirname(@path))
25
+ file = File.open(@path, File::RDWR | File::CREAT, 0o600)
26
+ return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
27
+ holder_role, holder = file.read.split
28
+ return refuse(file) unless role == "inbox" && holder_role == "headless"
29
+ Process.kill("TERM", holder.to_i) if holder.to_i.positive? && holder.to_i != Process.pid
30
+ EVICT_TRIES.times do
31
+ return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
32
+ sleep 0.1
33
+ end
34
+ refuse(file)
35
+ rescue Errno::ESRCH, Errno::EPERM
36
+ # The holder the file names is gone already: the lock is free.
37
+ file.flock(File::LOCK_EX | File::LOCK_NB) ? hold(file, role) : refuse(file)
38
+ end
39
+
40
+ def held? = !@file.nil?
41
+
42
+ def release
43
+ @file&.close
44
+ @file = nil
45
+ end
46
+
47
+ private
48
+
49
+ # After reading the holder the position sits at the end, and truncate does
50
+ # not move it, so the write is rewound first or it lands after NUL padding.
51
+ def hold(file, role)
52
+ file.rewind
53
+ file.truncate(0)
54
+ file.write("#{role} #{Process.pid}")
55
+ file.flush
56
+ @file = file
57
+ true
58
+ end
59
+
60
+ def refuse(file)
61
+ file.close
62
+ false
63
+ end
64
+ end
65
+ end
data/lib/claude_inbox.rb CHANGED
@@ -1,14 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
6
-
7
- require_relative "claude_inbox/palette"
8
- require_relative "claude_inbox/session"
9
- require_relative "claude_inbox/job_state"
10
- require_relative "claude_inbox/pull_requests"
11
- require_relative "claude_inbox/agents_client"
12
- require_relative "claude_inbox/sessions"
13
- require_relative "claude_inbox/store"
14
- require_relative "claude_inbox/reaper"
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "inbox-pane",
3
+ "version": "0.2.0",
4
+ "description": "claude-inbox's sections in a pane beside the transcript, with its keys.",
5
+ "types": "./types/index.d.ts",
6
+ "userConfig": {
7
+ "command": {
8
+ "type": "string",
9
+ "title": "claude-inbox command",
10
+ "description": "The claude-inbox executable to run headless when no inbox is writing the snapshot.",
11
+ "default": "claude-inbox"
12
+ },
13
+ "openOnStart": {
14
+ "type": "boolean",
15
+ "title": "Open on start",
16
+ "description": "Open the pane when a session starts. Off, /inbox opens it.",
17
+ "default": true
18
+ }
19
+ }
20
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }