claude-inbox 0.4.0 → 0.5.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 +13 -7
- data/exe/claude-inbox +5 -4
- data/lib/claude_inbox/actions.rb +4 -4
- data/lib/claude_inbox/agents_client.rb +4 -3
- data/lib/claude_inbox/app.rb +14 -8
- data/lib/claude_inbox/headless.rb +4 -5
- data/lib/claude_inbox/new_session_form.rb +52 -17
- data/lib/claude_inbox/painter.rb +1 -3
- data/lib/claude_inbox/poller.rb +1 -3
- data/lib/claude_inbox/pull_requests.rb +1 -1
- data/lib/claude_inbox/rate_limits.rb +1 -1
- data/lib/claude_inbox/reaper.rb +1 -3
- data/lib/claude_inbox/remote/http.rb +1 -1
- data/lib/claude_inbox/remote/listener.rb +2 -6
- data/lib/claude_inbox/remote/start.rb +2 -8
- data/lib/claude_inbox/renderer.rb +4 -12
- data/lib/claude_inbox/session_request.rb +10 -0
- data/lib/claude_inbox/slash_commands.rb +12 -12
- data/lib/claude_inbox/snapshot.rb +2 -3
- data/lib/claude_inbox/store/row.rb +0 -2
- data/lib/claude_inbox/switch.rb +1 -3
- data/lib/claude_inbox/terminal.rb +8 -0
- data/lib/claude_inbox/writer_lock.rb +1 -8
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +63 -42
- data/mod/tests/contract.test.tsx +0 -2
- data/mod/tests/pane.test.tsx +24 -6
- data/mod/tests/snapshot.fixture.ts +2 -5
- data/mod/types/index.d.ts +0 -2
- metadata +1 -2
- data/lib/claude_inbox/remote.rb +0 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 150bc4c5ae639139680ad48d476e0628faa82e89e77e783fb1ac9b2ac34ab25f
|
|
4
|
+
data.tar.gz: c40bbe7c0a955ba01edc66014b3b2ff9d8b80eb70d3b6ea4a9345495a69af011
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1168c17b14d49071580d1c8565eff838a8c5229e339de5cbbd3f90ed78274973cbe1b1b0f3d21b89c4734142029630a1cb9ea8b03932655d22d151a3447faaf8
|
|
7
|
+
data.tar.gz: 7a6fb8224d67ae2ff94136d9615bb1a5caf12b7430031c5d221fde12a46357aef6f7ffd223f2ab725fb15d3797458ddb7f6cd824d890a01b3b525c0dbc8e3f1b
|
data/README.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
An inbox for Claude Code's background sessions. It lists what `claude agents`
|
|
4
4
|
lists and adds snooze, settle, pinning and each session's pull request.
|
|
5
5
|
|
|
6
|
+
Inspired by [T3 Code](https://github.com/pingdotgg/t3code)'s functionality and
|
|
7
|
+
inbox-style UX, I wanted a better way to manage my Claude agents, so I built
|
|
8
|
+
this.
|
|
9
|
+
|
|
6
10
|

|
|
7
11
|
|
|
8
12
|
## Install
|
|
@@ -33,8 +37,8 @@ a fullscreen terminal.
|
|
|
33
37
|
|
|
34
38
|
`/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
|
|
35
39
|
ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
|
|
36
|
-
`P`, `X`, `Ctrl-x`, `/` or `R`. `Enter
|
|
37
|
-
attached through `claude-inbox`.
|
|
40
|
+
`P`, `X`, `Ctrl-x`, `/` or `R`. `Enter`, or a click on a row, switches to the
|
|
41
|
+
session when you attached through `claude-inbox`.
|
|
38
42
|
|
|
39
43
|
It opens when a session starts. To stop that, set
|
|
40
44
|
`pluginConfigs.inbox-pane.options.openOnStart` to `false` in
|
|
@@ -97,6 +101,9 @@ session and `Ctrl-O` starts it and attaches.
|
|
|
97
101
|
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
98
102
|
`[Image #1]`. Type `/` for a menu of your skills.
|
|
99
103
|
|
|
104
|
+
Type in Directory to pick from recent and trusted directories; `Ctrl-U`
|
|
105
|
+
lists them all.
|
|
106
|
+
|
|
100
107
|
## How sessions move
|
|
101
108
|
|
|
102
109
|
A snoozed session wakes when its timer runs out, when you press `u`, or when
|
|
@@ -108,11 +115,10 @@ again.
|
|
|
108
115
|
A session settles when you press `x` or when all its pull requests are merged
|
|
109
116
|
or closed. A session without a pull request only settles by hand.
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
`claude rm`. Working, pinned and snoozed sessions are kept, and
|
|
113
|
-
refuses a worktree with unpushed commits. Each deletion is logged
|
|
114
|
-
`~/.config/claude-inbox/reaped.log`.
|
|
115
|
-
off.
|
|
118
|
+
With `--reap`, the inbox deletes background sessions that have been idle for
|
|
119
|
+
14 days, with `claude rm`. Working, pinned and snoozed sessions are kept, and
|
|
120
|
+
`claude rm` refuses a worktree with unpushed commits. Each deletion is logged
|
|
121
|
+
to `~/.config/claude-inbox/reaped.log`.
|
|
116
122
|
|
|
117
123
|
## Remote Control
|
|
118
124
|
|
data/exe/claude-inbox
CHANGED
|
@@ -60,12 +60,13 @@ if fixture
|
|
|
60
60
|
opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
61
61
|
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
|
|
62
62
|
else
|
|
63
|
-
# The only place the reaper is armed
|
|
64
|
-
#
|
|
65
|
-
#
|
|
63
|
+
# The only place the reaper is armed, and only with --reap: deleting
|
|
64
|
+
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
65
|
+
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
66
|
+
# comes only from this file, and only when asked for.
|
|
66
67
|
opts[:client] = ClaudeInbox::AgentsClient.new
|
|
67
68
|
opts[:store] = ClaudeInbox::Store.new
|
|
68
|
-
opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
|
|
69
|
+
opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
|
|
69
70
|
opts[:snapshot] = ClaudeInbox::Snapshot.new
|
|
70
71
|
opts[:switch] = ClaudeInbox::Switch.new
|
|
71
72
|
opts[:actions] = ClaudeInbox::Actions.new
|
data/lib/claude_inbox/actions.rb
CHANGED
|
@@ -25,16 +25,16 @@ module ClaudeInbox
|
|
|
25
25
|
@sent = 0
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# Leaves one request for the writer, as the pane does
|
|
29
|
-
#
|
|
30
|
-
def request(action, id, choice: nil
|
|
28
|
+
# Leaves one request for the writer, as the pane does, named for this
|
|
29
|
+
# inbox; a count keeps two requests in one millisecond apart.
|
|
30
|
+
def request(action, id, choice: nil)
|
|
31
31
|
return unless @dir
|
|
32
32
|
FileUtils.mkdir_p(@dir)
|
|
33
33
|
at = (@clock.call.to_f * 1000).to_i
|
|
34
34
|
request = {"action" => action, "id" => id, "at" => at}
|
|
35
35
|
request["choice"] = choice if choice
|
|
36
36
|
@sent += 1
|
|
37
|
-
File.write(File.join(@dir, "
|
|
37
|
+
File.write(File.join(@dir, "inbox-#{Process.pid}-#{at}-#{@sent}.json"), JSON.generate(request))
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# Applies every request on disk to `store` and returns how many it took.
|
|
@@ -39,6 +39,8 @@ module ClaudeInbox
|
|
|
39
39
|
|
|
40
40
|
# Hands the terminal to the child; caller must have restored cooked mode.
|
|
41
41
|
# Returns when the user detaches. Detaching never stops the session.
|
|
42
|
+
# False when the attach failed on its own, as a session that cannot be
|
|
43
|
+
# woken does: the CLI prints why and exits 1.
|
|
42
44
|
#
|
|
43
45
|
# Pressing ← inside an attached session detaches it and then `claude
|
|
44
46
|
# attach` execs itself in place as `claude agents` (same pid). There is
|
|
@@ -52,7 +54,7 @@ module ClaudeInbox
|
|
|
52
54
|
pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
|
|
53
55
|
watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
|
|
54
56
|
_, status = Process.wait2(pid)
|
|
55
|
-
status
|
|
57
|
+
status.success? || !status.exited?
|
|
56
58
|
ensure
|
|
57
59
|
watchdog&.kill
|
|
58
60
|
end
|
|
@@ -188,14 +190,13 @@ module ClaudeInbox
|
|
|
188
190
|
# origin, terminal when unlisted) and the bridge a remote one is served
|
|
189
191
|
# over, and drops the unattended ones, whose parent is the row worth
|
|
190
192
|
# showing.
|
|
191
|
-
def assign_origins(sessions, origins, bridges
|
|
193
|
+
def assign_origins(sessions, origins, bridges)
|
|
192
194
|
sessions
|
|
193
195
|
.map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
|
|
194
196
|
.reject(&:unattended?)
|
|
195
197
|
end
|
|
196
198
|
|
|
197
199
|
def origins_from(rows)
|
|
198
|
-
return {} if rows.empty?
|
|
199
200
|
self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
|
|
200
201
|
end
|
|
201
202
|
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -11,7 +11,7 @@ require_relative "terminal"
|
|
|
11
11
|
require_relative "logs"
|
|
12
12
|
require_relative "peek"
|
|
13
13
|
require_relative "keymap"
|
|
14
|
-
require_relative "remote"
|
|
14
|
+
require_relative "remote/listener"
|
|
15
15
|
require_relative "mouse"
|
|
16
16
|
require_relative "new_session_form"
|
|
17
17
|
require_relative "paste"
|
|
@@ -23,6 +23,7 @@ require_relative "session_request"
|
|
|
23
23
|
require_relative "actions"
|
|
24
24
|
require_relative "snapshot"
|
|
25
25
|
require_relative "switch"
|
|
26
|
+
require_relative "trust"
|
|
26
27
|
|
|
27
28
|
module ClaudeInbox
|
|
28
29
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
@@ -32,7 +33,7 @@ module ClaudeInbox
|
|
|
32
33
|
# so both are off unless `bin/claude-inbox` switches them on.
|
|
33
34
|
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
34
35
|
rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
|
|
35
|
-
actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
|
|
36
|
+
actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true, trust: Trust.method(:projects),
|
|
36
37
|
terminal: Terminal.new(out, input), queue: Queue.new)
|
|
37
38
|
@client = client
|
|
38
39
|
@store = store
|
|
@@ -41,6 +42,7 @@ module ClaudeInbox
|
|
|
41
42
|
@rate_limits = rate_limits
|
|
42
43
|
@terminal = terminal
|
|
43
44
|
@color = color
|
|
45
|
+
@trust = trust
|
|
44
46
|
@renderer = Renderer.new(color: color)
|
|
45
47
|
@input = input
|
|
46
48
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
@@ -305,7 +307,7 @@ module ClaudeInbox
|
|
|
305
307
|
return if @list_width && col > @list_width
|
|
306
308
|
item = row_item_at(row)
|
|
307
309
|
return unless item
|
|
308
|
-
select(item
|
|
310
|
+
select(item)
|
|
309
311
|
activate
|
|
310
312
|
end
|
|
311
313
|
|
|
@@ -313,7 +315,7 @@ module ClaudeInbox
|
|
|
313
315
|
# no item of its own; a click there resolves to the row above it.
|
|
314
316
|
def row_item_at(row)
|
|
315
317
|
idx = row - 1
|
|
316
|
-
@row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.
|
|
318
|
+
@row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
|
|
317
319
|
end
|
|
318
320
|
|
|
319
321
|
def perform(action)
|
|
@@ -420,13 +422,16 @@ module ClaudeInbox
|
|
|
420
422
|
|
|
421
423
|
# The pane in the attached session may ask for a different one: the
|
|
422
424
|
# attach ends and the next starts without passing through the list. The
|
|
423
|
-
# poller keeps running meanwhile, so the pane's snapshot stays fresh.
|
|
425
|
+
# poller keeps running meanwhile, so the pane's snapshot stays fresh. A
|
|
426
|
+
# failed attach leaves its reason on screen until a key is pressed.
|
|
424
427
|
def attach(id)
|
|
425
428
|
while id
|
|
426
429
|
@store.acknowledge(id)
|
|
427
430
|
@switch.clear
|
|
428
|
-
from =
|
|
429
|
-
@terminal.release
|
|
431
|
+
from = session_for(id)&.session_id
|
|
432
|
+
@terminal.release do
|
|
433
|
+
@terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
|
|
434
|
+
end
|
|
430
435
|
id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
|
|
431
436
|
end
|
|
432
437
|
end
|
|
@@ -475,7 +480,8 @@ module ClaudeInbox
|
|
|
475
480
|
|
|
476
481
|
def open_new_session
|
|
477
482
|
cwd = selected_session&.cwd || Dir.pwd
|
|
478
|
-
|
|
483
|
+
dirs = SessionRequest.directories(@store.sessions, @trust.call)
|
|
484
|
+
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
|
|
479
485
|
end
|
|
480
486
|
|
|
481
487
|
# `attach:` hands the terminal over as soon as the session starts. Without
|
|
@@ -13,12 +13,11 @@ module ClaudeInbox
|
|
|
13
13
|
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
14
14
|
# exits at once while an inbox holds the writer lock.
|
|
15
15
|
class Headless
|
|
16
|
-
def initialize(client:, store:,
|
|
17
|
-
lock: WriterLock.new, queue: Queue.new)
|
|
16
|
+
def initialize(client:, store:, lock: WriterLock.new)
|
|
18
17
|
@lock = lock
|
|
19
|
-
@queue =
|
|
20
|
-
@poller = Poller.new(client: client, store: store, pull_requests:
|
|
21
|
-
queue: queue, snapshot:
|
|
18
|
+
@queue = Queue.new
|
|
19
|
+
@poller = Poller.new(client: client, store: store, pull_requests: PullRequests.new, reaper: Reaper.disabled,
|
|
20
|
+
queue: @queue, snapshot: Snapshot.new, actions: Actions.new)
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
# False when the lock is held, by an inbox or another headless process.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pastel"
|
|
3
4
|
require_relative "agents_client"
|
|
4
5
|
require_relative "images"
|
|
5
6
|
require_relative "session_request"
|
|
@@ -18,6 +19,12 @@ module ClaudeInbox
|
|
|
18
19
|
# string on :choice ones — `kind` says which.
|
|
19
20
|
Field = Struct.new(:key, :label, :kind, :value, :choices)
|
|
20
21
|
|
|
22
|
+
# A directory the Directory field offers, shown by its shortest
|
|
23
|
+
# distinct label as the phone's form shows it.
|
|
24
|
+
Place = Struct.new(:path, :label, :description) do
|
|
25
|
+
def to_s = label
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
# The screen shows what "default" resolves to; `values` makes it nil.
|
|
22
29
|
DEFAULT = "default"
|
|
23
30
|
|
|
@@ -27,11 +34,13 @@ module ClaudeInbox
|
|
|
27
34
|
# editable field spends these on its own text instead.
|
|
28
35
|
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
29
36
|
|
|
30
|
-
def initialize(cwd:,
|
|
31
|
-
@p =
|
|
32
|
-
@theme = Theme.new(enabled:
|
|
37
|
+
def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
38
|
+
@p = Pastel.new(enabled: color)
|
|
39
|
+
@theme = Theme.new(enabled: color)
|
|
33
40
|
@home = home
|
|
34
41
|
@clipboard = clipboard
|
|
42
|
+
@places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
|
|
43
|
+
@dir_settled = cwd.to_s
|
|
35
44
|
@fields = [
|
|
36
45
|
Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
|
|
37
46
|
Field.new(:name, "Name", :text, TextBuffer.new),
|
|
@@ -66,7 +75,7 @@ module ClaudeInbox
|
|
|
66
75
|
@error = nil
|
|
67
76
|
@candidates = nil
|
|
68
77
|
return :changed if menu && menu_press(name)
|
|
69
|
-
before =
|
|
78
|
+
before = menu_query
|
|
70
79
|
case name
|
|
71
80
|
when :escape then return escape_pressed
|
|
72
81
|
when :ctrl_s then return submit(attach: false)
|
|
@@ -80,7 +89,7 @@ module ClaudeInbox
|
|
|
80
89
|
else
|
|
81
90
|
editable? ? focused.value.press(name, raw) : choose(name, raw)
|
|
82
91
|
end
|
|
83
|
-
edited = before &&
|
|
92
|
+
edited = before && menu_query && menu_query != before
|
|
84
93
|
@pick = 0 if edited
|
|
85
94
|
@dismissed = nil if edited
|
|
86
95
|
:changed
|
|
@@ -103,10 +112,12 @@ module ClaudeInbox
|
|
|
103
112
|
:changed
|
|
104
113
|
end
|
|
105
114
|
|
|
115
|
+
# Slash commands while one is being typed in the prompt; the recent and
|
|
116
|
+
# trusted directories once the Directory field is edited.
|
|
106
117
|
def menu
|
|
107
|
-
q =
|
|
118
|
+
q = menu_query
|
|
108
119
|
return nil if q.nil? || @dismissed == q
|
|
109
|
-
found = SlashCommands.match(commands, q)
|
|
120
|
+
found = (focused.key == :cwd) ? places(q) : SlashCommands.match(commands, q)
|
|
110
121
|
found.empty? ? nil : found
|
|
111
122
|
end
|
|
112
123
|
|
|
@@ -144,9 +155,12 @@ module ClaudeInbox
|
|
|
144
155
|
out << ""
|
|
145
156
|
out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
|
|
146
157
|
out += prompt_box(field(:prompt), inner_w, prompt_h)
|
|
147
|
-
out += menu_rows
|
|
158
|
+
out += menu_rows if focused.key == :prompt
|
|
148
159
|
out << ""
|
|
149
|
-
@fields[1..].each
|
|
160
|
+
@fields[1..].each do |f|
|
|
161
|
+
out << " " + field_label(f) + field_value(f, inner_w - 16)
|
|
162
|
+
out += menu_rows if f.key == :cwd && focused.key == :cwd
|
|
163
|
+
end
|
|
150
164
|
out.first(height) + [""] * [height - out.size, 0].max
|
|
151
165
|
end
|
|
152
166
|
|
|
@@ -162,6 +176,7 @@ module ClaudeInbox
|
|
|
162
176
|
when :choice then [["← → h l", "change"], ["⏎", "next"]]
|
|
163
177
|
else [["⏎", "next"]]
|
|
164
178
|
end
|
|
179
|
+
keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
|
|
165
180
|
keys += [["^S", "start"], ["^O", "start & open"],
|
|
166
181
|
["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
|
|
167
182
|
hints(keys)
|
|
@@ -179,9 +194,25 @@ module ClaudeInbox
|
|
|
179
194
|
|
|
180
195
|
private
|
|
181
196
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
197
|
+
def menu_query
|
|
198
|
+
case focused.key
|
|
199
|
+
when :prompt then focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
200
|
+
when :cwd then focused.value.to_s unless focused.value.to_s == @dir_settled
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def places(query)
|
|
205
|
+
q = query.downcase
|
|
206
|
+
found = @places.select { |d| d.path.downcase.include?(q) || d.description.downcase.include?(q) }
|
|
207
|
+
starts, rest = found.partition { |d| d.label.downcase.start_with?(q) }
|
|
208
|
+
starts + rest
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A path put in the field by a pick or a completion rather than typed,
|
|
212
|
+
# so the menu stays shut until the next edit.
|
|
213
|
+
def settle_dir(path)
|
|
214
|
+
field(:cwd).value.replace(path)
|
|
215
|
+
@dir_settled = path
|
|
185
216
|
end
|
|
186
217
|
|
|
187
218
|
# Project commands live under the Directory field's path, so they
|
|
@@ -228,14 +259,18 @@ module ClaudeInbox
|
|
|
228
259
|
when :up, :ctrl_p then @pick = (@pick - 1) % menu.size
|
|
229
260
|
when :down, :ctrl_n then @pick = (@pick + 1) % menu.size
|
|
230
261
|
when :tab, :return, :enter then accept(picked)
|
|
231
|
-
when :escape then @dismissed =
|
|
262
|
+
when :escape then @dismissed = menu_query
|
|
232
263
|
else return false
|
|
233
264
|
end
|
|
234
265
|
true
|
|
235
266
|
end
|
|
236
267
|
|
|
237
|
-
def accept(
|
|
238
|
-
focused.
|
|
268
|
+
def accept(item)
|
|
269
|
+
if focused.key == :cwd
|
|
270
|
+
settle_dir(item.path)
|
|
271
|
+
else
|
|
272
|
+
focused.value.replace_before(menu_query.grapheme_clusters.size + 1, "#{item} ")
|
|
273
|
+
end
|
|
239
274
|
@pick = 0
|
|
240
275
|
end
|
|
241
276
|
|
|
@@ -327,10 +362,10 @@ module ClaudeInbox
|
|
|
327
362
|
matches = Dir.glob(listing ? "#{base}/*" : "#{base}*").select { |d| File.directory?(d) }.sort
|
|
328
363
|
return nil if matches.empty?
|
|
329
364
|
if matches.size == 1
|
|
330
|
-
|
|
365
|
+
settle_dir("#{matches.first}/")
|
|
331
366
|
else
|
|
332
367
|
@candidates = matches.map { |d| File.basename(d) }
|
|
333
|
-
|
|
368
|
+
settle_dir(common_prefix(matches))
|
|
334
369
|
end
|
|
335
370
|
:changed
|
|
336
371
|
end
|
data/lib/claude_inbox/painter.rb
CHANGED
|
@@ -18,9 +18,7 @@ module ClaudeInbox
|
|
|
18
18
|
next if @prev[i] == line
|
|
19
19
|
buf << TTY::Cursor.move_to(0, i) << line
|
|
20
20
|
end
|
|
21
|
-
|
|
22
|
-
(lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
|
|
23
|
-
end
|
|
21
|
+
(lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
|
|
24
22
|
@out.print buf unless buf.empty?
|
|
25
23
|
@out.flush
|
|
26
24
|
@prev = lines
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "actions"
|
|
4
3
|
require_relative "sessions"
|
|
5
|
-
require_relative "snapshot"
|
|
6
4
|
|
|
7
5
|
module ClaudeInbox
|
|
8
6
|
# Asks `claude agents` for the list off the main thread, every INTERVAL
|
|
@@ -17,7 +15,7 @@ module ClaudeInbox
|
|
|
17
15
|
INTERVAL = 4
|
|
18
16
|
ACTIONS_INTERVAL = 0.5
|
|
19
17
|
|
|
20
|
-
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot
|
|
18
|
+
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
|
|
21
19
|
interval: INTERVAL, clock: -> { Time.now })
|
|
22
20
|
@client = client
|
|
23
21
|
@clock = clock
|
|
@@ -76,7 +76,7 @@ module ClaudeInbox
|
|
|
76
76
|
# one (interactive, or forgotten) has none; Sessions.load sees to the
|
|
77
77
|
# order. `overrides` maps session key => url for links set by hand; an
|
|
78
78
|
# override replaces the scanned list.
|
|
79
|
-
def enrich(sessions, overrides
|
|
79
|
+
def enrich(sessions, overrides)
|
|
80
80
|
sessions.map do |s|
|
|
81
81
|
urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
|
|
82
82
|
s.with(prs: urls.map { |u| known(u) })
|
|
@@ -30,7 +30,7 @@ module ClaudeInbox
|
|
|
30
30
|
# time, or nil.
|
|
31
31
|
# Parses only when the file has changed, since render asks several times
|
|
32
32
|
# a second.
|
|
33
|
-
def windows(now
|
|
33
|
+
def windows(now)
|
|
34
34
|
mtime = File.mtime(@path)
|
|
35
35
|
return nil if now - mtime > STALE_AFTER
|
|
36
36
|
@data = parse if mtime != @mtime
|
data/lib/claude_inbox/reaper.rb
CHANGED
|
@@ -20,15 +20,13 @@ module ClaudeInbox
|
|
|
20
20
|
RETRY_AFTER = 24 * 3600
|
|
21
21
|
DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
|
|
22
22
|
|
|
23
|
-
def initialize(client, store, log_path: DEFAULT_LOG, enabled:
|
|
23
|
+
def initialize(client, store, log_path: DEFAULT_LOG, enabled: true)
|
|
24
24
|
@client = client
|
|
25
25
|
@store = store
|
|
26
26
|
@log_path = log_path
|
|
27
27
|
@enabled = enabled
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def self.enabled? = ENV["CLAUDE_INBOX_NO_REAP"].to_s.empty?
|
|
31
|
-
|
|
32
30
|
# For --fixture runs and tests: selects nothing, deletes nothing, and
|
|
33
31
|
# needs neither a client nor a store to do it.
|
|
34
32
|
def self.disabled = new(nil, nil, enabled: false)
|
|
@@ -107,7 +107,7 @@ module ClaudeInbox
|
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
def self.write(io, status, headers
|
|
110
|
+
def self.write(io, status, headers, body)
|
|
111
111
|
body = body.b
|
|
112
112
|
head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
|
|
113
113
|
HEADERS.merge(headers, "Content-Length" => body.bytesize.to_s).each { |name, value| head << "#{name}: #{value}" }
|
|
@@ -17,7 +17,7 @@ module ClaudeInbox
|
|
|
17
17
|
# connection limits, the static files and the Pairing token, in front of
|
|
18
18
|
# Start. Like the Poller it reaches App only through the queue, and it
|
|
19
19
|
# never writes to the terminal: every failure becomes a response or a
|
|
20
|
-
# notice.
|
|
20
|
+
# notice. It is App's only way into Remote.
|
|
21
21
|
class Listener
|
|
22
22
|
DEFAULT_PORT = 7433
|
|
23
23
|
DEFAULT_MODES = %w[default auto plan].freeze
|
|
@@ -58,17 +58,13 @@ module ClaudeInbox
|
|
|
58
58
|
# lock, as `held_by`) or :failed (for good, as `error` says). `urls` and
|
|
59
59
|
# `firewall` are nil until `refresh`.
|
|
60
60
|
Snapshot = Data.define(:state, :port, :lan, :urls, :firewall, :allowed_modes, :recent, :held_by, :fixture, :error) do
|
|
61
|
-
def initialize(error: nil, **) = super
|
|
62
|
-
|
|
63
61
|
# Over a VPN only an address reaches the Mac: multicast DNS, which
|
|
64
62
|
# the .local name needs, stays on the LAN.
|
|
65
63
|
def pairing_url = (lan && urls&.[](1)) || urls&.first
|
|
66
64
|
end
|
|
67
65
|
|
|
68
66
|
# `count` is how many times `via` got `result`, the last at `at`.
|
|
69
|
-
Outcome = Data.define(:at, :via, :result, :count)
|
|
70
|
-
def initialize(at:, via:, result:, count: 1) = super
|
|
71
|
-
end
|
|
67
|
+
Outcome = Data.define(:at, :via, :result, :count)
|
|
72
68
|
|
|
73
69
|
# App's `listen:` from the flags, else the environment; nil when neither
|
|
74
70
|
# asks for it. --listen-lan wins over --listen, whichever came last.
|
|
@@ -26,7 +26,7 @@ module ClaudeInbox
|
|
|
26
26
|
BODY_TIMEOUT = 120
|
|
27
27
|
KEYS_KEPT = 16
|
|
28
28
|
|
|
29
|
-
def initialize(client:, store:, queue:, allowed_modes:, fixture
|
|
29
|
+
def initialize(client:, store:, queue:, allowed_modes:, fixture:, images_dir: Images::DEFAULT_DIR,
|
|
30
30
|
trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
|
|
31
31
|
@client = client
|
|
32
32
|
@store = store
|
|
@@ -62,13 +62,7 @@ module ClaudeInbox
|
|
|
62
62
|
}
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
# trust dialog was accepted: the likely choices first. Only an order;
|
|
67
|
-
# the CLI still decides where a session may start.
|
|
68
|
-
def dir_paths
|
|
69
|
-
recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
|
|
70
|
-
(recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
|
|
71
|
-
end
|
|
65
|
+
def dir_paths = SessionRequest.directories(@store.sessions, @trust.call)
|
|
72
66
|
|
|
73
67
|
# Checked cheapest first, and nothing is written to disk until every
|
|
74
68
|
# check has passed. A retry with the same Idempotency-Key gets the
|
|
@@ -11,12 +11,6 @@ module ClaudeInbox
|
|
|
11
11
|
# `width` columns wide) plus a parallel Array of selectable items.
|
|
12
12
|
# Pure: no terminal, no IO, no clock beyond the `now` it is handed.
|
|
13
13
|
class Renderer
|
|
14
|
-
Item = Struct.new(:kind, :row, :section) do
|
|
15
|
-
def selection = (kind == :fold_toggle) ? Store::Selection.fold(section) : Store::Selection.row(row.key)
|
|
16
|
-
|
|
17
|
-
def key = selection.key
|
|
18
|
-
end
|
|
19
|
-
|
|
20
14
|
Frame = Struct.new(:lines, :items, :top, :list_width)
|
|
21
15
|
|
|
22
16
|
CHROME_ROWS = 2 # header + footer
|
|
@@ -175,7 +169,7 @@ module ClaudeInbox
|
|
|
175
169
|
end
|
|
176
170
|
end
|
|
177
171
|
|
|
178
|
-
def header_chips(sections, compact:, listening:
|
|
172
|
+
def header_chips(sections, compact:, listening:)
|
|
179
173
|
pinned = sections.pinned.size
|
|
180
174
|
needing = sections.needs_you.size
|
|
181
175
|
working = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
|
|
@@ -214,7 +208,7 @@ module ClaudeInbox
|
|
|
214
208
|
|
|
215
209
|
def footer(width, view)
|
|
216
210
|
text =
|
|
217
|
-
if view.filter_editing then " " + @theme.cyan_bold("/") +
|
|
211
|
+
if view.filter_editing then " " + @theme.cyan_bold("/") + view.filter.row(width - 2, cursor: caret)
|
|
218
212
|
elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
|
|
219
213
|
else " " + footer_keys(view).map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
220
214
|
end
|
|
@@ -226,8 +220,6 @@ module ClaudeInbox
|
|
|
226
220
|
KEYS.flat_map { |key| (key[0] == "n") ? [key, ["N", "pair"]] : [key] }
|
|
227
221
|
end
|
|
228
222
|
|
|
229
|
-
def line(buffer, width) = buffer.row(width, cursor: caret)
|
|
230
|
-
|
|
231
223
|
def section_title(name, count, width)
|
|
232
224
|
title = " #{SECTION_TITLES[name]} "
|
|
233
225
|
count_s = " #{count} "
|
|
@@ -256,13 +248,13 @@ module ClaudeInbox
|
|
|
256
248
|
items << nil << nil
|
|
257
249
|
if Store.folded?(name, view.expanded)
|
|
258
250
|
lines << fold_toggle_line(name, rows.size, selected, width)
|
|
259
|
-
items <<
|
|
251
|
+
items << Store::Selection.fold(name)
|
|
260
252
|
next
|
|
261
253
|
end
|
|
262
254
|
rows.each do |row|
|
|
263
255
|
row_lines(row, name, selected, width, view.now, view.tick).each_with_index do |l, i|
|
|
264
256
|
lines << l
|
|
265
|
-
items << ((i.zero? && row.selectable?) ?
|
|
257
|
+
items << ((i.zero? && row.selectable?) ? Store::Selection.row(row.key) : nil)
|
|
266
258
|
end
|
|
267
259
|
end
|
|
268
260
|
end
|
|
@@ -80,6 +80,16 @@ module ClaudeInbox
|
|
|
80
80
|
# writing over the same files. Fall back to the repo it was cut from.
|
|
81
81
|
def self.strip_worktree(cwd) = cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
|
|
82
82
|
|
|
83
|
+
# Where sessions ran lately, newest first, then every directory whose
|
|
84
|
+
# trust dialog was accepted: the likely choices first. Only an order;
|
|
85
|
+
# the CLI still decides where a session may start. Every worktree gets
|
|
86
|
+
# trusted on its own, so each folds into its repo rather than crowding
|
|
87
|
+
# the list.
|
|
88
|
+
def self.directories(sessions, trusted)
|
|
89
|
+
recent = sessions.sort_by { |s| -s.started_at.to_i }.filter_map(&:cwd)
|
|
90
|
+
(recent + trusted).map { |dir| strip_worktree(dir) }.uniq.select { |dir| File.directory?(dir) }
|
|
91
|
+
end
|
|
92
|
+
|
|
83
93
|
# The daemon's summary line can echo the prompt to the terminal, so no
|
|
84
94
|
# control character but tab and newline gets that far. A CRLF, the
|
|
85
95
|
# newline an HTTP client may send, is taken as a newline.
|
|
@@ -13,7 +13,7 @@ module ClaudeInbox
|
|
|
13
13
|
#
|
|
14
14
|
# Pure: filesystem in, plain values out. Nothing here runs `claude`.
|
|
15
15
|
module SlashCommands
|
|
16
|
-
Command = Struct.new(:name, :description
|
|
16
|
+
Command = Struct.new(:name, :description) do
|
|
17
17
|
def to_s = "/#{name}"
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -26,8 +26,8 @@ module ClaudeInbox
|
|
|
26
26
|
def list(cwd:, home: Dir.home)
|
|
27
27
|
found = {}
|
|
28
28
|
add = ->(cmd) { found[cmd.name] ||= cmd }
|
|
29
|
-
from_dir(File.join(cwd, ".claude")
|
|
30
|
-
from_dir(File.join(home, ".claude")
|
|
29
|
+
from_dir(File.join(cwd, ".claude")).each(&add)
|
|
30
|
+
from_dir(File.join(home, ".claude")).each(&add)
|
|
31
31
|
plugins(home).each(&add)
|
|
32
32
|
synced(home).each(&add)
|
|
33
33
|
found.values.sort_by(&:name)
|
|
@@ -39,25 +39,25 @@ module ClaudeInbox
|
|
|
39
39
|
starts + rest.select { |c| c.name.downcase.include?(q) }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def from_dir(dir,
|
|
43
|
-
skills(File.join(dir, "skills"),
|
|
44
|
-
commands(File.join(dir, "commands"),
|
|
42
|
+
def from_dir(dir, prefix: nil)
|
|
43
|
+
skills(File.join(dir, "skills"), prefix:) +
|
|
44
|
+
commands(File.join(dir, "commands"), prefix:)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def skills(dir,
|
|
47
|
+
def skills(dir, prefix: nil)
|
|
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"
|
|
51
51
|
name = File.basename(File.dirname(path))
|
|
52
|
-
Command.new(qualify(prefix, name), meta["description"].to_s
|
|
52
|
+
Command.new(qualify(prefix, name), meta["description"].to_s)
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
# A command file's name is its own; a subdirectory only groups them.
|
|
57
|
-
def commands(dir,
|
|
57
|
+
def commands(dir, prefix: nil)
|
|
58
58
|
Dir.glob(File.join(dir, "**", "*.md")).sort.map do |path|
|
|
59
59
|
name = File.basename(path, ".md")
|
|
60
|
-
Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s
|
|
60
|
+
Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s)
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -71,7 +71,7 @@ module ClaudeInbox
|
|
|
71
71
|
(installed["plugins"] || {}).flat_map do |key, entries|
|
|
72
72
|
plugin = key.split("@").first
|
|
73
73
|
Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
|
|
74
|
-
from_dir(root,
|
|
74
|
+
from_dir(root, prefix: plugin)
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
end
|
|
@@ -80,7 +80,7 @@ module ClaudeInbox
|
|
|
80
80
|
# ~/.claude/skills/synced and answer to the anthropic-skills prefix.
|
|
81
81
|
def synced(home)
|
|
82
82
|
Dir.glob(File.join(home, ".claude", "skills", "synced", "*", "")).flat_map do |bucket|
|
|
83
|
-
skills(bucket,
|
|
83
|
+
skills(bucket, prefix: SYNCED_PLUGIN)
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -23,7 +23,7 @@ module ClaudeInbox
|
|
|
23
23
|
# Called from the poller's threads and the screen's alike.
|
|
24
24
|
def write(sections, now)
|
|
25
25
|
return unless @path
|
|
26
|
-
body = sections.to_h
|
|
26
|
+
body = sections.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }
|
|
27
27
|
@mutex.synchronize do
|
|
28
28
|
return if body == @body && now.to_i - @written_at < HEARTBEAT
|
|
29
29
|
@body = body
|
|
@@ -39,8 +39,7 @@ module ClaudeInbox
|
|
|
39
39
|
pr = s.pr
|
|
40
40
|
{
|
|
41
41
|
"id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
|
|
42
|
-
"waiting" => s.waiting_on_work? || nil, "
|
|
43
|
-
"cwd" => s.cwd, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
|
|
42
|
+
"waiting" => s.waiting_on_work? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
|
|
44
43
|
"pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
|
|
45
44
|
}.compact
|
|
46
45
|
end
|
|
@@ -5,8 +5,6 @@ module ClaudeInbox
|
|
|
5
5
|
# A session paired with its Entry (nil until a poll has recorded one), with
|
|
6
6
|
# the triage rules as its methods; each takes `now`, so a Row is pure.
|
|
7
7
|
Row = Struct.new(:session, :entry) do
|
|
8
|
-
def id = session.id
|
|
9
|
-
|
|
10
8
|
def key = session.key
|
|
11
9
|
|
|
12
10
|
def selectable? = !key.nil?
|
data/lib/claude_inbox/switch.rb
CHANGED
|
@@ -16,11 +16,9 @@ module ClaudeInbox
|
|
|
16
16
|
@path = path
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def request(id, from, now) = @path && Records.save(@path, {"id" => id, "from" => from, "at" => now.to_i})
|
|
20
|
-
|
|
21
19
|
# Whether the session `from` has asked to go elsewhere.
|
|
22
20
|
def requested_for?(from)
|
|
23
|
-
return false if
|
|
21
|
+
return false if from.nil?
|
|
24
22
|
Records.read(@path)["from"] == from
|
|
25
23
|
end
|
|
26
24
|
|
|
@@ -76,6 +76,14 @@ module ClaudeInbox
|
|
|
76
76
|
enter
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# Inside `release`: keeps what a failed child printed on the screen
|
|
80
|
+
# until a key is pressed, since `enter` would cover it at once.
|
|
81
|
+
def pause
|
|
82
|
+
@out.print "\r\npress any key to return to claude-inbox"
|
|
83
|
+
@out.flush
|
|
84
|
+
@input.tty? ? @input.getch : @input.getc
|
|
85
|
+
end
|
|
86
|
+
|
|
79
87
|
# [cols, rows]. Cached: querying the terminal can fall back to spawning
|
|
80
88
|
# `tput`, which is far too slow to do on every frame. Refreshed by
|
|
81
89
|
# `resized`, which WINCH and re-entry both call.
|
|
@@ -37,13 +37,6 @@ module ClaudeInbox
|
|
|
37
37
|
file.flock(File::LOCK_EX | File::LOCK_NB) ? hold(file, role) : refuse(file)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
def held? = !@file.nil?
|
|
41
|
-
|
|
42
|
-
def release
|
|
43
|
-
@file&.close
|
|
44
|
-
@file = nil
|
|
45
|
-
end
|
|
46
|
-
|
|
47
40
|
private
|
|
48
41
|
|
|
49
42
|
# After reading the holder the position sits at the end, and truncate does
|
|
@@ -53,7 +46,7 @@ module ClaudeInbox
|
|
|
53
46
|
file.truncate(0)
|
|
54
47
|
file.write("#{role} #{Process.pid}")
|
|
55
48
|
file.flush
|
|
56
|
-
@file = file
|
|
49
|
+
@file = file # only kept so GC never closes it and drops the flock
|
|
57
50
|
true
|
|
58
51
|
end
|
|
59
52
|
|
data/lib/claude_inbox.rb
CHANGED
data/mod/hooks/register.tsx
CHANGED
|
@@ -33,8 +33,6 @@ export type SnapshotRow = {
|
|
|
33
33
|
state?: string
|
|
34
34
|
actionable?: boolean
|
|
35
35
|
waiting?: boolean
|
|
36
|
-
terminal?: boolean
|
|
37
|
-
cwd?: string
|
|
38
36
|
line?: string
|
|
39
37
|
wake_at?: number | 'until_woken'
|
|
40
38
|
remote?: string
|
|
@@ -43,7 +41,7 @@ export type SnapshotRow = {
|
|
|
43
41
|
type Snapshot = { written_at?: number; sections?: Partial<Record<Section, SnapshotRow[]>> }
|
|
44
42
|
// This session, as the gem's files name it, and the headless inbox this module may start.
|
|
45
43
|
type Host = { session: string; command: string; headlessStartedAt: number; isHeadlessRunning: boolean }
|
|
46
|
-
type Fold =
|
|
44
|
+
type Fold = keyof Folds
|
|
47
45
|
type Item = { kind: 'row'; row: InboxRow } | { kind: 'fold'; section: Fold; count: number }
|
|
48
46
|
type Snooze = 'm15' | 'h1' | 'tomorrow_9am' | 'until_woken'
|
|
49
47
|
type Action = { action: 'settle' | 'wake' | 'pin'; id: string } | { action: 'snooze'; id: string; choice: Snooze }
|
|
@@ -60,7 +58,7 @@ const SNOOZE: Record<string, { choice: Snooze; label: string }> = {
|
|
|
60
58
|
'4': { choice: 'until_woken', label: 'until woken' },
|
|
61
59
|
}
|
|
62
60
|
// What each chord's second key does, drawn under the footer while it waits.
|
|
63
|
-
const MENU: Record<
|
|
61
|
+
const MENU: Record<NonNullable<Pending>, [string, string][]> = {
|
|
64
62
|
g: [
|
|
65
63
|
['g', 'first'],
|
|
66
64
|
['e', 'last'],
|
|
@@ -82,7 +80,7 @@ function isFold(section: Section): section is Fold {
|
|
|
82
80
|
return section === 'snoozed' || section === 'settled'
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
// The
|
|
83
|
+
// The Button drawn for an item, its label: the ring walks these with the arrows, a click presses one.
|
|
86
84
|
function itemKey(item: Item): string {
|
|
87
85
|
return item.kind === 'fold' ? `fold:${item.section}` : `row:${item.row.id ?? item.row.label}`
|
|
88
86
|
}
|
|
@@ -105,6 +103,15 @@ function groupsOf(list: InboxRow[], folded: Folds, current: InboxRow | null): {
|
|
|
105
103
|
)
|
|
106
104
|
}
|
|
107
105
|
|
|
106
|
+
async function listed($: EngineInterface): Promise<Item[]> {
|
|
107
|
+
return groupsOf(await read($, rows), await read($, folds), await read($, current)).flatMap(group => group.items)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The stored cursor may point past a list that has shrunk since it was set.
|
|
111
|
+
function onList(flat: Item[], at: number): number {
|
|
112
|
+
return Math.min(at, Math.max(0, flat.length - 1))
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
async function readJson<T>($: EngineInterface, path: string, fallback: T): Promise<T> {
|
|
109
116
|
try {
|
|
110
117
|
return JSON.parse(await $.fs.read(path)) as T
|
|
@@ -123,8 +130,6 @@ export function rowOf(section: Section, one: SnapshotRow): InboxRow {
|
|
|
123
130
|
state: one.state ?? 'unknown',
|
|
124
131
|
actionable: one.actionable === true,
|
|
125
132
|
isWaiting: one.waiting === true,
|
|
126
|
-
isTerminal: one.terminal === true,
|
|
127
|
-
cwd: one.cwd,
|
|
128
133
|
line: one.line,
|
|
129
134
|
wakeAt: one.wake_at,
|
|
130
135
|
remote: one.remote,
|
|
@@ -142,26 +147,34 @@ function ago(seconds: number): string {
|
|
|
142
147
|
return seconds < 60 ? 'just now' : `${Math.floor(seconds / 60)}m ago`
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
async function inboxFile($: EngineInterface, path: string): Promise<string> {
|
|
151
|
+
return `${(await $.env.get('HOME')) ?? ''}/${path}`
|
|
152
|
+
}
|
|
153
|
+
|
|
145
154
|
async function nowS($: EngineInterface): Promise<number> {
|
|
146
155
|
return Math.floor((await $.clock.now()) / 1000)
|
|
147
156
|
}
|
|
148
157
|
|
|
158
|
+
// Read each time: it is the transcript's name, which the daemon reports as sessionId,
|
|
159
|
+
// and a /clear may mint a new one mid-session.
|
|
160
|
+
async function sessionId($: EngineInterface, host: Host): Promise<string> {
|
|
161
|
+
return $.session.id().catch(() => host.session)
|
|
162
|
+
}
|
|
163
|
+
|
|
149
164
|
// Leaves one request for the inbox to apply; its poller takes it within half a second
|
|
150
165
|
// and the next snapshot shows the row where the gem's own rules put it.
|
|
151
166
|
async function ask($: EngineInterface, host: Host, action: Action): Promise<void> {
|
|
152
|
-
const
|
|
167
|
+
const actions = await inboxFile($, ACTIONS)
|
|
153
168
|
const at = Math.floor(await $.clock.now())
|
|
154
|
-
await $.fs.write(`${
|
|
169
|
+
await $.fs.write(`${actions}/${host.session}-${at}.json`, JSON.stringify({ ...action, at }))
|
|
155
170
|
}
|
|
156
171
|
|
|
157
172
|
// Asks the inbox attached to this session for another one. It takes the file when it
|
|
158
173
|
// does; a file still there after SWITCH_MS, and still ours, means no inbox is attached to
|
|
159
|
-
// this session.
|
|
160
|
-
// daemon reports as sessionId, and a /clear may mint a new one mid-session.
|
|
174
|
+
// this session.
|
|
161
175
|
async function requestSwitch($: EngineInterface, host: Host, id: string): Promise<void> {
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const from = await $.session.id().catch(() => host.session)
|
|
176
|
+
const path = await inboxFile($, SWITCH)
|
|
177
|
+
const from = await sessionId($, host)
|
|
165
178
|
const at = await nowS($)
|
|
166
179
|
await $.fs.write(path, JSON.stringify({ id, from, at }))
|
|
167
180
|
$.ui.toast(`switching to ${id}…`)
|
|
@@ -194,14 +207,13 @@ function startHeadless($: EngineInterface, host: Host, now: number): void {
|
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
async function poll($: EngineInterface, host: Host): Promise<void> {
|
|
197
|
-
const
|
|
210
|
+
const file = await inboxFile($, SNAPSHOT)
|
|
198
211
|
const now = await nowS($)
|
|
199
|
-
const snapshot = await readJson<Snapshot | null>($,
|
|
212
|
+
const snapshot = await readJson<Snapshot | null>($, file, null)
|
|
200
213
|
const written = snapshot?.written_at ?? 0
|
|
201
214
|
if (snapshot !== null) {
|
|
202
215
|
const all = ORDER.flatMap(section => (snapshot.sections?.[section] ?? []).map(one => rowOf(section, one)))
|
|
203
|
-
|
|
204
|
-
const id = await $.session.id().catch(() => host.session)
|
|
216
|
+
const id = await sessionId($, host)
|
|
205
217
|
await update($, current, () => all.find(one => one.session === id) ?? null)
|
|
206
218
|
await update($, rows, () => all.filter(one => one.session !== id))
|
|
207
219
|
}
|
|
@@ -209,7 +221,7 @@ async function poll($: EngineInterface, host: Host): Promise<void> {
|
|
|
209
221
|
if (now - written > STALE_S) startHeadless($, host, now)
|
|
210
222
|
}
|
|
211
223
|
|
|
212
|
-
// Puts the ring on the item's
|
|
224
|
+
// Puts the ring on the item's Button, so Enter acts on it, and scrolls it into view.
|
|
213
225
|
async function ringOn($: EngineInterface, item: Item): Promise<void> {
|
|
214
226
|
await $.ui.focus({ requestId: PANE, key: itemKey(item) }).catch(() => undefined)
|
|
215
227
|
await $.ui.scroll({ in: PANE, to: { key: viewKey(item) }, block: 'nearest' }).catch(() => undefined)
|
|
@@ -227,7 +239,7 @@ async function moveTo($: EngineInterface, flat: Item[], to: number): Promise<voi
|
|
|
227
239
|
async function setFold($: EngineInterface, list: InboxRow[], current: InboxRow | null, name: Fold | undefined, open: boolean): Promise<void> {
|
|
228
240
|
await update($, pending, () => null)
|
|
229
241
|
if (!name) return
|
|
230
|
-
const folded = await update($, folds, f => ({ ...
|
|
242
|
+
const folded = await update($, folds, f => ({ ...f, [name]: !open }))
|
|
231
243
|
// The item under the cursor may have just become a fold, or the fold its rows.
|
|
232
244
|
const groups = groupsOf(list, folded, current)
|
|
233
245
|
const at = groups.findIndex(group => group.section === name)
|
|
@@ -265,12 +277,12 @@ export const register: Register = (on, options) => {
|
|
|
265
277
|
return { text: `Inbox pane open on ${surfaces}. ctrl+x tab focuses it; the keys are claude-inbox's.` }
|
|
266
278
|
})
|
|
267
279
|
|
|
268
|
-
// The ring walks the items'
|
|
280
|
+
// The ring walks the items' Buttons and nothing else: entering the pane it lands on
|
|
269
281
|
// the cursor's item, the arrows move it to the next, and the cursor follows.
|
|
270
282
|
on('ui.focus', { requestId: PANE }, async ($, e, next) => {
|
|
271
|
-
const flat =
|
|
283
|
+
const flat = await listed($)
|
|
272
284
|
if (e.element === undefined) {
|
|
273
|
-
const item = flat[
|
|
285
|
+
const item = flat[onList(flat, await read($, cursor))]
|
|
274
286
|
return item ? next({ ...e, element: itemKey(item) }) : next(e)
|
|
275
287
|
}
|
|
276
288
|
const at = flat.findIndex(item => itemKey(item) === e.element)
|
|
@@ -284,8 +296,8 @@ export const register: Register = (on, options) => {
|
|
|
284
296
|
// moves the cursor instead, and the wheel, the page keys and the pane's own scrolls pass.
|
|
285
297
|
on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
|
|
286
298
|
if (e.origin.kind !== 'person' || e.pointer !== undefined || Math.abs(e.by) !== 1) return next(e)
|
|
287
|
-
const flat =
|
|
288
|
-
const at =
|
|
299
|
+
const flat = await listed($)
|
|
300
|
+
const at = onList(flat, await read($, cursor))
|
|
289
301
|
await moveTo($, flat, at + e.by)
|
|
290
302
|
return {}
|
|
291
303
|
})
|
|
@@ -300,7 +312,7 @@ export const register: Register = (on, options) => {
|
|
|
300
312
|
const ageS = (await nowS($)) - written
|
|
301
313
|
const groups = groupsOf(list, folded, viewing)
|
|
302
314
|
const flat = groups.flatMap(group => group.items)
|
|
303
|
-
const at =
|
|
315
|
+
const at = onList(flat, await read($, cursor))
|
|
304
316
|
const selected = flat[at]
|
|
305
317
|
const row = selected?.kind === 'row' ? selected.row : undefined
|
|
306
318
|
const section = selected?.kind === 'fold' ? selected.section : row === viewing ? undefined : row?.section
|
|
@@ -329,8 +341,10 @@ export const register: Register = (on, options) => {
|
|
|
329
341
|
void ask($, host, { action, id: row.id }).then(() => $.ui.toast(`${verb} ${row.label}`))
|
|
330
342
|
}
|
|
331
343
|
const key = (hotkey: string, onPress: () => void) => <Button key={hotkey} hotkey={hotkey} plain label="" onPress={onPress} />
|
|
332
|
-
// Enter on an item: a fold opens, a row the inbox can
|
|
344
|
+
// Enter or a click on an item: the cursor lands on it, a fold opens, a row the inbox can
|
|
345
|
+
// attach to asks for the switch.
|
|
333
346
|
const enter = (item: Item) => () => {
|
|
347
|
+
void moveTo($, flat, flat.indexOf(item))
|
|
334
348
|
if (item.kind === 'fold') return void setFold($, list, viewing, item.section, true)
|
|
335
349
|
if (item.row === viewing) return $.ui.toast('you are in this session')
|
|
336
350
|
if (!item.row.id || !item.row.actionable) return $.ui.toast('this session cannot be attached')
|
|
@@ -383,8 +397,8 @@ export const register: Register = (on, options) => {
|
|
|
383
397
|
|
|
384
398
|
return (
|
|
385
399
|
<Box flexDirection="column">
|
|
386
|
-
{/* The
|
|
387
|
-
<Box columnGap={2}
|
|
400
|
+
{/* The engine draws its close mark on a row of its own above the body, so the header can't share it. */}
|
|
401
|
+
<Box columnGap={2}>
|
|
388
402
|
<Text color="cyan" bold>
|
|
389
403
|
claude-inbox
|
|
390
404
|
</Text>
|
|
@@ -408,7 +422,7 @@ export const register: Register = (on, options) => {
|
|
|
408
422
|
return (
|
|
409
423
|
<Box key={viewKey(item)}>
|
|
410
424
|
<Text color={isHere ? 'yellow' : undefined}>{isHere ? '› ' : ' '}</Text>
|
|
411
|
-
<
|
|
425
|
+
<Button key={itemKey(item)} plain dimColor label={`▸ ${item.count} folded · za opens`} onPress={enter(item)} autoFocus={isHere || undefined} />
|
|
412
426
|
</Box>
|
|
413
427
|
)
|
|
414
428
|
}
|
|
@@ -421,15 +435,25 @@ export const register: Register = (on, options) => {
|
|
|
421
435
|
<Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
|
|
422
436
|
{GLYPH[one.state] ?? '·'}{' '}
|
|
423
437
|
</Text>
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
438
|
+
{/* A Button cannot truncate, so its Box clips a long label to the row. */}
|
|
439
|
+
<Box height={1} flexShrink={1} overflow="hidden">
|
|
440
|
+
<Button
|
|
441
|
+
key={itemKey(item)}
|
|
442
|
+
plain
|
|
443
|
+
dimColor={isQuiet && !isHere}
|
|
444
|
+
label={one.wakeAt !== undefined ? `${one.label} · ${wakeLabel(one.wakeAt)}` : one.label}
|
|
445
|
+
onPress={enter(item)}
|
|
446
|
+
autoFocus={isHere || undefined}
|
|
447
|
+
/>
|
|
448
|
+
</Box>
|
|
449
|
+
{/* The label gives up its cells to the PR chip, which would otherwise wrap under it. */}
|
|
428
450
|
{one.pr ? (
|
|
429
|
-
<
|
|
430
|
-
{
|
|
431
|
-
|
|
432
|
-
|
|
451
|
+
<Box flexShrink={0}>
|
|
452
|
+
<Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
|
|
453
|
+
{' '}
|
|
454
|
+
{one.pr.short} {one.pr.state}
|
|
455
|
+
</Text>
|
|
456
|
+
</Box>
|
|
433
457
|
) : null}
|
|
434
458
|
</Box>
|
|
435
459
|
{!isQuiet && one.line !== undefined ? (
|
|
@@ -457,11 +481,8 @@ export const register: Register = (on, options) => {
|
|
|
457
481
|
</Box>
|
|
458
482
|
)}
|
|
459
483
|
</Box>
|
|
460
|
-
{/* Drawn nowhere:
|
|
484
|
+
{/* Drawn nowhere: the hotkeys. */}
|
|
461
485
|
<Box height={0} overflow="hidden">
|
|
462
|
-
{flat.map(item => (
|
|
463
|
-
<Button key={itemKey(item)} plain label="" onPress={enter(item)} autoFocus={item === selected ? true : undefined} />
|
|
464
|
-
))}
|
|
465
486
|
{keys}
|
|
466
487
|
</Box>
|
|
467
488
|
</Box>
|
data/mod/tests/contract.test.tsx
CHANGED
|
@@ -17,7 +17,5 @@ test("the rows the pane reads are the ones the gem's Snapshot writes", () => {
|
|
|
17
17
|
['settled', 'c3', 'put away', 'done', true],
|
|
18
18
|
])
|
|
19
19
|
expect(rows[1]?.pr).toEqual({ short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' })
|
|
20
|
-
expect(rows[0]?.cwd).toBe('/tmp/proj')
|
|
21
20
|
expect(rows[0]?.isWaiting).toBe(false)
|
|
22
|
-
expect(rows[0]?.isTerminal).toBe(false)
|
|
23
21
|
})
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -12,7 +12,7 @@ const PANE = {
|
|
|
12
12
|
const ROWS = [
|
|
13
13
|
{ section: 'needs_you', id: 'a1', label: 'needs an answer', state: 'blocked', actionable: true, line: 'confirm: drop it?' },
|
|
14
14
|
{ section: 'active', id: 'b2', label: 'still going', state: 'working', actionable: true, line: 'running bin/ci', pr: { short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' } },
|
|
15
|
-
{ section: 'active', id: 'uuid-term', label: 'a terminal', state: 'working', actionable: false
|
|
15
|
+
{ section: 'active', id: 'uuid-term', label: 'a terminal', state: 'working', actionable: false },
|
|
16
16
|
{ section: 'settled', id: 'c3', label: 'put away', state: 'done', actionable: true },
|
|
17
17
|
]
|
|
18
18
|
|
|
@@ -78,18 +78,18 @@ test("rows sit under the gem's sections, Settled folded until za opens it", asyn
|
|
|
78
78
|
expect(await ui.find({ type: 'Text', text: /Settled · 1/ })).toBeDefined()
|
|
79
79
|
expect(await ui.find({ type: 'Text', text: /confirm: drop it\?/ })).toBeDefined()
|
|
80
80
|
expect(await ui.find({ type: 'Text', text: /#7 open/ })).toBeDefined()
|
|
81
|
-
expect(await ui.find({
|
|
82
|
-
expect(await ui.find({
|
|
81
|
+
expect(await ui.find({ text: /1 folded/ })).toBeDefined()
|
|
82
|
+
expect(await ui.find({ text: /put away/ })).toBeUndefined()
|
|
83
83
|
await ui.press({ key: 'g' })
|
|
84
84
|
await ui.press({ key: 'e' })
|
|
85
85
|
await ui.press({ key: 'z' })
|
|
86
86
|
expect(await ui.find({ type: 'Button', key: 'x' })).toBeUndefined()
|
|
87
87
|
await ui.press({ key: 'a' })
|
|
88
|
-
expect(await ui.find({
|
|
88
|
+
expect(await ui.find({ text: /put away/ })).toBeDefined()
|
|
89
89
|
expect(await ui.find({ type: 'Button', key: 'x' })).toBeDefined()
|
|
90
90
|
await ui.press({ key: 'z' })
|
|
91
91
|
await ui.press({ key: 'c' })
|
|
92
|
-
expect(await ui.find({
|
|
92
|
+
expect(await ui.find({ text: /put away/ })).toBeUndefined()
|
|
93
93
|
// Cursor and folds live in session state, so put them back for the next surface.
|
|
94
94
|
await ui.press({ key: 'g' })
|
|
95
95
|
await ui.press({ key: 'g' })
|
|
@@ -114,7 +114,7 @@ test('the session the pane sits in leads the list under This session, with its o
|
|
|
114
114
|
})
|
|
115
115
|
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
116
116
|
expect(await ui.find({ type: 'Text', text: /^This session$/ })).toBeDefined()
|
|
117
|
-
expect(await ui.find({
|
|
117
|
+
expect(await ui.find({ text: /needs an answer/ })).toBeDefined()
|
|
118
118
|
expect(await ui.find({ type: 'Text', text: /Needs you/ })).toBeUndefined()
|
|
119
119
|
await ui.press({ key: 'row:a1' })
|
|
120
120
|
expect(written).toEqual([])
|
|
@@ -172,6 +172,24 @@ test('Enter asks the attached inbox for the row, naming this session, and refuse
|
|
|
172
172
|
await ui.unmount()
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
+
test('a click on a row puts the cursor on it before it asks for the switch', async ($, on) => {
|
|
176
|
+
mock.env(on, { HOME: '/Users/me' })
|
|
177
|
+
mock.clock(on, { now: 1_700_000_000_000 })
|
|
178
|
+
on('state.get', ($, e, next) => (e.key === 'rows' ? seeded(ROWS) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
|
|
179
|
+
const written: { path: string; text: string }[] = []
|
|
180
|
+
on('fs.write', ($, e) => {
|
|
181
|
+
written.push({ path: e.path, text: e.text })
|
|
182
|
+
return { value: undefined }
|
|
183
|
+
})
|
|
184
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
185
|
+
expect((await ui.find({ key: 'row:b2' }))?.props.label).toBe('still going')
|
|
186
|
+
await ui.press({ key: 'row:b2' })
|
|
187
|
+
await ui.press({ key: 'x' })
|
|
188
|
+
expect(written.map(one => one.path)).toEqual(['/Users/me/.config/claude-inbox/switch.json', '/Users/me/.config/claude-inbox/actions/pane-1700000000000.json'])
|
|
189
|
+
expect(JSON.parse(written[1]?.text ?? '{}')).toMatchObject({ action: 'settle', id: 'b2' })
|
|
190
|
+
await ui.unmount()
|
|
191
|
+
})
|
|
192
|
+
|
|
175
193
|
test('an unfocused pane says how to focus it', async ($, on) => {
|
|
176
194
|
mock.env(on, { HOME: '/Users/me' })
|
|
177
195
|
mock.clock(on, { now: 1_700_000_000_000 })
|
|
@@ -13,8 +13,7 @@ export const SNAPSHOT = `{
|
|
|
13
13
|
"session": "u-a1",
|
|
14
14
|
"label": "needs an answer",
|
|
15
15
|
"state": "blocked",
|
|
16
|
-
"actionable": true
|
|
17
|
-
"cwd": "/tmp/proj"
|
|
16
|
+
"actionable": true
|
|
18
17
|
}
|
|
19
18
|
],
|
|
20
19
|
"active": [
|
|
@@ -24,7 +23,6 @@ export const SNAPSHOT = `{
|
|
|
24
23
|
"label": "still going",
|
|
25
24
|
"state": "working",
|
|
26
25
|
"actionable": true,
|
|
27
|
-
"cwd": "/tmp/proj",
|
|
28
26
|
"pr": {
|
|
29
27
|
"short": "#7",
|
|
30
28
|
"state": "open",
|
|
@@ -39,8 +37,7 @@ export const SNAPSHOT = `{
|
|
|
39
37
|
"session": "u-c3",
|
|
40
38
|
"label": "put away",
|
|
41
39
|
"state": "done",
|
|
42
|
-
"actionable": true
|
|
43
|
-
"cwd": "/tmp/proj"
|
|
40
|
+
"actionable": true
|
|
44
41
|
}
|
|
45
42
|
]
|
|
46
43
|
}
|
data/mod/types/index.d.ts
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-inbox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Byron
|
|
@@ -128,7 +128,6 @@ files:
|
|
|
128
128
|
- lib/claude_inbox/rate_limits.rb
|
|
129
129
|
- lib/claude_inbox/reaper.rb
|
|
130
130
|
- lib/claude_inbox/records.rb
|
|
131
|
-
- lib/claude_inbox/remote.rb
|
|
132
131
|
- lib/claude_inbox/remote/http.rb
|
|
133
132
|
- lib/claude_inbox/remote/icon.png
|
|
134
133
|
- lib/claude_inbox/remote/icon.svg
|