claude-inbox 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +8 -3
- data/exe/claude-inbox +35 -1
- data/lib/claude_inbox/actions.rb +7 -4
- data/lib/claude_inbox/agents_client.rb +17 -12
- data/lib/claude_inbox/app.rb +64 -6
- data/lib/claude_inbox/dialog.rb +40 -0
- data/lib/claude_inbox/headless.rb +20 -3
- data/lib/claude_inbox/images.rb +1 -1
- data/lib/claude_inbox/job_state.rb +16 -2
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/logs.rb +0 -1
- data/lib/claude_inbox/mod.rb +36 -1
- data/lib/claude_inbox/mouse.rb +1 -1
- data/lib/claude_inbox/new_session_form.rb +8 -3
- data/lib/claude_inbox/palette.rb +1 -1
- data/lib/claude_inbox/paste.rb +1 -1
- data/lib/claude_inbox/peek.rb +5 -9
- data/lib/claude_inbox/poller.rb +4 -6
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/reaper.rb +1 -1
- data/lib/claude_inbox/remote/http.rb +1 -1
- data/lib/claude_inbox/remote/page.html +7 -4
- data/lib/claude_inbox/remote/pairing.rb +2 -1
- data/lib/claude_inbox/remote/start.rb +1 -0
- data/lib/claude_inbox/renderer.rb +1 -1
- data/lib/claude_inbox/session_request.rb +6 -4
- data/lib/claude_inbox/slash_commands.rb +3 -3
- data/lib/claude_inbox/snapshot.rb +8 -3
- data/lib/claude_inbox/store/entry.rb +4 -5
- data/lib/claude_inbox/store/sections.rb +3 -3
- data/lib/claude_inbox/store.rb +1 -2
- data/lib/claude_inbox/text_buffer.rb +4 -9
- data/lib/claude_inbox/theme.rb +1 -1
- data/lib/claude_inbox/updater.rb +163 -0
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +29 -5
- data/mod/tests/contract.test.tsx +2 -1
- data/mod/tests/pane.test.tsx +63 -2
- data/mod/tests/snapshot.fixture.ts +4 -3
- data/mod/types/index.d.ts +3 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b325ed415502366088de4b6e9b7634835aea1fb0a6be708e211344dfeba8fa33
|
|
4
|
+
data.tar.gz: 53fbfbeac341e7f5643724911eb5feecb4165a6aaa367dad73c2a4002be9bee9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 072a5ce3e44c1e5317d105fd49aea27fc5e1365c146c165e8ad87b61b50c24f0046bd659278a41b3848ec15523c1eaa255748f5f080a4e26b25476ba6f7e1218
|
|
7
|
+
data.tar.gz: 31715b40f83b6749d3064c323018c74f62e1176f248bcc00b3cf16769b81c202b49cb0e7228e3ca8bd3b5519c8d7ca99001c026868d29605b488430a6662f5eb
|
data/README.md
CHANGED
|
@@ -18,6 +18,8 @@ gem install claude-inbox
|
|
|
18
18
|
claude-inbox
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
`claude-inbox update` installs the latest release.
|
|
22
|
+
|
|
21
23
|
Put arguments you want on every launch in `~/.config/claude-inbox/config`.
|
|
22
24
|
|
|
23
25
|
To add the pane to every Claude Code session:
|
|
@@ -26,6 +28,7 @@ To add the pane to every Claude Code session:
|
|
|
26
28
|
claude-inbox install
|
|
27
29
|
```
|
|
28
30
|
|
|
31
|
+
The inbox offers to do this once, the first time it starts without the pane.
|
|
29
32
|
`claude-inbox install --force` also replaces any other copy of the pane.
|
|
30
33
|
|
|
31
34
|
## Claude Code pane
|
|
@@ -37,7 +40,7 @@ a fullscreen terminal.
|
|
|
37
40
|
|
|
38
41
|
`/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
|
|
39
42
|
ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
|
|
40
|
-
`P`, `X`, `Ctrl-x`,
|
|
43
|
+
`P`, `X`, `Ctrl-x`, `/`, `R` or `U`. `Enter`, or a click on a row, switches to the
|
|
41
44
|
session when you attached through `claude-inbox`.
|
|
42
45
|
|
|
43
46
|
It opens when a session starts. To stop that, set
|
|
@@ -88,6 +91,7 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
88
91
|
| `Ctrl-x` | delete the session, transcript and worktree for good |
|
|
89
92
|
| `/` | filter; `Enter` keeps it, `Esc` clears |
|
|
90
93
|
| `R` | poll now |
|
|
94
|
+
| `U` | update claude-inbox when the header says a newer one is out |
|
|
91
95
|
| `q` | quit |
|
|
92
96
|
|
|
93
97
|
`X` and `Ctrl-x` both ask for `y` first.
|
|
@@ -95,8 +99,9 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
95
99
|
### New session
|
|
96
100
|
|
|
97
101
|
`n` opens a form for the prompt, name, directory, model, effort, permissions,
|
|
98
|
-
worktree
|
|
99
|
-
|
|
102
|
+
worktree, Remote Control and color, which tints the row as `/color` would.
|
|
103
|
+
`Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
|
|
104
|
+
it and attaches.
|
|
100
105
|
|
|
101
106
|
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
102
107
|
`[Image #1]`. Type `/` for a menu of your skills.
|
data/exe/claude-inbox
CHANGED
|
@@ -29,8 +29,28 @@ if ARGV.first == "install"
|
|
|
29
29
|
exit
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
if ARGV.first == "update"
|
|
33
|
+
require "claude_inbox/updater"
|
|
34
|
+
updater = ClaudeInbox::Updater.new
|
|
35
|
+
begin
|
|
36
|
+
update = updater.check(force: true)
|
|
37
|
+
rescue ClaudeInbox::Updater::Error => e
|
|
38
|
+
abort "claude-inbox: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
unless update
|
|
41
|
+
puts((updater.kind == :gem) ? "claude-inbox #{ClaudeInbox::VERSION} is the latest release." : "main is up to date.")
|
|
42
|
+
exit
|
|
43
|
+
end
|
|
44
|
+
puts update.installing
|
|
45
|
+
applied = updater.apply
|
|
46
|
+
abort "claude-inbox: #{applied.output.strip}" unless applied.success?
|
|
47
|
+
puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
|
|
48
|
+
exit
|
|
49
|
+
end
|
|
50
|
+
|
|
32
51
|
require "claude_inbox/app"
|
|
33
52
|
require "claude_inbox/config"
|
|
53
|
+
require "claude_inbox/updater"
|
|
34
54
|
require "claude_inbox/writer_lock"
|
|
35
55
|
require "tmpdir"
|
|
36
56
|
|
|
@@ -70,14 +90,27 @@ else
|
|
|
70
90
|
opts[:snapshot] = ClaudeInbox::Snapshot.new
|
|
71
91
|
opts[:switch] = ClaudeInbox::Switch.new
|
|
72
92
|
opts[:actions] = ClaudeInbox::Actions.new
|
|
93
|
+
opts[:pane_offer] = ClaudeInbox::Mod::Offer.new
|
|
73
94
|
opts[:listen] = listen
|
|
95
|
+
opts[:updater] = ClaudeInbox::Updater.new unless argv.include?("--no-update-check")
|
|
96
|
+
# The typed arguments, not the config's: a relaunch reads the config again.
|
|
97
|
+
opts[:argv] = ARGV
|
|
74
98
|
end
|
|
75
99
|
opts[:color] = false if argv.include?("--no-color")
|
|
76
100
|
|
|
77
101
|
if argv.include?("--headless")
|
|
78
102
|
abort "claude-inbox: --headless takes no --fixture" if fixture
|
|
79
103
|
require "claude_inbox/headless"
|
|
80
|
-
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store]).run ? 0 : 1)
|
|
104
|
+
exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store], updater: opts[:updater]).run ? 0 : 1)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Only what a check within the day already found: a cold launch never waits
|
|
108
|
+
# on the network. The relaunch is the new version's launch; a failure leaves
|
|
109
|
+
# this one to start as usual.
|
|
110
|
+
if argv.include?("--auto-update") && (update = opts[:updater]&.cached)
|
|
111
|
+
puts update.installing
|
|
112
|
+
applied = opts[:updater].apply
|
|
113
|
+
applied.success? ? opts[:updater].relaunch(ARGV) : warn("claude-inbox: #{applied.output.strip}")
|
|
81
114
|
end
|
|
82
115
|
|
|
83
116
|
# The first inbox you can see is the snapshot's writer: a headless one gives
|
|
@@ -89,6 +122,7 @@ unless writer_lock.take(role: "inbox")
|
|
|
89
122
|
opts[:reaper] = ClaudeInbox::Reaper.disabled
|
|
90
123
|
opts[:snapshot] = ClaudeInbox::Snapshot.disabled
|
|
91
124
|
opts[:actions] = ClaudeInbox::Actions.disabled
|
|
125
|
+
opts[:pane_offer] = ClaudeInbox::Mod::Offer.disabled
|
|
92
126
|
end
|
|
93
127
|
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
94
128
|
ClaudeInbox::Mod.link unless fixture
|
data/lib/claude_inbox/actions.rb
CHANGED
|
@@ -11,7 +11,6 @@ module ClaudeInbox
|
|
|
11
11
|
# the store cannot honour after that, or a verb it lacks, is dropped.
|
|
12
12
|
class Actions
|
|
13
13
|
DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "actions")
|
|
14
|
-
VERBS = %w[settle wake pin snooze acknowledge].freeze
|
|
15
14
|
SNOOZES = %w[m15 h1 tomorrow_9am until_woken].freeze
|
|
16
15
|
# A file still being written parses as nothing for milliseconds; a
|
|
17
16
|
# session not yet polled appears within one poll.
|
|
@@ -62,12 +61,14 @@ module ClaudeInbox
|
|
|
62
61
|
def parse(path)
|
|
63
62
|
request = JSON.parse(File.read(path))
|
|
64
63
|
request = {} unless request.is_a?(Hash)
|
|
65
|
-
return request if
|
|
64
|
+
return request if stale?(path)
|
|
66
65
|
(request.key?("id") && request.key?("action")) ? request : nil
|
|
67
66
|
rescue JSON::ParserError
|
|
68
|
-
(
|
|
67
|
+
stale?(path) ? {} : nil
|
|
69
68
|
end
|
|
70
69
|
|
|
70
|
+
def stale?(path) = File.mtime(path) < @clock.call - GRACE
|
|
71
|
+
|
|
71
72
|
def young?(request)
|
|
72
73
|
at = request["at"].to_i / 1000
|
|
73
74
|
at.positive? && Time.at(at) >= @clock.call - GRACE
|
|
@@ -75,7 +76,7 @@ module ClaudeInbox
|
|
|
75
76
|
|
|
76
77
|
def apply(store, request, keys)
|
|
77
78
|
id = request["id"]
|
|
78
|
-
return false unless keys.include?(id)
|
|
79
|
+
return false unless keys.include?(id)
|
|
79
80
|
case request["action"]
|
|
80
81
|
when "settle" then store.settle(id)
|
|
81
82
|
when "wake" then store.wake(id)
|
|
@@ -84,6 +85,8 @@ module ClaudeInbox
|
|
|
84
85
|
when "snooze"
|
|
85
86
|
return false unless SNOOZES.include?(request["choice"])
|
|
86
87
|
store.snooze(id, request["choice"].to_sym)
|
|
88
|
+
else
|
|
89
|
+
return false
|
|
87
90
|
end
|
|
88
91
|
true
|
|
89
92
|
end
|
|
@@ -59,17 +59,23 @@ module ClaudeInbox
|
|
|
59
59
|
watchdog&.kill
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def stop(id) = run(
|
|
62
|
+
def stop(id) = run("stop", id)
|
|
63
63
|
|
|
64
|
-
def rm(id) = run(
|
|
64
|
+
def rm(id) = run("rm", id)
|
|
65
65
|
|
|
66
66
|
MODELS = %w[default fable opus sonnet haiku].freeze
|
|
67
67
|
EFFORTS = %w[default low medium high xhigh max].freeze
|
|
68
68
|
PERMISSION_MODES = %w[default acceptEdits auto plan bypassPermissions].freeze
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
# The colors `/color` offers, in its order.
|
|
70
|
+
COLORS = %w[default red blue green yellow purple orange pink cyan].freeze
|
|
71
|
+
|
|
72
|
+
# Start a background session. Returns its short id. `claude --bg` takes
|
|
73
|
+
# no color, and `/color` refuses a prompt after the color, so the color
|
|
74
|
+
# goes into the job file the way `/color` would put it there.
|
|
75
|
+
def spawn(prompt:, cwd:, color: nil, **opts)
|
|
76
|
+
id = start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
|
|
77
|
+
JobState.write_color(id, color, jobs_dir: @jobs_dir) if color
|
|
78
|
+
id
|
|
73
79
|
end
|
|
74
80
|
|
|
75
81
|
# How a prompt attaches a file: the same @ mention the CLI's own prompt
|
|
@@ -239,10 +245,9 @@ module ClaudeInbox
|
|
|
239
245
|
nil
|
|
240
246
|
end
|
|
241
247
|
|
|
242
|
-
def run(
|
|
243
|
-
r = Subprocess.capture(
|
|
244
|
-
raise Error, "#{
|
|
245
|
-
true
|
|
248
|
+
def run(verb, id)
|
|
249
|
+
r = Subprocess.capture(@bin, verb, id)
|
|
250
|
+
raise Error, "#{verb} failed: #{r.err.strip}" unless r.success?
|
|
246
251
|
end
|
|
247
252
|
|
|
248
253
|
def start_bg(argv, cwd, what)
|
|
@@ -275,7 +280,7 @@ module ClaudeInbox
|
|
|
275
280
|
# terminal round-trips through cooked mode and back.
|
|
276
281
|
def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
|
|
277
282
|
|
|
278
|
-
def stop(_id) =
|
|
283
|
+
def stop(_id) = nil
|
|
279
284
|
|
|
280
285
|
def spawn(prompt:, cwd:, **)
|
|
281
286
|
sleep 0.5
|
|
@@ -284,6 +289,6 @@ module ClaudeInbox
|
|
|
284
289
|
|
|
285
290
|
def adopt(session_id:, cwd:, pid:) = "adop7ed0"
|
|
286
291
|
|
|
287
|
-
def rm(_id) =
|
|
292
|
+
def rm(_id) = nil
|
|
288
293
|
end
|
|
289
294
|
end
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -24,6 +24,8 @@ require_relative "actions"
|
|
|
24
24
|
require_relative "snapshot"
|
|
25
25
|
require_relative "switch"
|
|
26
26
|
require_relative "trust"
|
|
27
|
+
require_relative "mod"
|
|
28
|
+
require_relative "updater"
|
|
27
29
|
|
|
28
30
|
module ClaudeInbox
|
|
29
31
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
@@ -33,14 +35,17 @@ module ClaudeInbox
|
|
|
33
35
|
# so both are off unless `bin/claude-inbox` switches them on.
|
|
34
36
|
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
35
37
|
rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
|
|
36
|
-
actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
|
|
37
|
-
terminal: Terminal.new(out, input), queue: Queue.new)
|
|
38
|
+
actions: Actions.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
|
|
39
|
+
trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled)
|
|
38
40
|
@client = client
|
|
39
41
|
@store = store
|
|
40
42
|
@snapshot = snapshot
|
|
41
43
|
@switch = switch
|
|
42
44
|
@rate_limits = rate_limits
|
|
43
45
|
@terminal = terminal
|
|
46
|
+
@updater = updater
|
|
47
|
+
@argv = argv
|
|
48
|
+
@out = out
|
|
44
49
|
@color = color
|
|
45
50
|
@trust = trust
|
|
46
51
|
@renderer = Renderer.new(color: color)
|
|
@@ -65,6 +70,8 @@ module ClaudeInbox
|
|
|
65
70
|
@last_poll = nil
|
|
66
71
|
@quit = false
|
|
67
72
|
@resize = false
|
|
73
|
+
@pane_offer = pane_offer
|
|
74
|
+
@modal = Dialog::PaneOffer.new if pane_offer.take
|
|
68
75
|
end
|
|
69
76
|
|
|
70
77
|
def run
|
|
@@ -83,6 +90,7 @@ module ClaudeInbox
|
|
|
83
90
|
end
|
|
84
91
|
|
|
85
92
|
def step(input = nil)
|
|
93
|
+
check_for_update unless @update_status
|
|
86
94
|
drain_queue
|
|
87
95
|
@logs.tick
|
|
88
96
|
if @resize
|
|
@@ -124,6 +132,7 @@ module ClaudeInbox
|
|
|
124
132
|
@error = nil
|
|
125
133
|
when :error then @error = rest[0]
|
|
126
134
|
when :notice then notice(rest[0])
|
|
135
|
+
when :update then update_checked(*rest)
|
|
127
136
|
when :select then @pending_select = rest[0]
|
|
128
137
|
when :attach then attach(rest[0])
|
|
129
138
|
when :form_started then @modal = nil if @modal.equal?(rest[0])
|
|
@@ -131,8 +140,6 @@ module ClaudeInbox
|
|
|
131
140
|
when :remote_started then remote_started(*rest)
|
|
132
141
|
end
|
|
133
142
|
end
|
|
134
|
-
rescue ThreadError
|
|
135
|
-
nil
|
|
136
143
|
end
|
|
137
144
|
|
|
138
145
|
# ----- main loop --------------------------------------------------------
|
|
@@ -208,7 +215,49 @@ module ClaudeInbox
|
|
|
208
215
|
def status_text(now)
|
|
209
216
|
return @notice[0] if @notice && now < @notice[1]
|
|
210
217
|
return "⚠ #{@error}" if @error
|
|
211
|
-
"polling…" unless @last_poll
|
|
218
|
+
return "polling…" unless @last_poll
|
|
219
|
+
"#{@update.notice} · U updates" if @update
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# ----- updates ----------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
# Once, on the first step: a stale cache means a network round trip, so
|
|
225
|
+
# it runs off the main thread. A laptop that is offline stays quiet
|
|
226
|
+
# rather than show an error at every launch; U says what went wrong.
|
|
227
|
+
def check_for_update
|
|
228
|
+
@update_status = @updater ? "checking for updates…" : "update check is off — claude-inbox update installs the latest"
|
|
229
|
+
return unless @updater
|
|
230
|
+
in_background do
|
|
231
|
+
@queue << [:update, @updater.check]
|
|
232
|
+
rescue Updater::Error => e
|
|
233
|
+
@queue << [:update, nil, e.message]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def update_checked(update, failure = nil)
|
|
238
|
+
@update = update
|
|
239
|
+
@snapshot.update = update&.notice
|
|
240
|
+
@update_status = failure ? "couldn't check for updates: #{failure}" : "up to date"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The install prints on the real screen, and the relaunch replaces this
|
|
244
|
+
# process. A failed install relaunches this version instead, once its
|
|
245
|
+
# error has been read, rather than leave the shell showing.
|
|
246
|
+
def update_inbox
|
|
247
|
+
return notice(@update_status) unless @update
|
|
248
|
+
@terminal.release do
|
|
249
|
+
@out.puts @update.installing
|
|
250
|
+
applied = @updater.apply
|
|
251
|
+
@out.puts applied.output unless applied.output.empty?
|
|
252
|
+
@terminal.pause unless applied.success?
|
|
253
|
+
relaunch
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def relaunch
|
|
258
|
+
@updater.relaunch(@argv)
|
|
259
|
+
rescue SystemCallError => e
|
|
260
|
+
@error = "relaunch failed: #{e.message}"
|
|
212
261
|
end
|
|
213
262
|
|
|
214
263
|
def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
|
|
@@ -227,7 +276,7 @@ module ClaudeInbox
|
|
|
227
276
|
|
|
228
277
|
def select(selection)
|
|
229
278
|
@selected = selection
|
|
230
|
-
@peek.select(
|
|
279
|
+
@peek.select(selected_session)
|
|
231
280
|
end
|
|
232
281
|
|
|
233
282
|
def session_for(key) = @store.sessions.find { |s| s.key == key }
|
|
@@ -349,6 +398,7 @@ module ClaudeInbox
|
|
|
349
398
|
when :stop then open_confirm(:stop)
|
|
350
399
|
when :delete then open_confirm(:delete)
|
|
351
400
|
when :refresh then @poller.soon
|
|
401
|
+
when :update then update_inbox
|
|
352
402
|
when :toggle_peek then toggle_peek
|
|
353
403
|
when :new_session then open_new_session
|
|
354
404
|
when :remote_pairing then @modal = @listener.pairing_dialog
|
|
@@ -538,6 +588,7 @@ module ClaudeInbox
|
|
|
538
588
|
when :stop then stop_session(id)
|
|
539
589
|
when :delete then delete_session(id)
|
|
540
590
|
when :adopt then adopt_session(id)
|
|
591
|
+
when :install then install_pane
|
|
541
592
|
end
|
|
542
593
|
when :save then save_prompt
|
|
543
594
|
when :copy then @listener.copy_pairing_url
|
|
@@ -576,6 +627,13 @@ module ClaudeInbox
|
|
|
576
627
|
end
|
|
577
628
|
end
|
|
578
629
|
|
|
630
|
+
def install_pane
|
|
631
|
+
@pane_offer.install
|
|
632
|
+
notice("added the pane — new Claude Code sessions load it; /inbox opens it")
|
|
633
|
+
rescue JSON::ParserError
|
|
634
|
+
notice("~/.claude/settings.json is not valid JSON, so it was left alone")
|
|
635
|
+
end
|
|
636
|
+
|
|
579
637
|
def delete_session(id)
|
|
580
638
|
notice("deleting #{id}…")
|
|
581
639
|
in_background do
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "tty-box"
|
|
4
|
+
require_relative "text"
|
|
4
5
|
require_relative "text_buffer"
|
|
5
6
|
|
|
6
7
|
module ClaudeInbox
|
|
@@ -78,6 +79,45 @@ module ClaudeInbox
|
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
# The first launch without the Claude Code pane asks once whether to add
|
|
83
|
+
# it, and has to say what the pane is to someone who has never seen it.
|
|
84
|
+
class PaneOffer < Confirm
|
|
85
|
+
WIDTH = 60
|
|
86
|
+
SKETCH = [
|
|
87
|
+
" ┌──────────────────────────┬─────────────────────┐",
|
|
88
|
+
" │ > fix the flaky spec │ 📥 Inbox │",
|
|
89
|
+
" │ │ ● needs you docs │",
|
|
90
|
+
" │ Reading app.rb… │ ⠋ working api │",
|
|
91
|
+
" │ │ ✓ done auth │",
|
|
92
|
+
" └──────────────────────────┴─────────────────────┘"
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
def initialize = super(:install, nil)
|
|
96
|
+
|
|
97
|
+
def title = " ✨ The inbox, inside Claude Code "
|
|
98
|
+
|
|
99
|
+
# Drawn by hand: TTY::Box measures an emoji as one column, which
|
|
100
|
+
# pushes the right border out on every line that has one.
|
|
101
|
+
def frame(width, _caret = nil)
|
|
102
|
+
inner = [width - 4, WIDTH].min - 2
|
|
103
|
+
["┌#{title}#{"─" * (inner - Text.width(title))}┐",
|
|
104
|
+
*lines(inner, nil).map { |l| "│#{Text.pad(Text.truncate(l, inner), inner)}│" },
|
|
105
|
+
"└#{"─" * inner}┘"]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def lines(_width, _caret)
|
|
109
|
+
["", *SKETCH, "",
|
|
110
|
+
" • Every session, docked beside the transcript",
|
|
111
|
+
" • Switch sessions without leaving the one you're in",
|
|
112
|
+
" • Snooze, pin and settle with the same keys",
|
|
113
|
+
" • /inbox opens it, ctrl+x tab focuses it",
|
|
114
|
+
"",
|
|
115
|
+
" You can install this later with claude-inbox install",
|
|
116
|
+
"",
|
|
117
|
+
" y add it to Claude Code esc not now"]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
81
121
|
# `a` alias and `P` pull request: one line of text, read from `value`
|
|
82
122
|
# once :save comes back. Empty means clear.
|
|
83
123
|
class Prompt < Dialog
|
|
@@ -5,6 +5,7 @@ require_relative "poller"
|
|
|
5
5
|
require_relative "pull_requests"
|
|
6
6
|
require_relative "reaper"
|
|
7
7
|
require_relative "snapshot"
|
|
8
|
+
require_relative "updater"
|
|
8
9
|
require_relative "writer_lock"
|
|
9
10
|
|
|
10
11
|
module ClaudeInbox
|
|
@@ -13,18 +14,34 @@ module ClaudeInbox
|
|
|
13
14
|
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
14
15
|
# exits at once while an inbox holds the writer lock.
|
|
15
16
|
class Headless
|
|
16
|
-
def initialize(client:, store:,
|
|
17
|
+
def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
|
|
18
|
+
lock: WriterLock.new, updater: nil)
|
|
17
19
|
@lock = lock
|
|
20
|
+
@snapshot = snapshot
|
|
21
|
+
@updater = updater
|
|
18
22
|
@queue = Queue.new
|
|
19
|
-
@poller = Poller.new(client: client, store: store, pull_requests:
|
|
20
|
-
queue: @queue, snapshot:
|
|
23
|
+
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
|
|
24
|
+
queue: @queue, snapshot: snapshot, actions: actions)
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
# False when the lock is held, by an inbox or another headless process.
|
|
24
28
|
def run
|
|
25
29
|
return false unless @lock.take(role: "headless")
|
|
26
30
|
@poller.start
|
|
31
|
+
check_for_update if @updater
|
|
27
32
|
loop { @queue.pop }
|
|
28
33
|
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# After the poller is off, since the check may wait on the network. The
|
|
38
|
+
# pane cannot swap the process under Claude Code, so the snapshot only
|
|
39
|
+
# says a newer one is out, and a check that cannot tell says nothing.
|
|
40
|
+
def check_for_update
|
|
41
|
+
@snapshot.update = @updater.check&.notice
|
|
42
|
+
@poller.soon
|
|
43
|
+
rescue Updater::Error
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
29
46
|
end
|
|
30
47
|
end
|
data/lib/claude_inbox/images.rb
CHANGED
|
@@ -41,7 +41,7 @@ module ClaudeInbox
|
|
|
41
41
|
# `index` orders the images one request brings; the random part keeps
|
|
42
42
|
# two requests saving in the same millisecond apart. Readable by the
|
|
43
43
|
# owner only: a photo can be anything.
|
|
44
|
-
def self.save(bytes, dir
|
|
44
|
+
def self.save(bytes, dir:, index:, now: Time.now)
|
|
45
45
|
data = bytes.b
|
|
46
46
|
ext = extension(data)
|
|
47
47
|
raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
|
|
@@ -4,7 +4,8 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module ClaudeInbox
|
|
6
6
|
# What the daemon writes about a background session in
|
|
7
|
-
# ~/.claude/jobs/<id>/state.json. The only reader of that file
|
|
7
|
+
# ~/.claude/jobs/<id>/state.json. The only reader of that file, and the
|
|
8
|
+
# only writer: a new session's color.
|
|
8
9
|
#
|
|
9
10
|
# `claude agents --json` reports one word, `state`, and "working" covers two
|
|
10
11
|
# different situations: the agent is thinking, or the agent is idle and
|
|
@@ -50,6 +51,19 @@ module ClaudeInbox
|
|
|
50
51
|
nil
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
# The job file is there by the time `claude --bg` returns, and the daemon
|
|
55
|
+
# updates it read-modify-write, so the color stays through work, stop and
|
|
56
|
+
# respawn (checked on 2.1.296). A color is cosmetic: failing to write one
|
|
57
|
+
# must not report a started session as failed.
|
|
58
|
+
# ponytail: a daemon write racing this one can drop the color; re-apply on poll if that shows up.
|
|
59
|
+
def self.write_color(id, color, jobs_dir:)
|
|
60
|
+
path = File.join(jobs_dir, id, "state.json")
|
|
61
|
+
File.write("#{path}.tmp", JSON.generate(JSON.parse(File.read(path)).merge("color" => color)))
|
|
62
|
+
File.rename("#{path}.tmp", path)
|
|
63
|
+
rescue JSON::ParserError, SystemCallError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
attr_reader :pr_urls, :color, :intent, :bridge_id
|
|
54
68
|
|
|
55
69
|
def initialize(hash)
|
|
@@ -59,7 +73,7 @@ module ClaudeInbox
|
|
|
59
73
|
@tempo = hash["tempo"]
|
|
60
74
|
@kinds = (hash["fan"] || []).filter_map { |f| f["kind"] }
|
|
61
75
|
@tasks = (hash["inFlight"] || {})["tasks"].to_i
|
|
62
|
-
@pr_urls = (hash["children"] || []).
|
|
76
|
+
@pr_urls = (hash["children"] || []).filter_map { |c| c["href"] if c["kind"] == "pr" }
|
|
63
77
|
@color = hash["color"]
|
|
64
78
|
@intent = hash["intent"]
|
|
65
79
|
@bridge_id = hash["bridgeSessionId"]
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -27,7 +27,7 @@ module ClaudeInbox
|
|
|
27
27
|
"s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
|
|
28
28
|
:ctrl_x => :delete,
|
|
29
29
|
"o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
|
|
30
|
-
"R" => :refresh, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
|
|
30
|
+
"R" => :refresh, "U" => :update, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
|
|
31
31
|
:tab => :next_section, :back_tab => :prev_section,
|
|
32
32
|
"/" => :filter, :escape => :escape,
|
|
33
33
|
"q" => :quit, :ctrl_c => :quit
|
data/lib/claude_inbox/logs.rb
CHANGED
data/lib/claude_inbox/mod.rb
CHANGED
|
@@ -11,6 +11,7 @@ module ClaudeInbox
|
|
|
11
11
|
DIR = File.expand_path("../../mod", __dir__)
|
|
12
12
|
LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
|
|
13
13
|
NAME = "inbox-pane"
|
|
14
|
+
SETTINGS = File.join(Dir.home, ".claude", "settings.json")
|
|
14
15
|
CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
|
|
15
16
|
# What the pane runs headless: a checkout's own, or the gem's from PATH.
|
|
16
17
|
COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
|
|
@@ -38,7 +39,7 @@ module ClaudeInbox
|
|
|
38
39
|
# drops the other folders holding this plugin, such as a worktree's, and
|
|
39
40
|
# sets the command the pane runs to this copy's. Raises JSON::ParserError
|
|
40
41
|
# rather than rewrite a settings file it cannot read.
|
|
41
|
-
def self.install(settings:
|
|
42
|
+
def self.install(settings: SETTINGS, link: LINK, dir: DIR, force: false, command: COMMAND)
|
|
42
43
|
self.link(link: link, dir: dir, force: true)
|
|
43
44
|
json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
|
|
44
45
|
env = json["env"] ||= {}
|
|
@@ -61,6 +62,40 @@ module ClaudeInbox
|
|
|
61
62
|
installed
|
|
62
63
|
end
|
|
63
64
|
|
|
65
|
+
# Whether any folder in CLAUDE_CODE_PLUGIN_DIRS holds the pane, a
|
|
66
|
+
# worktree's included. A settings file it cannot read counts as yes, since
|
|
67
|
+
# install would refuse to touch it.
|
|
68
|
+
def self.installed?(settings: SETTINGS)
|
|
69
|
+
json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
|
|
70
|
+
json.dig("env", "CLAUDE_CODE_PLUGIN_DIRS").to_s.split(File::PATH_SEPARATOR).any? { |d| plugin?(d.strip) }
|
|
71
|
+
rescue JSON::ParserError, Errno::EACCES
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Asks once, on the first launch that finds the pane missing, whether to
|
|
76
|
+
# add it. Whatever the answer, a marker file keeps it from asking again.
|
|
77
|
+
class Offer
|
|
78
|
+
MARKER = File.join(Dir.home, ".config", "claude-inbox", "pane-offered")
|
|
79
|
+
|
|
80
|
+
def self.disabled = new(marker: nil)
|
|
81
|
+
|
|
82
|
+
def initialize(marker: MARKER, settings: SETTINGS, link: LINK)
|
|
83
|
+
@marker = marker
|
|
84
|
+
@settings = settings
|
|
85
|
+
@link = link
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# True once: the first time it is asked while the pane is missing.
|
|
89
|
+
def take
|
|
90
|
+
return false if @marker.nil? || File.exist?(@marker) || Mod.installed?(settings: @settings)
|
|
91
|
+
FileUtils.mkdir_p(File.dirname(@marker))
|
|
92
|
+
FileUtils.touch(@marker)
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def install = Mod.install(settings: @settings, link: @link)
|
|
97
|
+
end
|
|
98
|
+
|
|
64
99
|
def self.plugin?(folder)
|
|
65
100
|
JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
|
|
66
101
|
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
|
data/lib/claude_inbox/mouse.rb
CHANGED
|
@@ -14,7 +14,7 @@ module ClaudeInbox
|
|
|
14
14
|
# "esc gg" glues onto one read (see App#split_keys), so this scans
|
|
15
15
|
# rather than matching once.
|
|
16
16
|
def self.events(raw)
|
|
17
|
-
raw.
|
|
17
|
+
raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
|
|
18
18
|
kind = kind_for(cb.to_i, type)
|
|
19
19
|
Event.new(kind, row.to_i, col.to_i) if kind
|
|
20
20
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "pastel"
|
|
4
4
|
require_relative "agents_client"
|
|
5
5
|
require_relative "images"
|
|
6
|
+
require_relative "palette"
|
|
6
7
|
require_relative "session_request"
|
|
7
8
|
require_relative "settings"
|
|
8
9
|
require_relative "slash_commands"
|
|
@@ -37,19 +38,21 @@ module ClaudeInbox
|
|
|
37
38
|
def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
38
39
|
@p = Pastel.new(enabled: color)
|
|
39
40
|
@theme = Theme.new(enabled: color)
|
|
41
|
+
@palette = Palette.new(enabled: color)
|
|
40
42
|
@home = home
|
|
41
43
|
@clipboard = clipboard
|
|
42
44
|
@places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
|
|
43
|
-
@dir_settled = cwd
|
|
45
|
+
@dir_settled = cwd
|
|
44
46
|
@fields = [
|
|
45
47
|
Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
|
|
46
48
|
Field.new(:name, "Name", :text, TextBuffer.new),
|
|
47
|
-
Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd
|
|
49
|
+
Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd)),
|
|
48
50
|
Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
|
|
49
51
|
Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
|
|
50
52
|
Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
|
|
51
53
|
Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
|
|
52
|
-
Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
|
|
54
|
+
Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"]),
|
|
55
|
+
Field.new(:color, "Color", :choice, DEFAULT, AgentsClient::COLORS)
|
|
53
56
|
]
|
|
54
57
|
@focus = 0
|
|
55
58
|
@error = nil
|
|
@@ -320,6 +323,7 @@ module ClaudeInbox
|
|
|
320
323
|
f.choices.map { |c|
|
|
321
324
|
text = (c == DEFAULT) ? default_text(f) : c
|
|
322
325
|
if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
|
|
326
|
+
elsif f.key == :color then @palette.paint(" #{text} ", c)
|
|
323
327
|
else @p.dim(" #{text} ")
|
|
324
328
|
end
|
|
325
329
|
}.join(" ")
|
|
@@ -327,6 +331,7 @@ module ClaudeInbox
|
|
|
327
331
|
end
|
|
328
332
|
|
|
329
333
|
def default_text(f)
|
|
334
|
+
return "none" if f.key == :color
|
|
330
335
|
resolved = defaults[f.key]
|
|
331
336
|
resolved ? "#{resolved} (settings)" : "auto (cli default)"
|
|
332
337
|
end
|