claude-inbox 0.11.2 → 0.12.1
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 +5 -4
- data/lib/claude_inbox/app/attaching.rb +4 -3
- data/lib/claude_inbox/app/navigating.rb +3 -0
- data/lib/claude_inbox/app.rb +5 -0
- data/lib/claude_inbox/dialog.rb +22 -9
- data/lib/claude_inbox/input.rb +11 -1
- data/lib/claude_inbox/job_state.rb +5 -2
- data/lib/claude_inbox/new_session_form.rb +8 -5
- data/lib/claude_inbox/remote/listener.rb +10 -6
- data/lib/claude_inbox/remote/pairing_dialog.rb +2 -2
- data/lib/claude_inbox/renderer.rb +12 -11
- data/lib/claude_inbox/session_request.rb +8 -5
- 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: 58a50d40ba0f8df6b8b53f3613a75b5982871eaa3c6f9d0c29c46411a1e70323
|
|
4
|
+
data.tar.gz: f8abcc49a7a5d61d339189f79c0f7a62d800b98531ce1e82060efda0a91e0d2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c994b3434e3e45fbf60f6c472227fb97610ae8355bb0dd166fb49c83713025fc8be70d2f06660e4475aa0606604a6860ce063c2d721fb836c048e7f18393b86
|
|
7
|
+
data.tar.gz: 591b90b883b64fc4494d8dec115958c618cf88ad13fd5cd3e9a125cbba6364df80b7c370d6d49c6db97bb74e65905882ede46bf9fdcab4b2201d39bc1e582d19
|
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
|
|
|
@@ -105,7 +105,8 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
105
105
|
`n` opens a form for the prompt, name, directory, model, effort, permissions,
|
|
106
106
|
worktree, Remote Control and color, which tints the row as `/color` would.
|
|
107
107
|
`Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
|
|
108
|
-
it and attaches.
|
|
108
|
+
it and attaches. `Option-S` or `Option-O` by mistake only says which key you
|
|
109
|
+
meant; it no longer reads as `Esc` and asks to discard the prompt.
|
|
109
110
|
|
|
110
111
|
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
111
112
|
`[Image #1]`. Type `/` for a menu of your skills.
|
|
@@ -53,7 +53,7 @@ module ClaudeInbox
|
|
|
53
53
|
|
|
54
54
|
def open_confirm(kind)
|
|
55
55
|
return unless require_actionable
|
|
56
|
-
@modal = Dialog::Confirm.new(kind, @selected.key)
|
|
56
|
+
@modal = Dialog::Confirm.new(kind, @selected.key, label_for(@selected.key))
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def stop_session(id)
|
|
@@ -64,11 +64,12 @@ module ClaudeInbox
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def delete_session(id)
|
|
67
|
-
|
|
67
|
+
label = label_for(id)
|
|
68
|
+
notice("deleting #{label}…")
|
|
68
69
|
in_background do
|
|
69
70
|
@client.rm(id)
|
|
70
71
|
@store.forget(id)
|
|
71
|
-
@queue << [:notice, "deleted #{
|
|
72
|
+
@queue << [:notice, "deleted #{label}"]
|
|
72
73
|
@poller.soon
|
|
73
74
|
end
|
|
74
75
|
end
|
|
@@ -38,6 +38,9 @@ module ClaudeInbox
|
|
|
38
38
|
|
|
39
39
|
def selected_session = session_for(@selected&.key)
|
|
40
40
|
|
|
41
|
+
# The alias if one is set, as the row shows it, rather than the bare id.
|
|
42
|
+
def label_for(key) = session_for(key)&.then { |s| @store.row(s).label } || key
|
|
43
|
+
|
|
41
44
|
def page = [Renderer.body_height(@terminal.size[1]), 1].max
|
|
42
45
|
|
|
43
46
|
def move(delta)
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -173,7 +173,12 @@ module ClaudeInbox
|
|
|
173
173
|
@input.typed(text).each { |name, c| handle_key(name, c) } if typing
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
# Only the form gives an Option chord a meaning; anywhere else it is the
|
|
177
|
+
# Esc and letter it was before Input named it.
|
|
176
178
|
def handle_key(name, key)
|
|
179
|
+
if Input.alt?(name) && !(@modal.is_a?(NewSessionForm) && NewSessionForm::SLIPS.key?(name))
|
|
180
|
+
return Input.unglued(key).each { |n, k| handle_key(n, k) }
|
|
181
|
+
end
|
|
177
182
|
return handle_modal_key(name, key) if @modal
|
|
178
183
|
return handle_line_key(name, key) if filter_editing?
|
|
179
184
|
|
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
|
|
@@ -114,13 +116,19 @@ module ClaudeInbox
|
|
|
114
116
|
class Confirm < Dialog
|
|
115
117
|
TITLES = {stop: " Stop ", delete: " Delete ", adopt: " Adopt "}.freeze
|
|
116
118
|
|
|
119
|
+
# `label` is the name the list shows; the id is what App acts on.
|
|
120
|
+
def initialize(kind, id, label = id)
|
|
121
|
+
super(kind, id)
|
|
122
|
+
@label = label
|
|
123
|
+
end
|
|
124
|
+
|
|
117
125
|
def title = TITLES.fetch(kind)
|
|
118
126
|
|
|
119
|
-
def lines(
|
|
127
|
+
def lines(width, _caret)
|
|
120
128
|
case kind
|
|
121
|
-
when :stop then ["
|
|
129
|
+
when :stop then [ask("Stop", width), "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
|
|
122
130
|
when :delete
|
|
123
|
-
["
|
|
131
|
+
[ask("Delete", width), " Its worktree and conversation", " go with it.",
|
|
124
132
|
"", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
|
|
125
133
|
when :adopt
|
|
126
134
|
[" Pull this session into the daemon?", " Its phone session ends and a",
|
|
@@ -130,9 +138,14 @@ module ClaudeInbox
|
|
|
130
138
|
|
|
131
139
|
def press(name, key)
|
|
132
140
|
return :confirm if key == "y"
|
|
133
|
-
return :cancel if name
|
|
141
|
+
return :cancel if dismiss?(name, key) || key == "n"
|
|
134
142
|
nil
|
|
135
143
|
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# One line, so a long name is cut rather than wrapped mid-word.
|
|
148
|
+
def ask(verb, width) = " #{verb} #{Text.truncate(@label.to_s, width - Text.width(verb) - 4)}?"
|
|
136
149
|
end
|
|
137
150
|
|
|
138
151
|
# The first launch without the Claude Code pane asks once whether to add
|
data/lib/claude_inbox/input.rb
CHANGED
|
@@ -9,6 +9,15 @@ module ClaudeInbox
|
|
|
9
9
|
# keys. Hides what tty-reader does with ESC, echo and a paste, so App sees
|
|
10
10
|
# events and not the raw bytes.
|
|
11
11
|
class Input
|
|
12
|
+
# macOS sends Option-s as "\es", so an Esc glued to one lowercase letter
|
|
13
|
+
# is taken as Option held with it.
|
|
14
|
+
ALT = /\A\e([a-z])\z/
|
|
15
|
+
|
|
16
|
+
def self.alt?(name) = name.to_s.start_with?("alt_")
|
|
17
|
+
|
|
18
|
+
# An Option chord as the quick Esc-then-letter it may also have been.
|
|
19
|
+
def self.unglued(raw) = [[:escape, "\e"], [raw[1], raw[1]]]
|
|
20
|
+
|
|
12
21
|
def initialize(input, out)
|
|
13
22
|
@input = input
|
|
14
23
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
@@ -51,7 +60,7 @@ module ClaudeInbox
|
|
|
51
60
|
out
|
|
52
61
|
end
|
|
53
62
|
|
|
54
|
-
def key_name(key) = @reader.console.keys[key] || key
|
|
63
|
+
def key_name(key) = @reader.console.keys[key] || key[ALT, 1]&.then { |c| :"alt_#{c}" } || key
|
|
55
64
|
|
|
56
65
|
# tty-reader glues ESC to whatever arrives within 100ms, so a fast
|
|
57
66
|
# "esc gg" comes in as one unknown key "\egg". Vim hands make that
|
|
@@ -59,6 +68,7 @@ module ClaudeInbox
|
|
|
59
68
|
def split_keys(key)
|
|
60
69
|
return [key] if key.size <= 1 || @reader.console.keys.key?(key)
|
|
61
70
|
return [key] unless key.start_with?("\e")
|
|
71
|
+
return [key] if key.match?(ALT)
|
|
62
72
|
["\e"] + key[1..].chars
|
|
63
73
|
end
|
|
64
74
|
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)
|
|
@@ -35,6 +35,10 @@ module ClaudeInbox
|
|
|
35
35
|
# editable field spends these on its own text instead.
|
|
36
36
|
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
37
37
|
|
|
38
|
+
# Option slips for Ctrl on the two submit keys. Option-s arrives as Esc
|
|
39
|
+
# then s, and that Esc asked to discard the prompt.
|
|
40
|
+
SLIPS = {alt_s: "that's option-s — ctrl-s starts it", alt_o: "that's option-o — ctrl-o starts and attaches"}.freeze
|
|
41
|
+
|
|
38
42
|
def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
39
43
|
@p = Pastel.new(enabled: true)
|
|
40
44
|
@theme = Theme.new
|
|
@@ -83,6 +87,7 @@ module ClaudeInbox
|
|
|
83
87
|
when :escape then return escape_pressed
|
|
84
88
|
when :ctrl_s then return submit(attach: false)
|
|
85
89
|
when :ctrl_o then return submit(attach: true)
|
|
90
|
+
when *SLIPS.keys then @error = SLIPS[name]
|
|
86
91
|
when :ctrl_v then paste_clipboard
|
|
87
92
|
when :tab then complete_dir || move(1)
|
|
88
93
|
when :down then move(1)
|
|
@@ -169,10 +174,10 @@ module ClaudeInbox
|
|
|
169
174
|
|
|
170
175
|
def footer
|
|
171
176
|
return @p.dim("starting session…") if @busy
|
|
172
|
-
return hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
|
|
177
|
+
return @theme.hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
|
|
173
178
|
return @theme.red(@error) if @error
|
|
174
179
|
return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
|
|
175
|
-
return hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
|
|
180
|
+
return @theme.hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
|
|
176
181
|
keys =
|
|
177
182
|
case focused.kind
|
|
178
183
|
when :multiline then [["⏎", "newline"], ["^V", "image"]]
|
|
@@ -182,7 +187,7 @@ module ClaudeInbox
|
|
|
182
187
|
keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
|
|
183
188
|
keys += [["^S", "start"], ["^O", "start & open"],
|
|
184
189
|
["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
|
|
185
|
-
hints(keys)
|
|
190
|
+
@theme.hints(keys)
|
|
186
191
|
end
|
|
187
192
|
|
|
188
193
|
# The App's `claude --bg` call failed after `:start`/`:start_and_attach`
|
|
@@ -299,8 +304,6 @@ module ClaudeInbox
|
|
|
299
304
|
rows
|
|
300
305
|
end
|
|
301
306
|
|
|
302
|
-
def hints(pairs) = pairs.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
303
|
-
|
|
304
307
|
def field_label(f)
|
|
305
308
|
on = f.equal?(focused)
|
|
306
309
|
(on ? @theme.yellow_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
|
|
@@ -200,11 +200,11 @@ module ClaudeInbox
|
|
|
200
200
|
authenticated(slot) if slot
|
|
201
201
|
Http.write(io, *answer(request, io, via))
|
|
202
202
|
rescue Http::Error => e
|
|
203
|
-
|
|
203
|
+
refuse(io, e)
|
|
204
204
|
rescue => e
|
|
205
205
|
# A message can name a path under the home directory, which is
|
|
206
206
|
# nobody's business before the token checks out.
|
|
207
|
-
|
|
207
|
+
refuse(io, Http::Error.new(500, authorized ? e.message : "internal error"))
|
|
208
208
|
end
|
|
209
209
|
|
|
210
210
|
private
|
|
@@ -322,23 +322,27 @@ 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
|
|
336
338
|
|
|
339
|
+
def refuse(io, error) = Http.write(io, error.status, Http::JSON_TYPE.merge(error.headers), JSON.generate(error.body))
|
|
340
|
+
|
|
337
341
|
# `handle` answers every failure it can; what is left is the socket
|
|
338
342
|
# itself going away, and a thread must not report that on stderr.
|
|
339
343
|
def serve(sock, slot)
|
|
340
344
|
if slot[:busy]
|
|
341
|
-
|
|
345
|
+
refuse(sock, Http::Error.new(503, "busy: try again in a moment", headers: {"Retry-After" => "2"}))
|
|
342
346
|
else
|
|
343
347
|
handle(sock, via: slot[:via], slot: slot)
|
|
344
348
|
end
|
|
@@ -353,7 +357,7 @@ module ClaudeInbox
|
|
|
353
357
|
|
|
354
358
|
def authenticated(slot)
|
|
355
359
|
@mutex.synchronize do
|
|
356
|
-
release_unauthenticated(slot
|
|
360
|
+
release_unauthenticated(slot)
|
|
357
361
|
slot[:authenticated] = true
|
|
358
362
|
end
|
|
359
363
|
end
|
|
@@ -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
|
|
@@ -87,7 +87,7 @@ module ClaudeInbox
|
|
|
87
87
|
def key_lines(s)
|
|
88
88
|
return [" paired phones get 401 until they pair again —", " rotate? y/n"] if @asking
|
|
89
89
|
keys = (s.state == :listening) ? [%w[c copy], ["r", "new token"], %w[esc close]] : [%w[esc close]]
|
|
90
|
-
[" " +
|
|
90
|
+
[" " + @theme.hints(keys)]
|
|
91
91
|
end
|
|
92
92
|
end
|
|
93
93
|
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("·")
|
|
@@ -26,6 +26,7 @@ module ClaudeInbox
|
|
|
26
26
|
FLAGS = {true => true, false => false, "yes" => true, "no" => false}.freeze
|
|
27
27
|
MAX_PROMPT = 100_000
|
|
28
28
|
MAX_NAME = 100
|
|
29
|
+
NOT_ONE_LINE = /[[:cntrl:]\u2028\u2029]/
|
|
29
30
|
|
|
30
31
|
# The hash NewSessionForm#values builds, before `resolve`, from a
|
|
31
32
|
# request's fields, or Invalid. "default" is nil. An unknown key is
|
|
@@ -123,7 +124,7 @@ module ClaudeInbox
|
|
|
123
124
|
def self.name_param(value)
|
|
124
125
|
return nil if value.nil?
|
|
125
126
|
name = string_param(:name, value).strip
|
|
126
|
-
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)
|
|
127
128
|
raise Invalid.new(:name, "the name is over #{MAX_NAME} characters") if name.length > MAX_NAME
|
|
128
129
|
name.empty? ? nil : name
|
|
129
130
|
end
|
|
@@ -133,19 +134,21 @@ module ClaudeInbox
|
|
|
133
134
|
parts = path.split("/").reject(&:empty?)
|
|
134
135
|
(1..parts.size).each do |n|
|
|
135
136
|
label = parts.last(n).join("/")
|
|
136
|
-
return label if dirs.one?
|
|
137
|
+
return label if dirs_called(dirs, label).one?
|
|
137
138
|
end
|
|
138
139
|
path
|
|
139
140
|
end
|
|
140
141
|
|
|
142
|
+
def self.dirs_called(dirs, label) = dirs.select { |dir| dir.end_with?("/#{label}") }
|
|
143
|
+
|
|
141
144
|
# A path is taken as given. Anything else names one of `dirs` by its
|
|
142
145
|
# `label`.
|
|
143
146
|
def self.cwd_param(value, dirs)
|
|
144
147
|
cwd = string_param(:cwd, value || "").strip
|
|
145
148
|
raise Invalid.new(:cwd, "a directory is required") if cwd.empty?
|
|
146
|
-
raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(
|
|
149
|
+
raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(NOT_ONE_LINE)
|
|
147
150
|
return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
|
|
148
|
-
found = dirs
|
|
151
|
+
found = dirs_called(dirs, cwd)
|
|
149
152
|
raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
|
|
150
153
|
raise Invalid.new(:cwd, "#{cwd} could be any of #{found.join(", ")}") if found.size > 1
|
|
151
154
|
found.first
|
|
@@ -173,6 +176,6 @@ module ClaudeInbox
|
|
|
173
176
|
text
|
|
174
177
|
end
|
|
175
178
|
|
|
176
|
-
private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param
|
|
179
|
+
private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param, :dirs_called
|
|
177
180
|
end
|
|
178
181
|
end
|
|
@@ -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 })
|