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.
- checksums.yaml +4 -4
- data/README.md +86 -164
- data/exe/claude-inbox +44 -0
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +11 -7
- data/lib/claude_inbox/app.rb +48 -14
- data/lib/claude_inbox/headless.rb +31 -0
- data/lib/claude_inbox/job_state.rb +4 -4
- data/lib/claude_inbox/keymap.rb +0 -2
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +18 -17
- data/lib/claude_inbox/painter.rb +3 -4
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/poller.rb +48 -25
- data/lib/claude_inbox/pull_requests.rb +31 -17
- data/lib/claude_inbox/rate_limits.rb +1 -3
- data/lib/claude_inbox/remote/http.rb +10 -7
- data/lib/claude_inbox/remote/listener.rb +2 -3
- data/lib/claude_inbox/remote/start.rb +2 -3
- data/lib/claude_inbox/renderer.rb +4 -4
- data/lib/claude_inbox/settings.rb +1 -0
- data/lib/claude_inbox/slash_commands.rb +1 -0
- data/lib/claude_inbox/snapshot.rb +48 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -3
- data/lib/claude_inbox/store.rb +24 -9
- data/lib/claude_inbox/switch.rb +40 -0
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/writer_lock.rb +65 -0
- data/lib/claude_inbox.rb +1 -10
- data/mod/.claude-plugin/plugin.json +20 -0
- data/mod/hooks/hooks.json +1 -0
- data/mod/hooks/register.tsx +470 -0
- data/mod/tests/contract.test.tsx +23 -0
- data/mod/tests/pane.test.tsx +199 -0
- data/mod/tests/snapshot.fixture.ts +47 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +30 -0
- 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, :
|
|
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, :
|
|
57
|
+
attr_reader :verb, :path, :headers
|
|
54
58
|
|
|
55
|
-
def initialize(verb, target,
|
|
59
|
+
def initialize(verb, target, headers, rest)
|
|
56
60
|
@verb = verb
|
|
57
|
-
@path
|
|
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/
|
|
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],
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
74
|
-
known.
|
|
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
|
data/lib/claude_inbox/store.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
95
|
+
def writable? = @writable
|
|
89
96
|
|
|
90
|
-
def
|
|
97
|
+
def snooze(id, choice) = edit(id, "snooze", choice: choice.to_s) { |e| e.snooze(choice, @clock.call) }
|
|
91
98
|
|
|
92
|
-
def
|
|
99
|
+
def wake(id) = edit(id, "wake") { |e| e.wake(@clock.call) }
|
|
93
100
|
|
|
94
|
-
def
|
|
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
|
-
|
|
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
|
|
13
|
-
# `[Image #1]`
|
|
14
|
-
# Code's own prompt
|
|
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
|
-
|
|
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
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 =
|
|
102
|
-
|
|
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
|
|
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|
|
|
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?(
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
cut
|
|
177
|
-
lines << word[
|
|
178
|
-
|
|
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
|
|
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.
|
|
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"] }
|