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