claude-inbox 0.2.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 +107 -177
- data/exe/claude-inbox +67 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +68 -62
- data/lib/claude_inbox/app.rb +88 -38
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/headless.rb +31 -0
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +25 -10
- data/lib/claude_inbox/keymap.rb +1 -3
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +43 -52
- data/lib/claude_inbox/painter.rb +31 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +48 -24
- data/lib/claude_inbox/pull_requests.rb +36 -22
- data/lib/claude_inbox/rate_limits.rb +1 -3
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +140 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +435 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +201 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +61 -61
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +20 -7
- 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 -11
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/switch.rb +40 -0
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- 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 +29 -1
|
@@ -7,21 +7,33 @@ module ClaudeInbox
|
|
|
7
7
|
# settings files it reads, most specific wins. Values are strings, or
|
|
8
8
|
# nil when nothing sets them — then the CLI decides at launch.
|
|
9
9
|
module Settings
|
|
10
|
-
Defaults = Struct.new(:model, :effort, :permission_mode)
|
|
10
|
+
Defaults = Struct.new(:model, :effort, :permission_mode, :remote)
|
|
11
11
|
|
|
12
12
|
def self.defaults(cwd, home: Dir.home)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
].map { |path| read(path) }
|
|
13
|
+
user = read(File.join(home, ".claude", "settings.json"))
|
|
14
|
+
project = read(File.join(cwd, ".claude", "settings.json"))
|
|
15
|
+
local = read(File.join(cwd, ".claude", "settings.local.json"))
|
|
16
|
+
layers = [user, project, local]
|
|
18
17
|
Defaults.new(
|
|
19
18
|
model: pick(layers, "model"),
|
|
20
19
|
effort: pick(layers, "effortLevel"),
|
|
21
|
-
permission_mode: pick(layers, "permissions", "defaultMode")
|
|
20
|
+
permission_mode: pick(layers, "permissions", "defaultMode"),
|
|
21
|
+
remote: remote_control(user, project, local, read(File.join(home, ".claude.json")))
|
|
22
22
|
)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# "Enable Remote Control for all sessions" in /config. The CLI lets a
|
|
26
|
+
# repo turn it off but only user scope turn it on, and still honours the
|
|
27
|
+
# copy older versions kept in ~/.claude.json.
|
|
28
|
+
def self.remote_control(user, project, local, legacy)
|
|
29
|
+
return "no" if project["remoteControlAtStartup"] == false || local["remoteControlAtStartup"] == false
|
|
30
|
+
[user, legacy].each do |h|
|
|
31
|
+
v = h["remoteControlAtStartup"]
|
|
32
|
+
return v ? "yes" : "no" if [true, false].include?(v)
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
25
37
|
def self.read(path)
|
|
26
38
|
JSON.parse(File.read(path))
|
|
27
39
|
rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
|
|
@@ -35,5 +47,6 @@ module ClaudeInbox
|
|
|
35
47
|
end
|
|
36
48
|
nil
|
|
37
49
|
end
|
|
50
|
+
private_class_method :remote_control, :pick
|
|
38
51
|
end
|
|
39
52
|
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,11 +57,15 @@ module ClaudeInbox
|
|
|
56
57
|
|
|
57
58
|
DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "state.json")
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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)
|
|
62
65
|
@path = path
|
|
63
66
|
@clock = clock
|
|
67
|
+
@writable = writable
|
|
68
|
+
@relay = relay
|
|
64
69
|
@mutex = Mutex.new
|
|
65
70
|
@sessions = []
|
|
66
71
|
@hidden = Set.new
|
|
@@ -76,7 +81,7 @@ module ClaudeInbox
|
|
|
76
81
|
@hidden &= keys
|
|
77
82
|
@forgotten &= keys
|
|
78
83
|
@sessions = sessions.reject { |s| @hidden.include?(s.key) || @forgotten.include?(s.key) }
|
|
79
|
-
@entries = self.class.merge_entries(@entries, @sessions, @clock.call)
|
|
84
|
+
@entries = self.class.merge_entries(@writable ? @entries : load, @sessions, @clock.call)
|
|
80
85
|
save
|
|
81
86
|
end
|
|
82
87
|
end
|
|
@@ -87,17 +92,19 @@ module ClaudeInbox
|
|
|
87
92
|
|
|
88
93
|
def sessions = @mutex.synchronize { @sessions.dup }
|
|
89
94
|
|
|
90
|
-
def
|
|
95
|
+
def writable? = @writable
|
|
96
|
+
|
|
97
|
+
def snooze(id, choice) = edit(id, "snooze", choice: choice.to_s) { |e| e.snooze(choice, @clock.call) }
|
|
91
98
|
|
|
92
|
-
def wake(id) = edit(id) { |e| e.wake(@clock.call) }
|
|
99
|
+
def wake(id) = edit(id, "wake") { |e| e.wake(@clock.call) }
|
|
93
100
|
|
|
94
|
-
def acknowledge(id) = edit(id) { |e| e.acknowledge(@clock.call) }
|
|
101
|
+
def acknowledge(id) = edit(id, "acknowledge") { |e| e.acknowledge(@clock.call) }
|
|
95
102
|
|
|
96
|
-
def settle(id) = edit(id) { |e| e.settle(@clock.call) }
|
|
103
|
+
def settle(id) = edit(id, "settle") { |e| e.settle(@clock.call) }
|
|
97
104
|
|
|
98
105
|
def mark_reap_failed(id, message) = edit(id) { |e| e.mark_reap_failed(@clock.call, message) }
|
|
99
106
|
|
|
100
|
-
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) }
|
|
101
108
|
|
|
102
109
|
def set_alias(id, name) = edit(id) { |e| e.alias = name }
|
|
103
110
|
|
|
@@ -146,11 +153,17 @@ module ClaudeInbox
|
|
|
146
153
|
|
|
147
154
|
private
|
|
148
155
|
|
|
149
|
-
|
|
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?
|
|
150
162
|
@mutex.synchronize do
|
|
151
163
|
yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
|
|
152
164
|
save
|
|
153
165
|
end
|
|
166
|
+
@relay&.request(action, id, **detail) if !@writable && action
|
|
154
167
|
end
|
|
155
168
|
|
|
156
169
|
def load
|
|
@@ -158,7 +171,7 @@ module ClaudeInbox
|
|
|
158
171
|
end
|
|
159
172
|
|
|
160
173
|
def save
|
|
161
|
-
return unless @path
|
|
174
|
+
return unless @path && @writable
|
|
162
175
|
Records.save(@path, {"version" => 1, "sessions" => @entries})
|
|
163
176
|
end
|
|
164
177
|
end
|
|
@@ -10,8 +10,16 @@ module ClaudeInbox
|
|
|
10
10
|
def success? = status.success?
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# Children start in the environment from before Bundler, or every ruby a
|
|
14
|
+
# session runs, in any project, loads our Gemfile. Handed to exec rather
|
|
15
|
+
# than set on ENV, which the poll and background threads share.
|
|
16
|
+
CHILD_ENV = defined?(Bundler.unbundled_env) ? Bundler.unbundled_env.freeze : nil
|
|
17
|
+
|
|
13
18
|
module_function
|
|
14
19
|
|
|
20
|
+
# argv as exec and Process.spawn take it, with CHILD_ENV in place of ours.
|
|
21
|
+
def command(*argv) = CHILD_ENV ? [CHILD_ENV, *argv, {unsetenv_others: true}] : argv
|
|
22
|
+
|
|
15
23
|
def capture(*argv, chdir: nil)
|
|
16
24
|
out_r, out_w = IO.pipe
|
|
17
25
|
err_r, err_w = IO.pipe
|
|
@@ -23,7 +31,7 @@ module ClaudeInbox
|
|
|
23
31
|
out_r.close
|
|
24
32
|
err_r.close
|
|
25
33
|
Dir.chdir(chdir) if chdir
|
|
26
|
-
exec(*argv)
|
|
34
|
+
exec(*command(*argv))
|
|
27
35
|
rescue SystemCallError => e
|
|
28
36
|
$stderr.write(e.message)
|
|
29
37
|
exit! 127
|
|
@@ -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
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "io/console"
|
|
4
4
|
require "tty-cursor"
|
|
5
5
|
require "tty-screen"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "painter"
|
|
7
7
|
|
|
8
8
|
module ClaudeInbox
|
|
9
9
|
# The screen the inbox draws on: the alt screen and the mouse and wheel
|
|
@@ -40,10 +40,16 @@ module ClaudeInbox
|
|
|
40
40
|
@size = nil
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# The tty's mode is kept as found, not assumed: `cooked!` sets a stock
|
|
44
|
+
# mode that differs from a shell's (istrip on, imaxbel off), and the
|
|
45
|
+
# shell inherits whatever is left on quit.
|
|
43
46
|
def enter
|
|
44
47
|
@out.print ALT_ON, WHEEL_KEYS_ON, MOUSE_ON, PASTE_ON, TTY::Cursor.hide, TTY::Cursor.clear_screen
|
|
45
48
|
@out.flush
|
|
46
|
-
|
|
49
|
+
if @input.respond_to?(:raw!) && @input.tty?
|
|
50
|
+
@mode = @input.console_mode
|
|
51
|
+
@input.raw!
|
|
52
|
+
end
|
|
47
53
|
@restored = false
|
|
48
54
|
resized
|
|
49
55
|
end
|
|
@@ -52,7 +58,7 @@ module ClaudeInbox
|
|
|
52
58
|
def restore
|
|
53
59
|
return if @restored
|
|
54
60
|
@restored = true
|
|
55
|
-
@input.
|
|
61
|
+
@input.console_mode = @mode if @mode && @input.tty?
|
|
56
62
|
@out.print TTY::Cursor.show, PASTE_OFF, MOUSE_OFF, WHEEL_KEYS_OFF, ALT_OFF
|
|
57
63
|
@out.flush
|
|
58
64
|
rescue
|
data/lib/claude_inbox/text.rb
CHANGED
|
@@ -13,6 +13,12 @@ module ClaudeInbox
|
|
|
13
13
|
|
|
14
14
|
def strip_ansi(s) = s.gsub(ANSI, "")
|
|
15
15
|
|
|
16
|
+
# Control characters as \xNN, so quoted text can't reach the terminal
|
|
17
|
+
# as an escape. Bytes that aren't UTF-8 become U+FFFD.
|
|
18
|
+
def printable(s)
|
|
19
|
+
String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD").gsub(/[[:cntrl:]]/) { |c| format("\\x%02x", c.ord) }
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
def width(s) = Unicode::DisplayWidth.of(strip_ansi(s))
|
|
17
23
|
|
|
18
24
|
# Truncate plain (uncolored) text to `w` columns, appending an ellipsis
|
|
@@ -57,7 +63,7 @@ module ClaudeInbox
|
|
|
57
63
|
s = truncate(strip_ansi(s), w)
|
|
58
64
|
cur = width(s)
|
|
59
65
|
end
|
|
60
|
-
s + (" " *
|
|
66
|
+
s + (" " * [w - cur, 0].max)
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
# Greedy word wrap on display width. Words wider than `w` are split.
|
|
@@ -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,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# The directories whose trust dialog has been accepted, read from where
|
|
7
|
+
# the CLI keeps the answer (see docs/cli-quirks.md). For choosing which
|
|
8
|
+
# directories to offer and in what order, never as a gate: whether a
|
|
9
|
+
# session may start somewhere stays the CLI's call.
|
|
10
|
+
module Trust
|
|
11
|
+
def self.projects(path: File.join(Dir.home, ".claude.json"))
|
|
12
|
+
data = JSON.parse(File.read(path))
|
|
13
|
+
projects = data["projects"] if data.is_a?(Hash)
|
|
14
|
+
return [] unless projects.is_a?(Hash)
|
|
15
|
+
projects.filter_map do |dir, entry|
|
|
16
|
+
dir if dir.start_with?("/") && !dir.include?("\0") &&
|
|
17
|
+
entry.is_a?(Hash) && entry["hasTrustDialogAccepted"] == true
|
|
18
|
+
end
|
|
19
|
+
rescue SystemCallError, IOError, JSON::ParserError
|
|
20
|
+
[]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -90,7 +90,7 @@ module ClaudeInbox
|
|
|
90
90
|
n = nums[0] || 1
|
|
91
91
|
case final
|
|
92
92
|
when "H", "f"
|
|
93
|
-
@row = (
|
|
93
|
+
@row = (n - 1).clamp(0, @rows - 1)
|
|
94
94
|
@col = ((nums[1] || 1) - 1).clamp(0, @cols - 1)
|
|
95
95
|
when "A" then @row = [@row - n, 0].max
|
|
96
96
|
when "B" then @row = [@row + n, @rows - 1].min
|
|
@@ -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"
|