claude-inbox 0.11.1 → 0.12.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 +3 -3
- data/lib/claude_inbox/app/spawning.rb +4 -4
- data/lib/claude_inbox/dialog.rb +8 -6
- data/lib/claude_inbox/job_state.rb +5 -2
- data/lib/claude_inbox/new_session_form.rb +3 -5
- data/lib/claude_inbox/remote/listener.rb +5 -3
- data/lib/claude_inbox/remote/page.html +3 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +1 -1
- data/lib/claude_inbox/remote/start.rb +3 -2
- data/lib/claude_inbox/renderer.rb +12 -11
- data/lib/claude_inbox/session_request.rb +18 -2
- data/lib/claude_inbox/snooze_time.rb +6 -3
- data/lib/claude_inbox/store/row.rb +15 -13
- data/lib/claude_inbox/subprocess.rb +1 -0
- data/lib/claude_inbox/theme.rb +3 -0
- data/lib/claude_inbox/updater.rb +8 -6
- data/lib/claude_inbox/writer_lock.rb +5 -3
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +4 -5
- data/mod/tests/pane.test.tsx +13 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cfb9f33ca25bbd7a081fcab7316c724af065f9d078cd9a329e4c5d2a4f25d49d
|
|
4
|
+
data.tar.gz: d733d6f8cd233cc37c0d4b0e43abe6f48936b17e1731b9d8d696990f8ded4046
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a4c6efddf42c8363e1f383941f854ec2ba35e38f2cfe687749aa4b18b88efe1fbb400cdcb2e3e727493daabda7c7b82753d637078b397ca7f8992305d6bb3df6
|
|
7
|
+
data.tar.gz: 7ddfe74e86730f795581cc454fc4c09a0b7f8b1b8f49c45e7e9cbabb504387c7411902177eb84b8578d36653b421dc0794afb01f6ae55a848fa2a657073b56e0
|
data/README.md
CHANGED
|
@@ -63,9 +63,9 @@ status line. A row with a pull request shows it as `#885 open`, `#885 merged`
|
|
|
63
63
|
and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
|
|
64
64
|
something is wrong, and `+2` after it when the session has two more.
|
|
65
65
|
A steady `◌` with `idle · 1 shell` means the agent stopped
|
|
66
|
-
and is waiting on a shell or sub-agent it started. A `●` marks a session you
|
|
67
|
-
haven't attached to since it got blocked
|
|
68
|
-
|
|
66
|
+
and is waiting on a shell or sub-agent it started. A blue `●` marks a session you
|
|
67
|
+
haven't attached to since it got blocked or finished; one you have attached
|
|
68
|
+
to since is dimmed, unless `/color` tints it.
|
|
69
69
|
|
|
70
70
|
## Keys
|
|
71
71
|
|
|
@@ -28,11 +28,11 @@ module ClaudeInbox
|
|
|
28
28
|
# it we stay in the inbox and poll, so the new row shows up right away
|
|
29
29
|
# rather than at the next tick.
|
|
30
30
|
def start_session(form, attach:)
|
|
31
|
-
v = form.values
|
|
31
|
+
v, note = SessionRequest.without_stray_worktree(form.values)
|
|
32
32
|
notice("starting session…")
|
|
33
33
|
in_background do
|
|
34
34
|
id = @client.spawn(**v)
|
|
35
|
-
@queue << [:notice, "started #{id}"]
|
|
35
|
+
@queue << [:notice, ["started #{id}", note].compact.join(" — ")]
|
|
36
36
|
@queue << [:form_started, form]
|
|
37
37
|
@queue << [:select, id]
|
|
38
38
|
attach ? @queue << [:attach, id] : @poller.soon
|
|
@@ -53,8 +53,8 @@ module ClaudeInbox
|
|
|
53
53
|
|
|
54
54
|
# Someone at the desk may be mid-thought, so a start from another
|
|
55
55
|
# device never moves the cursor, attaches or closes what is open.
|
|
56
|
-
def remote_started(id, via)
|
|
57
|
-
notice("started #{id} from #{via}")
|
|
56
|
+
def remote_started(id, via, note = nil)
|
|
57
|
+
notice(["started #{id} from #{via}", note].compact.join(" — "))
|
|
58
58
|
@poller.soon
|
|
59
59
|
end
|
|
60
60
|
end
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -38,6 +38,9 @@ module ClaudeInbox
|
|
|
38
38
|
# A key and what it does.
|
|
39
39
|
def hint(name, action) = "#{key(name)} #{@theme.dim(action)}"
|
|
40
40
|
|
|
41
|
+
# Esc or q closes a box. Prompt skips this, since q is text there.
|
|
42
|
+
def dismiss?(name, key) = name == :escape || key == "q"
|
|
43
|
+
|
|
41
44
|
# `s` snooze: one key per choice, and the choice is read from `choice`.
|
|
42
45
|
class Snooze < Dialog
|
|
43
46
|
MENU = [
|
|
@@ -57,7 +60,7 @@ module ClaudeInbox
|
|
|
57
60
|
def lines(_width, _caret) = MENU.map { |k, label, _| " #{key(k)} #{label}" } + ["", " " + hint("esc", "cancel")]
|
|
58
61
|
|
|
59
62
|
def press(name, key)
|
|
60
|
-
return :cancel if name
|
|
63
|
+
return :cancel if dismiss?(name, key)
|
|
61
64
|
entry = MENU.assoc(key)
|
|
62
65
|
return nil unless entry
|
|
63
66
|
@choice = entry[2]
|
|
@@ -70,7 +73,6 @@ module ClaudeInbox
|
|
|
70
73
|
class PrPicker < Dialog
|
|
71
74
|
MARGIN = 6
|
|
72
75
|
KEYS = {open: "o", copy: "y"}.freeze
|
|
73
|
-
VERBS = {open: "open", copy: "copy"}.freeze
|
|
74
76
|
|
|
75
77
|
attr_reader :choice
|
|
76
78
|
|
|
@@ -79,7 +81,7 @@ module ClaudeInbox
|
|
|
79
81
|
@prs = [session.pr, *session.prs.reject { |pr| pr.equal?(session.pr) }].first(9)
|
|
80
82
|
end
|
|
81
83
|
|
|
82
|
-
def title = " #{
|
|
84
|
+
def title = " #{kind.to_s.capitalize} pull request "
|
|
83
85
|
|
|
84
86
|
# Titles vary, so the box grows to the longest line plus some air,
|
|
85
87
|
# and never shrinks below a plain dialog.
|
|
@@ -87,12 +89,12 @@ module ClaudeInbox
|
|
|
87
89
|
|
|
88
90
|
def lines(width, _caret)
|
|
89
91
|
(@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
|
|
90
|
-
["", " #{hint(KEYS.fetch(kind), "#{
|
|
92
|
+
["", " #{hint(KEYS.fetch(kind), "#{kind} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
|
|
91
93
|
.map { |line| Text.truncate(line, width) }
|
|
92
94
|
end
|
|
93
95
|
|
|
94
96
|
def press(name, key)
|
|
95
|
-
return :cancel if name
|
|
97
|
+
return :cancel if dismiss?(name, key)
|
|
96
98
|
@choice = @prs.first if key == KEYS.fetch(kind) || name == :return || name == :enter
|
|
97
99
|
@choice = @prs[key.to_i - 1] if key.to_s.match?(/\A[1-9]\z/)
|
|
98
100
|
@choice && :pick
|
|
@@ -130,7 +132,7 @@ module ClaudeInbox
|
|
|
130
132
|
|
|
131
133
|
def press(name, key)
|
|
132
134
|
return :confirm if key == "y"
|
|
133
|
-
return :cancel if name
|
|
135
|
+
return :cancel if dismiss?(name, key) || key == "n"
|
|
134
136
|
nil
|
|
135
137
|
end
|
|
136
138
|
end
|
|
@@ -48,7 +48,7 @@ module ClaudeInbox
|
|
|
48
48
|
# => JobState, or nil when there is no readable file for this id.
|
|
49
49
|
def self.read(id, jobs_dir:)
|
|
50
50
|
return nil unless id
|
|
51
|
-
new(JSON.parse(File.read(
|
|
51
|
+
new(JSON.parse(File.read(job_file(id, jobs_dir))))
|
|
52
52
|
rescue JSON::ParserError, SystemCallError
|
|
53
53
|
nil
|
|
54
54
|
end
|
|
@@ -59,13 +59,16 @@ module ClaudeInbox
|
|
|
59
59
|
# must not report a started session as failed.
|
|
60
60
|
# ponytail: a daemon write racing this one can drop the color; re-apply on poll if that shows up.
|
|
61
61
|
def self.write_color(id, color, jobs_dir:)
|
|
62
|
-
path =
|
|
62
|
+
path = job_file(id, jobs_dir)
|
|
63
63
|
File.write("#{path}.tmp", JSON.generate(JSON.parse(File.read(path)).merge("color" => color)))
|
|
64
64
|
File.rename("#{path}.tmp", path)
|
|
65
65
|
rescue JSON::ParserError, SystemCallError
|
|
66
66
|
nil
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
def self.job_file(id, jobs_dir) = File.join(jobs_dir, id, "state.json")
|
|
70
|
+
private_class_method :job_file
|
|
71
|
+
|
|
69
72
|
attr_reader :pr_urls, :transcript_path, :color, :intent, :bridge_id, :created_at
|
|
70
73
|
|
|
71
74
|
def initialize(hash)
|
|
@@ -169,10 +169,10 @@ module ClaudeInbox
|
|
|
169
169
|
|
|
170
170
|
def footer
|
|
171
171
|
return @p.dim("starting session…") if @busy
|
|
172
|
-
return hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
|
|
172
|
+
return @theme.hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
|
|
173
173
|
return @theme.red(@error) if @error
|
|
174
174
|
return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
|
|
175
|
-
return hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
|
|
175
|
+
return @theme.hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
|
|
176
176
|
keys =
|
|
177
177
|
case focused.kind
|
|
178
178
|
when :multiline then [["⏎", "newline"], ["^V", "image"]]
|
|
@@ -182,7 +182,7 @@ module ClaudeInbox
|
|
|
182
182
|
keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
|
|
183
183
|
keys += [["^S", "start"], ["^O", "start & open"],
|
|
184
184
|
["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
|
|
185
|
-
hints(keys)
|
|
185
|
+
@theme.hints(keys)
|
|
186
186
|
end
|
|
187
187
|
|
|
188
188
|
# The App's `claude --bg` call failed after `:start`/`:start_and_attach`
|
|
@@ -299,8 +299,6 @@ module ClaudeInbox
|
|
|
299
299
|
rows
|
|
300
300
|
end
|
|
301
301
|
|
|
302
|
-
def hints(pairs) = pairs.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
303
|
-
|
|
304
302
|
def field_label(f)
|
|
305
303
|
on = f.equal?(focused)
|
|
306
304
|
(on ? @theme.yellow_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
|
|
@@ -322,14 +322,16 @@ module ClaudeInbox
|
|
|
322
322
|
if slot[:busy] then @turning_away -= 1
|
|
323
323
|
else
|
|
324
324
|
@alive -= 1
|
|
325
|
-
release_unauthenticated(slot
|
|
325
|
+
release_unauthenticated(slot)
|
|
326
326
|
end
|
|
327
327
|
end
|
|
328
328
|
end
|
|
329
329
|
|
|
330
330
|
# Under the mutex. A host leaves the hash at zero, so it holds only
|
|
331
331
|
# hosts connected now.
|
|
332
|
-
def release_unauthenticated(
|
|
332
|
+
def release_unauthenticated(slot)
|
|
333
|
+
return if slot[:authenticated]
|
|
334
|
+
via = slot[:via]
|
|
333
335
|
@unauthenticated[via] -= 1
|
|
334
336
|
@unauthenticated.delete(via) if @unauthenticated[via].zero?
|
|
335
337
|
end
|
|
@@ -353,7 +355,7 @@ module ClaudeInbox
|
|
|
353
355
|
|
|
354
356
|
def authenticated(slot)
|
|
355
357
|
@mutex.synchronize do
|
|
356
|
-
release_unauthenticated(slot
|
|
358
|
+
release_unauthenticated(slot)
|
|
357
359
|
slot[:authenticated] = true
|
|
358
360
|
end
|
|
359
361
|
end
|
|
@@ -140,6 +140,7 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
140
140
|
<section id="done" class="card" hidden>
|
|
141
141
|
<p>Started <code id="done-id"></code></p>
|
|
142
142
|
<p class="muted" id="done-where"></p>
|
|
143
|
+
<p class="muted" id="done-note" hidden></p>
|
|
143
144
|
<p class="muted" id="done-later">claude.ai/code lists it once it has registered, in a moment.</p>
|
|
144
145
|
<div class="actions">
|
|
145
146
|
<a class="button" id="done-url" target="_blank" rel="noopener">Open in Claude</a>
|
|
@@ -421,6 +422,8 @@ function started(session, remote) {
|
|
|
421
422
|
$("name").value = "";
|
|
422
423
|
$("done-id").textContent = session.id;
|
|
423
424
|
$("done-where").textContent = [session.name, session.cwd].filter(Boolean).join(" · ");
|
|
425
|
+
$("done-note").textContent = session.note || "";
|
|
426
|
+
$("done-note").hidden = !session.note;
|
|
424
427
|
const link = $("done-url");
|
|
425
428
|
link.hidden = !/^https:\/\//.test(session.url || "");
|
|
426
429
|
if (!link.hidden) link.href = session.url;
|
|
@@ -41,7 +41,7 @@ module ClaudeInbox
|
|
|
41
41
|
@asking = false
|
|
42
42
|
return (key == "y") ? :rotate : nil
|
|
43
43
|
end
|
|
44
|
-
return :cancel if name
|
|
44
|
+
return :cancel if dismiss?(name, key)
|
|
45
45
|
return nil unless @snapshot.call.state == :listening
|
|
46
46
|
case key
|
|
47
47
|
when "c" then :copy
|
|
@@ -156,11 +156,12 @@ module ClaudeInbox
|
|
|
156
156
|
values[:permission_mode] = capped_mode(values[:permission_mode] || defaults.permission_mode || builtin_mode)
|
|
157
157
|
paths = images.each_with_index.map { |bytes, i| Images.save(bytes, dir: @images_dir, index: i + 1) }
|
|
158
158
|
values[:prompt] = SessionRequest.attach(values[:prompt], paths)
|
|
159
|
+
values, note = SessionRequest.without_stray_worktree(values)
|
|
159
160
|
@queue << [:notice, "remote: starting session…"]
|
|
160
161
|
id = Http.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
|
|
161
162
|
url = remote_url(id, wait: values[:remote] ? @bridge_wait : 0)
|
|
162
|
-
@queue << [:remote_started, id, via]
|
|
163
|
-
{id: id, name: values[:name], cwd: values[:cwd], url: url}
|
|
163
|
+
@queue << [:remote_started, id, via, note].compact
|
|
164
|
+
{id: id, name: values[:name], cwd: values[:cwd], url: url}.merge(note ? {note: note} : {})
|
|
164
165
|
rescue SessionRequest::Invalid => e
|
|
165
166
|
raise Http::Error.new(422, e.message, field: e.field)
|
|
166
167
|
end
|
|
@@ -198,7 +198,7 @@ module ClaudeInbox
|
|
|
198
198
|
text =
|
|
199
199
|
if view.filter_editing then " " + @theme.cyan_bold("/") + view.filter.row(width - 2, cursor: caret)
|
|
200
200
|
elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
|
|
201
|
-
else " " +
|
|
201
|
+
else " " + @theme.hints(footer_keys(view))
|
|
202
202
|
end
|
|
203
203
|
Text.pad(text, width)
|
|
204
204
|
end
|
|
@@ -250,7 +250,7 @@ module ClaudeInbox
|
|
|
250
250
|
def loading_state(width, height, waited, tick)
|
|
251
251
|
return [] if waited < LOADING_QUIET
|
|
252
252
|
block = [
|
|
253
|
-
centered(@theme.cyan_bold(
|
|
253
|
+
centered(@theme.cyan_bold(spinner(tick)), width),
|
|
254
254
|
"",
|
|
255
255
|
centered(tray(tick), width),
|
|
256
256
|
"",
|
|
@@ -263,6 +263,8 @@ module ClaudeInbox
|
|
|
263
263
|
[""] * [(height - block.size) / 2, 0].max + block
|
|
264
264
|
end
|
|
265
265
|
|
|
266
|
+
def spinner(tick) = SPINNER[tick % SPINNER.size]
|
|
267
|
+
|
|
266
268
|
TRAY_SLOTS = 9
|
|
267
269
|
|
|
268
270
|
# A dot bouncing between the ends of a tray, one slot per tick.
|
|
@@ -302,8 +304,8 @@ module ClaudeInbox
|
|
|
302
304
|
quiet = section == :settled || (live && row.read?)
|
|
303
305
|
unread = live && row.unread?
|
|
304
306
|
# A terminal cannot be attached to, so it is never read: while it waits on you it keeps its dot.
|
|
305
|
-
|
|
306
|
-
marker = marker_for(sel,
|
|
307
|
+
dotted = unread || (live && s.needs_you? && !s.actionable?)
|
|
308
|
+
marker = marker_for(sel, dotted)
|
|
307
309
|
glyph = glyph_for(s, section, tick)
|
|
308
310
|
meta = meta_for(row, section, now)
|
|
309
311
|
|
|
@@ -328,13 +330,12 @@ module ClaudeInbox
|
|
|
328
330
|
[Text.pad(first, width), Text.pad(detail, width)]
|
|
329
331
|
end
|
|
330
332
|
|
|
331
|
-
# The caret takes the column. Otherwise
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
def marker_for(sel,
|
|
333
|
+
# The caret takes the column. Otherwise a blue dot asks for a look, in
|
|
334
|
+
# every state: glyph and meta already say needs you in red, so the dot
|
|
335
|
+
# only says unread. Read, it clears.
|
|
336
|
+
def marker_for(sel, dotted)
|
|
335
337
|
if sel then @theme.yellow_bold("▶")
|
|
336
|
-
elsif
|
|
337
|
-
elsif unread then @theme.blue("●")
|
|
338
|
+
elsif dotted then @theme.blue("●")
|
|
338
339
|
else " "
|
|
339
340
|
end
|
|
340
341
|
end
|
|
@@ -362,7 +363,7 @@ module ClaudeInbox
|
|
|
362
363
|
case s.effective_state
|
|
363
364
|
when "blocked" then @theme.red_bold("?")
|
|
364
365
|
when "failed" then @theme.red_bold("✗")
|
|
365
|
-
when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(
|
|
366
|
+
when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(spinner(tick))
|
|
366
367
|
when "done" then @theme.green("✓")
|
|
367
368
|
when "stopped" then @p.dim("■")
|
|
368
369
|
else @p.dim("·")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
3
4
|
require_relative "agents_client"
|
|
4
5
|
|
|
5
6
|
module ClaudeInbox
|
|
@@ -25,6 +26,7 @@ module ClaudeInbox
|
|
|
25
26
|
FLAGS = {true => true, false => false, "yes" => true, "no" => false}.freeze
|
|
26
27
|
MAX_PROMPT = 100_000
|
|
27
28
|
MAX_NAME = 100
|
|
29
|
+
NOT_ONE_LINE = /[[:cntrl:]\u2028\u2029]/
|
|
28
30
|
|
|
29
31
|
# The hash NewSessionForm#values builds, before `resolve`, from a
|
|
30
32
|
# request's fields, or Invalid. "default" is nil. An unknown key is
|
|
@@ -77,6 +79,20 @@ module ClaudeInbox
|
|
|
77
79
|
[text, rest.join("\n")].reject(&:empty?).join("\n\n")
|
|
78
80
|
end
|
|
79
81
|
|
|
82
|
+
# `claude --bg --worktree` outside a git repository dies before init
|
|
83
|
+
# (docs/cli-quirks.md). A folder above several repos is a fair place to
|
|
84
|
+
# start from, so there the session starts without a worktree and the
|
|
85
|
+
# note says so.
|
|
86
|
+
def self.without_stray_worktree(values)
|
|
87
|
+
return [values, nil] unless values[:worktree] && !inside_repo?(values[:cwd])
|
|
88
|
+
[values.merge(worktree: false), "not in a git repo, so no worktree"]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A worktree or a submodule has a `.git` file rather than a folder.
|
|
92
|
+
def self.inside_repo?(dir)
|
|
93
|
+
Pathname(File.expand_path(dir)).ascend.any? { |d| (d + ".git").exist? }
|
|
94
|
+
end
|
|
95
|
+
|
|
80
96
|
# A session's cwd may sit inside a worktree another agent is using;
|
|
81
97
|
# carrying that into a new prompt would spawn the new agent there too,
|
|
82
98
|
# writing over the same files. Fall back to the repo it was cut from.
|
|
@@ -108,7 +124,7 @@ module ClaudeInbox
|
|
|
108
124
|
def self.name_param(value)
|
|
109
125
|
return nil if value.nil?
|
|
110
126
|
name = string_param(:name, value).strip
|
|
111
|
-
raise Invalid.new(:name, "a name is one line") if name.match?(
|
|
127
|
+
raise Invalid.new(:name, "a name is one line") if name.match?(NOT_ONE_LINE)
|
|
112
128
|
raise Invalid.new(:name, "the name is over #{MAX_NAME} characters") if name.length > MAX_NAME
|
|
113
129
|
name.empty? ? nil : name
|
|
114
130
|
end
|
|
@@ -128,7 +144,7 @@ module ClaudeInbox
|
|
|
128
144
|
def self.cwd_param(value, dirs)
|
|
129
145
|
cwd = string_param(:cwd, value || "").strip
|
|
130
146
|
raise Invalid.new(:cwd, "a directory is required") if cwd.empty?
|
|
131
|
-
raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(
|
|
147
|
+
raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(NOT_ONE_LINE)
|
|
132
148
|
return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
|
|
133
149
|
found = dirs.select { |dir| dir.end_with?("/#{cwd}") }
|
|
134
150
|
raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
|
|
@@ -28,8 +28,8 @@ module ClaudeInbox
|
|
|
28
28
|
case head
|
|
29
29
|
when nil then nil
|
|
30
30
|
when "tonight", "evening" then rest.nil? ? next_at(now, EVENING) : nil
|
|
31
|
-
when "tomorrow" then (hm =
|
|
32
|
-
when WEEKDAY then (hm =
|
|
31
|
+
when "tomorrow" then (hm = time_of_day(rest)) && at(now, 1, hm)
|
|
32
|
+
when WEEKDAY then (hm = time_of_day(rest)) && weekday(now, WEEKDAYS[head[0, 3]], hm)
|
|
33
33
|
else (hm = clock(text)) && next_at(now, hm)
|
|
34
34
|
end
|
|
35
35
|
end
|
|
@@ -49,6 +49,9 @@ module ClaudeInbox
|
|
|
49
49
|
[hour, minute]
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# A day's snooze: the time after the word, else 9am.
|
|
53
|
+
def self.time_of_day(rest) = rest ? clock(rest) : MORNING
|
|
54
|
+
|
|
52
55
|
# That day at `hm`, or a week on when it has already passed.
|
|
53
56
|
def self.weekday(now, wday, hm)
|
|
54
57
|
t = at(now, (wday - now.wday) % 7, hm)
|
|
@@ -65,6 +68,6 @@ module ClaudeInbox
|
|
|
65
68
|
day = now.to_date + days
|
|
66
69
|
Time.new(day.year, day.month, day.day, hour, minute, 0, now.utc_offset).to_i
|
|
67
70
|
end
|
|
68
|
-
private_class_method :clock, :weekday, :next_at, :at
|
|
71
|
+
private_class_method :clock, :time_of_day, :weekday, :next_at, :at
|
|
69
72
|
end
|
|
70
73
|
end
|
|
@@ -46,31 +46,29 @@ module ClaudeInbox
|
|
|
46
46
|
# `x` holds through the session finishing; any other state change afterwards lifts it.
|
|
47
47
|
def hand_settled?
|
|
48
48
|
return false unless entry&.settled_at
|
|
49
|
-
session.finished? || entry.
|
|
49
|
+
session.finished? || unchanged_since?(entry.settled_at)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
# Attaching marks the current state seen: out of Needs You, but into
|
|
53
53
|
# Active rather than Settled, until the state changes again.
|
|
54
|
-
def acknowledged?
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
def acknowledged? = unchanged_since?(entry&.acknowledged_at)
|
|
55
|
+
|
|
56
|
+
# Finished or needing you, so it carries a read or unread mark. Only a
|
|
57
|
+
# session you can attach to counts, since nothing else ever clears it.
|
|
58
|
+
def markable? = session.actionable? && (session.finished? || session.needs_you?)
|
|
57
59
|
|
|
58
|
-
# Finished or needing you since you last attached.
|
|
59
|
-
# can attach to counts, since nothing else ever clears the mark.
|
|
60
|
+
# Finished or needing you since you last attached.
|
|
60
61
|
def unread?
|
|
61
|
-
return false unless
|
|
62
|
-
|
|
63
|
-
entry&.last_visited_at.nil? || entry.state_since.to_i > entry.last_visited_at.to_i
|
|
62
|
+
return false unless markable?
|
|
63
|
+
!unchanged_since?(entry&.last_visited_at)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# A finished or blocked session you have attached to since: you have seen what it wants.
|
|
67
|
-
def read? =
|
|
67
|
+
def read? = markable? && !unread?
|
|
68
68
|
|
|
69
69
|
# `u` overrides hand-settle and the PR rule alike, and holds until the
|
|
70
70
|
# state actually changes rather than just for the next poll.
|
|
71
|
-
def revived?
|
|
72
|
-
entry&.revived_at && entry.state_since.to_i <= entry.revived_at.to_i
|
|
73
|
-
end
|
|
71
|
+
def revived? = unchanged_since?(entry&.revived_at)
|
|
74
72
|
|
|
75
73
|
# Failing checks or requested changes on the PR want you back the way
|
|
76
74
|
# a blocked session does, once the agent has stopped working on it.
|
|
@@ -119,6 +117,10 @@ module ClaudeInbox
|
|
|
119
117
|
else :active
|
|
120
118
|
end
|
|
121
119
|
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def unchanged_since?(at) = at && entry.state_since.to_i <= at.to_i
|
|
122
124
|
end
|
|
123
125
|
end
|
|
124
126
|
end
|
data/lib/claude_inbox/theme.rb
CHANGED
|
@@ -38,6 +38,9 @@ module ClaudeInbox
|
|
|
38
38
|
# or choice — the inverse of `color`, which puts the hue on the text.
|
|
39
39
|
def pill(text, hue) = "\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
|
|
40
40
|
|
|
41
|
+
# A footer's key hints: `n new N pair`.
|
|
42
|
+
def hints(pairs) = pairs.map { |k, d| cyan_bold(k) + " " + dim(d) }.join(" ")
|
|
43
|
+
|
|
41
44
|
private
|
|
42
45
|
|
|
43
46
|
def color(text, hue, bold: false)
|
data/lib/claude_inbox/updater.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "net/http"
|
|
|
6
6
|
require "rbconfig"
|
|
7
7
|
require_relative "../claude_inbox"
|
|
8
8
|
require_relative "mod"
|
|
9
|
+
require_relative "records"
|
|
9
10
|
require_relative "subprocess"
|
|
10
11
|
|
|
11
12
|
module ClaudeInbox
|
|
@@ -33,6 +34,8 @@ module ClaudeInbox
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
Applied = Data.define(:success, :output) do
|
|
37
|
+
def self.from(result) = new(success: result.success?, output: result.out + result.err)
|
|
38
|
+
|
|
36
39
|
def success? = success
|
|
37
40
|
end
|
|
38
41
|
|
|
@@ -105,7 +108,7 @@ module ClaudeInbox
|
|
|
105
108
|
|
|
106
109
|
def install_gem
|
|
107
110
|
result = @run.call(Gem.ruby, File.join(RbConfig::CONFIG["bindir"], "gem"), "install", "claude-inbox")
|
|
108
|
-
Applied.
|
|
111
|
+
Applied.from(result)
|
|
109
112
|
end
|
|
110
113
|
|
|
111
114
|
def gem_update(latest)
|
|
@@ -140,7 +143,7 @@ module ClaudeInbox
|
|
|
140
143
|
return Applied.new(success: false, output: "not on main (on #{branch})") unless branch == "main"
|
|
141
144
|
return Applied.new(success: false, output: "the working tree is dirty") unless git("status", "--porcelain") == ""
|
|
142
145
|
merged = @run.call("git", "-C", @root, "merge", "--ff-only", "--quiet", "main@{upstream}")
|
|
143
|
-
Applied.
|
|
146
|
+
Applied.from(merged)
|
|
144
147
|
end
|
|
145
148
|
|
|
146
149
|
def git(*args)
|
|
@@ -149,15 +152,14 @@ module ClaudeInbox
|
|
|
149
152
|
end
|
|
150
153
|
|
|
151
154
|
def fresh_cache
|
|
152
|
-
cache =
|
|
155
|
+
cache = Records.read(@cache_path)
|
|
153
156
|
cache["latest"] if cache["kind"] == kind.to_s && @clock.call.to_i - cache["checked_at"].to_i < FRESH_FOR
|
|
154
|
-
rescue SystemCallError,
|
|
157
|
+
rescue SystemCallError, TypeError
|
|
155
158
|
nil
|
|
156
159
|
end
|
|
157
160
|
|
|
158
161
|
def write_cache(latest)
|
|
159
|
-
|
|
160
|
-
File.write(@cache_path, JSON.generate(checked_at: @clock.call.to_i, kind: kind, latest: latest))
|
|
162
|
+
Records.save(@cache_path, {checked_at: @clock.call.to_i, kind: kind, latest: latest})
|
|
161
163
|
end
|
|
162
164
|
end
|
|
163
165
|
end
|
|
@@ -24,18 +24,18 @@ module ClaudeInbox
|
|
|
24
24
|
return true unless @path
|
|
25
25
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
26
26
|
file = File.open(@path, File::RDWR | File::CREAT, 0o600)
|
|
27
|
-
return hold(file, role) if file
|
|
27
|
+
return hold(file, role) if try_lock(file)
|
|
28
28
|
holder_role, holder = file.read.split
|
|
29
29
|
return refuse(file) unless role == "inbox" && holder_role == "headless" && headless?(holder.to_i)
|
|
30
30
|
Process.kill("TERM", holder.to_i)
|
|
31
31
|
EVICT_TRIES.times do
|
|
32
|
-
return hold(file, role) if file
|
|
32
|
+
return hold(file, role) if try_lock(file)
|
|
33
33
|
sleep 0.1
|
|
34
34
|
end
|
|
35
35
|
refuse(file)
|
|
36
36
|
rescue Errno::ESRCH, Errno::EPERM
|
|
37
37
|
# The holder the file names is gone already: the lock is free.
|
|
38
|
-
file
|
|
38
|
+
try_lock(file) ? hold(file, role) : refuse(file)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
private
|
|
@@ -48,6 +48,8 @@ module ClaudeInbox
|
|
|
48
48
|
command.include?("claude-inbox") && command.split.include?("--headless")
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def try_lock(file) = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
52
|
+
|
|
51
53
|
# After reading the holder the position sits at the end, and truncate does
|
|
52
54
|
# not move it, so the write is rewound first or it lands after NUL padding.
|
|
53
55
|
def hold(file, role)
|
data/lib/claude_inbox.rb
CHANGED
data/mod/hooks/register.tsx
CHANGED
|
@@ -460,17 +460,16 @@ export const register: Register = (on, options) => {
|
|
|
460
460
|
// A finished or blocked row you have attached to since dims, as the gem's Active section draws it,
|
|
461
461
|
// unless it is tinted: faint ink on a /color background is unreadable.
|
|
462
462
|
const isRead = !isQuiet && one.actionable && ['done', 'stopped', 'blocked', 'failed'].includes(one.state) && !one.isUnread
|
|
463
|
-
//
|
|
464
|
-
// cannot be attached to, so it is never read and keeps its dot while it waits.
|
|
463
|
+
// A blue dot asks for a look in every state, as the gem draws it: the glyph already says needs you
|
|
464
|
+
// in red. A terminal cannot be attached to, so it is never read and keeps its dot while it waits.
|
|
465
465
|
const needsYou = one.state === 'blocked' || one.state === 'failed'
|
|
466
|
-
const
|
|
467
|
-
const dot = alert || (one.isUnread && !isQuiet)
|
|
466
|
+
const dot = !isQuiet && (one.isUnread || (needsYou && !one.actionable))
|
|
468
467
|
const text = one.wakeAt !== undefined ? `${one.label} · ${wakeLabel(one.wakeAt)}` : one.label
|
|
469
468
|
const tint = one.color ? TINT[one.color] : undefined
|
|
470
469
|
return (
|
|
471
470
|
<Box key={viewKey(item)} flexDirection="column">
|
|
472
471
|
<Box>
|
|
473
|
-
<Text color={isHere ? 'yellow' :
|
|
472
|
+
<Text color={isHere ? 'yellow' : 'blue'}>{isHere ? '› ' : dot ? '● ' : ' '}</Text>
|
|
474
473
|
<Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
|
|
475
474
|
{GLYPH[one.state] ?? '·'}{' '}
|
|
476
475
|
</Text>
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -87,6 +87,19 @@ test('a poll takes the notice from the snapshot', async ($, on) => {
|
|
|
87
87
|
expect(set.update).toBe('v0.6.0 available')
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
test('an unread row that needs you takes the same blue dot as one that only finished', async ($, on) => {
|
|
91
|
+
mock.env(on, { HOME: '/Users/me' })
|
|
92
|
+
mock.clock(on, { now: 1_700_000_000_000 })
|
|
93
|
+
on('state.get', ($, e, next) => {
|
|
94
|
+
if (e.key === 'rows') return seeded([{ ...ROWS[0], isUnread: true }, { ...ROWS[0], id: 'a2', isUnread: true }])
|
|
95
|
+
if (e.key === 'writtenAt') return seeded(1_700_000_000)
|
|
96
|
+
return next(e)
|
|
97
|
+
})
|
|
98
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
99
|
+
// The caret takes one row's column; the header's red needs-you chip is no row's dot.
|
|
100
|
+
expect((await ui.findAll({ type: 'Text', text: '● ' })).filter(dot => dot.props.color === 'blue')).toHaveLength(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
90
103
|
test("rows sit under the gem's sections, Settled folded until za opens it", async ($, on) => {
|
|
91
104
|
mock.env(on, { HOME: '/Users/me' })
|
|
92
105
|
mock.clock(on, { now: 1_700_000_000_000 })
|