claude-inbox 0.6.0 → 0.7.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 +1 -3
- data/exe/claude-inbox +2 -125
- data/lib/claude_inbox/agents_client.rb +1 -11
- data/lib/claude_inbox/app/attaching.rb +77 -0
- data/lib/claude_inbox/app/browsing.rb +29 -0
- data/lib/claude_inbox/app/filtering.rb +37 -0
- data/lib/claude_inbox/app/navigating.rb +91 -0
- data/lib/claude_inbox/app/spawning.rb +62 -0
- data/lib/claude_inbox/app/triaging.rb +76 -0
- data/lib/claude_inbox/app/updating.rb +56 -0
- data/lib/claude_inbox/app.rb +93 -470
- data/lib/claude_inbox/cli.rb +201 -0
- data/lib/claude_inbox/input.rb +65 -0
- data/lib/claude_inbox/job_state.rb +10 -2
- data/lib/claude_inbox/keymap.rb +2 -4
- data/lib/claude_inbox/mod.rb +8 -5
- data/lib/claude_inbox/mouse.rb +1 -1
- data/lib/claude_inbox/pull_requests.rb +2 -3
- data/lib/claude_inbox/remote/listener.rb +6 -7
- data/lib/claude_inbox/renderer.rb +12 -39
- data/lib/claude_inbox/session.rb +4 -0
- data/lib/claude_inbox/slash_commands.rb +6 -7
- data/lib/claude_inbox/store/row.rb +1 -1
- data/lib/claude_inbox/store/sections.rb +2 -5
- data/lib/claude_inbox/store.rb +1 -1
- data/lib/claude_inbox/vt_screen.rb +4 -6
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +11 -5
- data/mod/tests/pane.test.tsx +32 -0
- metadata +10 -3
- data/lib/claude_inbox/logs.rb +0 -68
- data/lib/claude_inbox/peek.rb +0 -77
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "app"
|
|
6
|
+
require_relative "config"
|
|
7
|
+
require_relative "headless"
|
|
8
|
+
require_relative "mod"
|
|
9
|
+
require_relative "updater"
|
|
10
|
+
require_relative "writer_lock"
|
|
11
|
+
|
|
12
|
+
module ClaudeInbox
|
|
13
|
+
# What `claude-inbox` does with its arguments: install the pane, update
|
|
14
|
+
# claude-inbox, or launch the inbox. `run` is true when it succeeded.
|
|
15
|
+
class CLI
|
|
16
|
+
USAGE = <<~USAGE
|
|
17
|
+
Usage: claude-inbox [options]
|
|
18
|
+
claude-inbox install [--force]
|
|
19
|
+
claude-inbox update
|
|
20
|
+
USAGE
|
|
21
|
+
|
|
22
|
+
# `lock` stands in for the writer lock a live launch takes.
|
|
23
|
+
def initialize(argv, env: ENV, config: Config::PATH, lock: nil, out: $stdout)
|
|
24
|
+
@argv = argv
|
|
25
|
+
@env = env
|
|
26
|
+
@config = config
|
|
27
|
+
@lock = lock
|
|
28
|
+
@out = out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
case @argv.first
|
|
33
|
+
when "install" then install
|
|
34
|
+
when "update" then update
|
|
35
|
+
else launch
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The launch flags, the config file's before the typed ones. A fixture
|
|
40
|
+
# run takes the listen flags, for trying the API with curl, but not the
|
|
41
|
+
# environment or the config file, which are for the real inbox.
|
|
42
|
+
def options
|
|
43
|
+
@options ||= begin
|
|
44
|
+
typed = parse(@argv)
|
|
45
|
+
options = typed[:fixture] ? typed : parse(Config.argv(@argv, path: @config))
|
|
46
|
+
env = typed[:fixture] ? {} : @env
|
|
47
|
+
options.merge(listen: Remote::Listener.options(env, flag: options[:listen], modes: options[:allow_modes]))
|
|
48
|
+
end
|
|
49
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
50
|
+
abort "claude-inbox: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The keywords App is built with. A live launch takes the writer lock
|
|
54
|
+
# here: the first inbox you can see is the snapshot's writer, and a
|
|
55
|
+
# second one reads along, writing and deleting nothing.
|
|
56
|
+
def app_options
|
|
57
|
+
opts = options[:fixture] ? fixture_options : live_options
|
|
58
|
+
opts[:color] = options[:color]
|
|
59
|
+
return opts if options[:fixture] || writer_lock.take(role: "inbox")
|
|
60
|
+
opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
|
|
61
|
+
snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def install
|
|
67
|
+
force = false
|
|
68
|
+
subcommand("install") { |p| p.on("--force", "Replace any other copy of the pane") { force = true } }
|
|
69
|
+
begin
|
|
70
|
+
installed = Mod.install(force: force)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
|
|
73
|
+
end
|
|
74
|
+
installed.removed.each { |dir| @out.puts "Removed #{dir} from ~/.claude/settings.json." }
|
|
75
|
+
@out.puts "The pane now runs #{Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
|
|
76
|
+
@out.puts(installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
|
|
77
|
+
"The pane is already in ~/.claude/settings.json.")
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def update
|
|
82
|
+
subcommand("update")
|
|
83
|
+
updater = Updater.new
|
|
84
|
+
begin
|
|
85
|
+
update = updater.check(force: true)
|
|
86
|
+
rescue Updater::Error => e
|
|
87
|
+
abort "claude-inbox: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
unless update
|
|
90
|
+
@out.puts((updater.kind == :gem) ? "claude-inbox #{VERSION} is the latest release." : "main is up to date.")
|
|
91
|
+
return true
|
|
92
|
+
end
|
|
93
|
+
@out.puts update.installing
|
|
94
|
+
applied = updater.apply
|
|
95
|
+
abort "claude-inbox: #{applied.output.strip}" unless applied.success?
|
|
96
|
+
@out.puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def launch
|
|
101
|
+
options
|
|
102
|
+
# Crash traces go here instead of the alt screen when set.
|
|
103
|
+
$stderr.reopen(@env["CLAUDE_INBOX_STDERR"], "a") if @env["CLAUDE_INBOX_STDERR"]
|
|
104
|
+
return headless if options[:headless]
|
|
105
|
+
auto_update if options[:auto_update]
|
|
106
|
+
# So the pane settings.json names is the installed version's, after a gem update too.
|
|
107
|
+
Mod.link unless options[:fixture]
|
|
108
|
+
App.new(**app_options).run
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def headless
|
|
113
|
+
abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
|
|
114
|
+
Headless.new(client: AgentsClient.new, store: Store.new, updater: updater).run
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Only what a check within the day already found: a cold launch never waits
|
|
118
|
+
# on the network. The relaunch is the new version's launch; a failure leaves
|
|
119
|
+
# this one to start as usual.
|
|
120
|
+
def auto_update
|
|
121
|
+
return unless (update = updater&.cached)
|
|
122
|
+
@out.puts update.installing
|
|
123
|
+
applied = updater.apply
|
|
124
|
+
applied.success? ? updater.relaunch(@argv) : warn("claude-inbox: #{applied.output.strip}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fixture_options
|
|
128
|
+
fixtures = File.dirname(options[:fixture])
|
|
129
|
+
# Its own token, lock and images, so a test inbox can neither rotate the
|
|
130
|
+
# token phones are paired with nor hold the real inbox's lock.
|
|
131
|
+
dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
|
|
132
|
+
{client: FixtureClient.new(options[:fixture]),
|
|
133
|
+
store: Store.new(path: nil),
|
|
134
|
+
pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
|
|
135
|
+
listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
|
|
136
|
+
lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The only place the reaper is armed, and only with --reap: deleting
|
|
140
|
+
# sessions is opt-in. Everything else — tests, --fixture, anything
|
|
141
|
+
# embedding App — gets one that deletes nothing. The listener, likewise,
|
|
142
|
+
# comes only from here, and only when asked for.
|
|
143
|
+
def live_options
|
|
144
|
+
client = AgentsClient.new
|
|
145
|
+
store = Store.new
|
|
146
|
+
{client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
|
|
147
|
+
snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
|
|
148
|
+
listen: options[:listen], updater: updater,
|
|
149
|
+
# The typed arguments, not the config's: a relaunch reads the config again.
|
|
150
|
+
argv: @argv}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def updater
|
|
154
|
+
@updater ||= Updater.new if options[:update_check] && !options[:fixture]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Held for the life of this process by this reference.
|
|
158
|
+
def writer_lock = (@lock ||= WriterLock.new)
|
|
159
|
+
|
|
160
|
+
def parse(argv)
|
|
161
|
+
opts = {color: true, update_check: true}
|
|
162
|
+
parser = OptionParser.new(USAGE) do |p|
|
|
163
|
+
p.on("--version", "Print the version") {
|
|
164
|
+
@out.puts VERSION
|
|
165
|
+
exit
|
|
166
|
+
}
|
|
167
|
+
p.on("--mod-dir", "Print the pane's directory, for CLAUDE_CODE_PLUGIN_DIRS") {
|
|
168
|
+
@out.puts Mod.link(force: true)
|
|
169
|
+
exit
|
|
170
|
+
}
|
|
171
|
+
p.on("--fixture PATH", "Read sessions from an agents.json instead of the daemon") { |path| opts[:fixture] = path }
|
|
172
|
+
p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
|
|
173
|
+
p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
|
|
174
|
+
p.on("--no-color", "Draw without color") { opts[:color] = false }
|
|
175
|
+
p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
|
|
176
|
+
p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
|
|
177
|
+
# --listen-lan wins over --listen, whichever came last.
|
|
178
|
+
p.on("--listen[=PORT]", "Let a phone on this Mac start sessions") { |port| opts[:listen] = {lan: false, port: port} unless opts.dig(:listen, :lan) }
|
|
179
|
+
p.on("--listen-lan[=PORT]", "Let a phone on your network start sessions") { |port| opts[:listen] = {lan: true, port: port} }
|
|
180
|
+
p.on("--listen-allow-modes=MODES", "The permission modes a phone may start in") { |modes| opts[:allow_modes] = modes }
|
|
181
|
+
end
|
|
182
|
+
strictly(parser, argv)
|
|
183
|
+
opts
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def subcommand(name)
|
|
187
|
+
parser = OptionParser.new(USAGE)
|
|
188
|
+
yield parser if block_given?
|
|
189
|
+
strictly(parser, @argv.drop(1))
|
|
190
|
+
rescue OptionParser::ParseError => e
|
|
191
|
+
abort "claude-inbox #{name}: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Exact names only, so a typo is an error rather than the flag it abbreviates.
|
|
195
|
+
def strictly(parser, argv)
|
|
196
|
+
parser.require_exact = true
|
|
197
|
+
rest = parser.parse(argv)
|
|
198
|
+
raise OptionParser::InvalidArgument, rest.first if rest.any?
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-reader"
|
|
4
|
+
require_relative "mouse"
|
|
5
|
+
require_relative "paste"
|
|
6
|
+
|
|
7
|
+
module ClaudeInbox
|
|
8
|
+
# The tty's keypress stream decoded into pastes, mouse reports and named
|
|
9
|
+
# keys. Hides what tty-reader does with ESC, echo and a paste, so App sees
|
|
10
|
+
# events and not the raw bytes.
|
|
11
|
+
class Input
|
|
12
|
+
def initialize(input, out)
|
|
13
|
+
@input = input
|
|
14
|
+
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
15
|
+
@paste = Paste.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def pasting? = @paste.pasting?
|
|
19
|
+
|
|
20
|
+
# The next raw read, or nil when nothing has arrived.
|
|
21
|
+
def read
|
|
22
|
+
pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# `[:paste, text]`, `[:mouse, event]` and `[:key, name, raw]`, in the
|
|
26
|
+
# order they arrived; `name` is the reader's symbol for a key it knows,
|
|
27
|
+
# else the raw string.
|
|
28
|
+
def events(raw)
|
|
29
|
+
@paste.feed(raw).flat_map do |kind, text|
|
|
30
|
+
next [[:paste, text]] if kind == :paste
|
|
31
|
+
mouse = Mouse.events(text)
|
|
32
|
+
next mouse.map { |e| [:mouse, e] } if mouse.any?
|
|
33
|
+
split_keys(text).map { |k| [:key, key_name(k), k] }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A paste typed out: `[name, raw]` per character.
|
|
38
|
+
def typed(text) = text.each_char.map { |c| [key_name(c), c] }
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# As much of an open paste as has arrived, read straight off the tty:
|
|
43
|
+
# the reader switches echo off and back on around every character, and
|
|
44
|
+
# on a long paste that is most of the wait.
|
|
45
|
+
def read_pasted
|
|
46
|
+
return nil unless @input.wait_readable(0.1)
|
|
47
|
+
out = +""
|
|
48
|
+
while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
|
|
49
|
+
out << c
|
|
50
|
+
end
|
|
51
|
+
out
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def key_name(key) = @reader.console.keys[key] || key
|
|
55
|
+
|
|
56
|
+
# tty-reader glues ESC to whatever arrives within 100ms, so a fast
|
|
57
|
+
# "esc gg" comes in as one unknown key "\egg". Vim hands make that
|
|
58
|
+
# constantly. Unknown ESC-prefixed strings become ESC + the rest.
|
|
59
|
+
def split_keys(key)
|
|
60
|
+
return [key] if key.size <= 1 || @reader.console.keys.key?(key)
|
|
61
|
+
return [key] unless key.start_with?("\e")
|
|
62
|
+
["\e"] + key[1..].chars
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module ClaudeInbox
|
|
6
7
|
# What the daemon writes about a background session in
|
|
@@ -16,7 +17,7 @@ module ClaudeInbox
|
|
|
16
17
|
# `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
|
|
17
18
|
# transcript, which PullRequests reads through here, the color `/color`
|
|
18
19
|
# set on the session, which `claude agents --json` drops, and `intent`, the
|
|
19
|
-
# prompt the session was started from.
|
|
20
|
+
# prompt the session was started from, and `createdAt`, when it was.
|
|
20
21
|
#
|
|
21
22
|
# Never cached. A color changes the moment you type `/color`, so every poll
|
|
22
23
|
# asks the file again.
|
|
@@ -64,7 +65,7 @@ module ClaudeInbox
|
|
|
64
65
|
nil
|
|
65
66
|
end
|
|
66
67
|
|
|
67
|
-
attr_reader :pr_urls, :color, :intent, :bridge_id
|
|
68
|
+
attr_reader :pr_urls, :color, :intent, :bridge_id, :created_at
|
|
68
69
|
|
|
69
70
|
def initialize(hash)
|
|
70
71
|
@detail = hash["detail"]
|
|
@@ -77,6 +78,7 @@ module ClaudeInbox
|
|
|
77
78
|
@color = hash["color"]
|
|
78
79
|
@intent = hash["intent"]
|
|
79
80
|
@bridge_id = hash["bridgeSessionId"]
|
|
81
|
+
@created_at = time(hash["createdAt"])
|
|
80
82
|
@flags = hash["respawnFlags"] || []
|
|
81
83
|
end
|
|
82
84
|
|
|
@@ -122,5 +124,11 @@ module ClaudeInbox
|
|
|
122
124
|
def count_label = "#{tasks} #{plural("task", tasks)}"
|
|
123
125
|
|
|
124
126
|
def plural(word, n) = (n == 1) ? word : "#{word}s"
|
|
127
|
+
|
|
128
|
+
def time(iso)
|
|
129
|
+
iso && Time.iso8601(iso)
|
|
130
|
+
rescue ArgumentError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
125
133
|
end
|
|
126
134
|
end
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -19,15 +19,13 @@ module ClaudeInbox
|
|
|
19
19
|
"G" => :bottom,
|
|
20
20
|
:ctrl_d => :half_page_down, :ctrl_u => :half_page_up,
|
|
21
21
|
:ctrl_f => :page_down, :ctrl_b => :page_up,
|
|
22
|
-
:ctrl_e => :peek_down, :ctrl_y => :peek_up,
|
|
23
|
-
"J" => :peek_down, "K" => :peek_up,
|
|
24
22
|
# actions
|
|
25
23
|
:return => :activate, :enter => :activate, "l" => :activate,
|
|
26
|
-
"h" => :
|
|
24
|
+
"h" => :fold_close,
|
|
27
25
|
"s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
|
|
28
26
|
:ctrl_x => :delete,
|
|
29
27
|
"o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
|
|
30
|
-
"R" => :refresh, "U" => :update, "
|
|
28
|
+
"R" => :refresh, "U" => :update, "n" => :new_session, "N" => :remote_pairing,
|
|
31
29
|
:tab => :next_section, :back_tab => :prev_section,
|
|
32
30
|
"/" => :filter, :escape => :escape,
|
|
33
31
|
"q" => :quit, :ctrl_c => :quit
|
data/lib/claude_inbox/mod.rb
CHANGED
|
@@ -41,9 +41,9 @@ module ClaudeInbox
|
|
|
41
41
|
# rather than rewrite a settings file it cannot read.
|
|
42
42
|
def self.install(settings: SETTINGS, link: LINK, dir: DIR, force: false, command: COMMAND)
|
|
43
43
|
self.link(link: link, dir: dir, force: true)
|
|
44
|
-
json =
|
|
44
|
+
json = read_settings(settings)
|
|
45
45
|
env = json["env"] ||= {}
|
|
46
|
-
dirs = env["CLAUDE_CODE_PLUGIN_DIRS"]
|
|
46
|
+
dirs = plugin_dirs(env["CLAUDE_CODE_PLUGIN_DIRS"])
|
|
47
47
|
kept = dirs.reject { |d| force && d != link && plugin?(d) }
|
|
48
48
|
wanted = kept.include?(link) ? kept : [*kept, link]
|
|
49
49
|
options = json.dig("pluginConfigs", NAME, "options") || {}
|
|
@@ -66,8 +66,7 @@ module ClaudeInbox
|
|
|
66
66
|
# worktree's included. A settings file it cannot read counts as yes, since
|
|
67
67
|
# install would refuse to touch it.
|
|
68
68
|
def self.installed?(settings: SETTINGS)
|
|
69
|
-
|
|
70
|
-
json.dig("env", "CLAUDE_CODE_PLUGIN_DIRS").to_s.split(File::PATH_SEPARATOR).any? { |d| plugin?(d.strip) }
|
|
69
|
+
plugin_dirs(read_settings(settings).dig("env", "CLAUDE_CODE_PLUGIN_DIRS")).any? { |d| plugin?(d) }
|
|
71
70
|
rescue JSON::ParserError, Errno::EACCES
|
|
72
71
|
true
|
|
73
72
|
end
|
|
@@ -101,6 +100,10 @@ module ClaudeInbox
|
|
|
101
100
|
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
|
|
102
101
|
false
|
|
103
102
|
end
|
|
104
|
-
|
|
103
|
+
|
|
104
|
+
def self.read_settings(path) = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
105
|
+
|
|
106
|
+
def self.plugin_dirs(value) = value.to_s.split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
|
|
107
|
+
private_class_method :plugin?, :read_settings, :plugin_dirs
|
|
105
108
|
end
|
|
106
109
|
end
|
data/lib/claude_inbox/mouse.rb
CHANGED
|
@@ -11,7 +11,7 @@ module ClaudeInbox
|
|
|
11
11
|
Event = Struct.new(:kind, :row, :col)
|
|
12
12
|
|
|
13
13
|
# A raw keypress can glue several reports together the same way a fast
|
|
14
|
-
# "esc gg" glues onto one read (see
|
|
14
|
+
# "esc gg" glues onto one read (see Input#split_keys), so this scans
|
|
15
15
|
# rather than matching once.
|
|
16
16
|
def self.events(raw)
|
|
17
17
|
raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
|
|
@@ -57,11 +57,10 @@ module ClaudeInbox
|
|
|
57
57
|
class PullRequests
|
|
58
58
|
REFRESH_AFTER = 60
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
CACHE_PATH = File.join(Dir.home, ".claude", "gh-pr-status-cache.json")
|
|
61
61
|
RESOLVED_PATH = File.join(Dir.home, ".config", "claude-inbox", "prs.json")
|
|
62
62
|
|
|
63
|
-
def initialize(cache_path:
|
|
64
|
-
gh: "gh", clock: -> { Time.now })
|
|
63
|
+
def initialize(cache_path: CACHE_PATH, resolved_path: RESOLVED_PATH, gh: "gh", clock: -> { Time.now })
|
|
65
64
|
@cache_path = cache_path
|
|
66
65
|
@resolved_path = resolved_path
|
|
67
66
|
@gh = gh
|
|
@@ -67,13 +67,12 @@ module ClaudeInbox
|
|
|
67
67
|
Outcome = Data.define(:at, :via, :result, :count)
|
|
68
68
|
|
|
69
69
|
# App's `listen:` from the flags, else the environment; nil when neither
|
|
70
|
-
# asks for it.
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
# asks for it. `flag` is the listen flag's `lan:` and `port:`, and
|
|
71
|
+
# `modes` --listen-allow-modes, both as typed.
|
|
72
|
+
def self.options(env, flag: nil, modes: nil)
|
|
73
73
|
lan, port =
|
|
74
|
-
if
|
|
75
|
-
flag
|
|
76
|
-
[!flag[1].nil?, flag[2]]
|
|
74
|
+
if flag
|
|
75
|
+
flag.values_at(:lan, :port)
|
|
77
76
|
elsif !(value = env["CLAUDE_INBOX_LISTEN"].to_s.strip).empty?
|
|
78
77
|
m = value.match(/\A(?:(lan)(?::(.*))?|(\d+))\z/i)
|
|
79
78
|
raise ArgumentError, "CLAUDE_INBOX_LISTEN is a port, lan or lan:PORT, not #{value}" unless m
|
|
@@ -81,7 +80,7 @@ module ClaudeInbox
|
|
|
81
80
|
else
|
|
82
81
|
return nil
|
|
83
82
|
end
|
|
84
|
-
modes
|
|
83
|
+
modes ||= env["CLAUDE_INBOX_LISTEN_ALLOW_MODES"]
|
|
85
84
|
{port: port_option(port), lan: lan, allowed_modes: modes_option(modes)}
|
|
86
85
|
end
|
|
87
86
|
|
|
@@ -11,18 +11,17 @@ 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
|
-
Frame = Struct.new(:lines, :items, :top
|
|
14
|
+
Frame = Struct.new(:lines, :items, :top)
|
|
15
15
|
|
|
16
16
|
CHROME_ROWS = 2 # header + footer
|
|
17
|
-
MIN_LIST_WIDTH = 44 # with the peek open, the list keeps at least this many columns
|
|
18
17
|
|
|
19
18
|
def self.body_height(height) = height - CHROME_ROWS
|
|
20
19
|
|
|
21
|
-
# What App hands Renderer for one frame. `
|
|
22
|
-
#
|
|
23
|
-
View = Data.define(:width, :height, :now, :selected, :top, :expanded, :
|
|
20
|
+
# What App hands Renderer for one frame. `listening` is a
|
|
21
|
+
# Remote::Listener::Snapshot.
|
|
22
|
+
View = Data.define(:width, :height, :now, :selected, :top, :expanded, :modal, :screen,
|
|
24
23
|
:status, :usage, :filter, :filter_editing, :tick, :loading, :listening) do
|
|
25
|
-
def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {},
|
|
24
|
+
def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {}, modal: nil, screen: nil,
|
|
26
25
|
status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil, listening: nil) = super
|
|
27
26
|
end
|
|
28
27
|
|
|
@@ -60,7 +59,7 @@ module ClaudeInbox
|
|
|
60
59
|
|
|
61
60
|
KEYS = [
|
|
62
61
|
["j/k", "move"], ["⏎", "attach"], ["n", "new"], ["t", "pin"], ["s", "snooze"], ["u", "wake"],
|
|
63
|
-
["a", "alias"], ["o", "PR"], ["x", "settle"], ["
|
|
62
|
+
["a", "alias"], ["o", "PR"], ["x", "settle"], ["⇥", "section"],
|
|
64
63
|
["za", "fold"], ["/", "filter"], ["q", "quit"]
|
|
65
64
|
].freeze
|
|
66
65
|
|
|
@@ -76,13 +75,12 @@ module ClaudeInbox
|
|
|
76
75
|
def frame(sections, view)
|
|
77
76
|
return full_screen(sections, view) if view.screen
|
|
78
77
|
width, height, selected = view.width, view.height, view.selected
|
|
79
|
-
list_w = width_for_list(width, view.peek)
|
|
80
78
|
view_h = Renderer.body_height(height)
|
|
81
79
|
body, items =
|
|
82
80
|
if view.loading
|
|
83
|
-
[loading_state(
|
|
81
|
+
[loading_state(width, view_h, view.loading, view.tick), []]
|
|
84
82
|
else
|
|
85
|
-
body_lines(sections,
|
|
83
|
+
body_lines(sections, width, view)
|
|
86
84
|
end
|
|
87
85
|
|
|
88
86
|
top = clamp_top(view.top, body.size, view_h, items, selected)
|
|
@@ -91,26 +89,13 @@ module ClaudeInbox
|
|
|
91
89
|
visible += [""] * (view_h - visible.size)
|
|
92
90
|
visible_items += [nil] * (view_h - visible_items.size)
|
|
93
91
|
|
|
94
|
-
if view.peek
|
|
95
|
-
peek_w = width - list_w - 1
|
|
96
|
-
peek_lines = peek_pane(view.peek, peek_w, view_h)
|
|
97
|
-
visible = visible.each_with_index.map do |l, i|
|
|
98
|
-
Text.pad(l, list_w) + @p.dim("│") + Text.pad(peek_lines[i] || "", peek_w)
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
92
|
lines = [header(sections, width, view)] + visible.map { |l| Text.pad(l, width) } + [footer(width, view)]
|
|
103
93
|
lines = overlay(lines, view.modal, width) if view.modal
|
|
104
|
-
Frame.new(lines, [nil] + visible_items + [nil], top
|
|
94
|
+
Frame.new(lines, [nil] + visible_items + [nil], top)
|
|
105
95
|
end
|
|
106
96
|
|
|
107
97
|
private
|
|
108
98
|
|
|
109
|
-
def width_for_list(width, peek)
|
|
110
|
-
return width unless peek
|
|
111
|
-
[(width * 0.4).floor, MIN_LIST_WIDTH].max.clamp(0, width)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
99
|
def clamp_top(top, size, view_h, items, selected)
|
|
115
100
|
idx = items.index { |item| item && item.key == selected }
|
|
116
101
|
top = idx - 2 if idx && idx - 2 < top # keep the section title in view
|
|
@@ -124,7 +109,7 @@ module ClaudeInbox
|
|
|
124
109
|
body = view.screen[:lines].first(view_h)
|
|
125
110
|
body += [""] * (view_h - body.size)
|
|
126
111
|
lines = [header(sections, width, view)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + view.screen[:footer], width)]
|
|
127
|
-
Frame.new(lines, [nil] * view.height, view.top
|
|
112
|
+
Frame.new(lines, [nil] * view.height, view.top)
|
|
128
113
|
end
|
|
129
114
|
|
|
130
115
|
# ----- chrome -------------------------------------------------------------
|
|
@@ -242,7 +227,7 @@ module ClaudeInbox
|
|
|
242
227
|
return [empty_state(width), []]
|
|
243
228
|
end
|
|
244
229
|
selected = view.selected
|
|
245
|
-
sections.
|
|
230
|
+
sections.each_pair do |name, rows|
|
|
246
231
|
next if rows.empty?
|
|
247
232
|
lines << "" << section_title(name, rows.size, width)
|
|
248
233
|
items << nil << nil
|
|
@@ -391,7 +376,7 @@ module ClaudeInbox
|
|
|
391
376
|
pr ? base + @p.dim(" · ") + pr : base
|
|
392
377
|
end
|
|
393
378
|
|
|
394
|
-
# "#885 open". Only the first PR is shown
|
|
379
|
+
# "#885 open". Only the first PR is shown.
|
|
395
380
|
def pr_badge(s, section)
|
|
396
381
|
pr = s.pr
|
|
397
382
|
return nil unless pr
|
|
@@ -427,18 +412,6 @@ module ClaudeInbox
|
|
|
427
412
|
path.start_with?(@home) ? path.sub(@home, "~") : path
|
|
428
413
|
end
|
|
429
414
|
|
|
430
|
-
# ----- peek ---------------------------------------------------------------
|
|
431
|
-
|
|
432
|
-
def peek_pane(peek, width, height)
|
|
433
|
-
bar = Text.pad(" " + (peek.title || ""), width)
|
|
434
|
-
out = [@p.inverse(bar)]
|
|
435
|
-
out << Text.pad(" " + @p.dim(peek.subtitle.to_s), width) if peek.subtitle
|
|
436
|
-
body_h = height - out.size
|
|
437
|
-
wrapped = peek.lines.flat_map { |l| Text.wrap(l, width - 1) }
|
|
438
|
-
wrapped.last(body_h).each { |l| out << Text.pad(" " + l, width) }
|
|
439
|
-
out
|
|
440
|
-
end
|
|
441
|
-
|
|
442
415
|
# ----- modal --------------------------------------------------------------
|
|
443
416
|
|
|
444
417
|
# Centre a block of lines over the frame.
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -78,6 +78,10 @@ module ClaudeInbox
|
|
|
78
78
|
# From `/color`; interactive sessions have no job file, so never one.
|
|
79
79
|
def color = job_state&.color
|
|
80
80
|
|
|
81
|
+
# A respawn moves `started_at` to the new process; the job file keeps
|
|
82
|
+
# when the session itself began.
|
|
83
|
+
def created_at = job_state&.created_at || started_at
|
|
84
|
+
|
|
81
85
|
# The prompt this session was started with.
|
|
82
86
|
def intent = job_state&.intent
|
|
83
87
|
|
|
@@ -24,13 +24,12 @@ module ClaudeInbox
|
|
|
24
24
|
# The project wins over the home directory when both define the same
|
|
25
25
|
# name, as it does in the CLI.
|
|
26
26
|
def list(cwd:, home:)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
found.values.sort_by(&:name)
|
|
27
|
+
[
|
|
28
|
+
*from_dir(File.join(cwd, ".claude")),
|
|
29
|
+
*from_dir(File.join(home, ".claude")),
|
|
30
|
+
*plugins(home),
|
|
31
|
+
*synced(home)
|
|
32
|
+
].uniq(&:name).sort_by(&:name)
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
def match(commands, query)
|
|
@@ -71,7 +71,7 @@ module ClaudeInbox
|
|
|
71
71
|
def settled?
|
|
72
72
|
return true if hand_settled?
|
|
73
73
|
return false if UNSETTLEABLE.include?(session.effective_state)
|
|
74
|
-
started = session.
|
|
74
|
+
started = session.created_at.to_i
|
|
75
75
|
ours = session.prs.select(&:known?).reject { |pr| pr.resolved_before?(started) }
|
|
76
76
|
ours.any? && ours.all? { |pr| pr.resolved_since?(started) }
|
|
77
77
|
end
|
|
@@ -4,8 +4,6 @@ module ClaudeInbox
|
|
|
4
4
|
class Store
|
|
5
5
|
# The Rows of one poll, grouped and ordered, and where the cursor may land among them.
|
|
6
6
|
Sections = Struct.new(:pinned, :needs_you, :active, :snoozed, :settled) do
|
|
7
|
-
def each_section(&) = each_pair(&)
|
|
8
|
-
|
|
9
7
|
def all = values.flatten(1)
|
|
10
8
|
|
|
11
9
|
def row(selection)
|
|
@@ -20,7 +18,7 @@ module ClaudeInbox
|
|
|
20
18
|
def section_of(selection)
|
|
21
19
|
return nil if selection.nil?
|
|
22
20
|
return selection.key if selection.fold?
|
|
23
|
-
|
|
21
|
+
each_pair { |name, rows| return name if rows.any? { |r| r.key == selection.key } }
|
|
24
22
|
nil
|
|
25
23
|
end
|
|
26
24
|
|
|
@@ -35,8 +33,7 @@ module ClaudeInbox
|
|
|
35
33
|
|
|
36
34
|
# An empty fold is left out, since there is nothing under it to open.
|
|
37
35
|
def stops(expanded)
|
|
38
|
-
|
|
39
|
-
rows = self[name]
|
|
36
|
+
each_pair.filter_map do |name, rows|
|
|
40
37
|
if !Store.folded?(name, expanded) then [name, rows.select(&:selectable?).map { |r| Selection.row(r.key) }]
|
|
41
38
|
elsif rows.any? then [name, [Selection.fold(name)]]
|
|
42
39
|
end
|
data/lib/claude_inbox/store.rb
CHANGED
|
@@ -116,7 +116,7 @@ module ClaudeInbox
|
|
|
116
116
|
def pr_for(id) = @mutex.synchronize { @entries[id] && Entry.new(@entries[id]).pr }
|
|
117
117
|
|
|
118
118
|
def pr_overrides
|
|
119
|
-
@mutex.synchronize { @entries.transform_values { |e| Entry.new(e).pr }.
|
|
119
|
+
@mutex.synchronize { @entries.transform_values { |e| Entry.new(e).pr }.compact }
|
|
120
120
|
end
|
|
121
121
|
|
|
122
122
|
# A copy, not the live entry: the reaper decides a deletion on this row
|
|
@@ -4,12 +4,10 @@ require "strscan"
|
|
|
4
4
|
require "unicode/display_width"
|
|
5
5
|
|
|
6
6
|
module ClaudeInbox
|
|
7
|
-
# A deliberately small cursor-addressed screen model
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# grid does not. This is not a VT emulator: it handles the handful of
|
|
12
|
-
# sequences the replay actually uses and ignores the rest.
|
|
7
|
+
# A deliberately small cursor-addressed screen model that turns captured
|
|
8
|
+
# terminal output into the lines it would show; bin/screens reads the
|
|
9
|
+
# inbox's own frames through it. This is not a VT emulator: it handles the
|
|
10
|
+
# handful of sequences claude's output actually uses and ignores the rest.
|
|
13
11
|
class VtScreen
|
|
14
12
|
CSI = /\e\[([0-9;?]*)([A-Za-z@`])/
|
|
15
13
|
OSC = /\e\][^\a\e]*(?:\a|\e\\)?/
|
data/lib/claude_inbox.rb
CHANGED