claude-inbox 0.2.0 → 0.3.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 +160 -152
- data/exe/claude-inbox +23 -4
- data/lib/claude_inbox/agents_client.rb +57 -55
- data/lib/claude_inbox/app.rb +42 -26
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +23 -8
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/new_session_form.rb +29 -39
- data/lib/claude_inbox/painter.rb +32 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +4 -3
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +137 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +436 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +202 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +57 -57
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +19 -7
- data/lib/claude_inbox/store.rb +0 -2
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require_relative "../agents_client"
|
|
6
|
+
require_relative "../images"
|
|
7
|
+
require_relative "../job_state"
|
|
8
|
+
require_relative "../session"
|
|
9
|
+
require_relative "../session_request"
|
|
10
|
+
require_relative "../settings"
|
|
11
|
+
require_relative "../text"
|
|
12
|
+
require_relative "../trust"
|
|
13
|
+
require_relative "http"
|
|
14
|
+
|
|
15
|
+
module ClaudeInbox
|
|
16
|
+
module Remote
|
|
17
|
+
# What a paired phone may ask for: the choices, and a session started
|
|
18
|
+
# the way the `n` form starts one, within the permission cap. Listener
|
|
19
|
+
# hands it a request only once the token has checked out.
|
|
20
|
+
class Start
|
|
21
|
+
# Path => [verb, the method that answers it].
|
|
22
|
+
ROUTES = {"/api/options" => ["GET", :options], "/api/sessions" => ["POST", :post_session]}.freeze
|
|
23
|
+
VERBS = ROUTES.transform_values(&:first).freeze
|
|
24
|
+
MAX_BODY = 16 * 1024 * 1024
|
|
25
|
+
MAX_IMAGES = 8
|
|
26
|
+
BODY_TIMEOUT = 120
|
|
27
|
+
KEYS_KEPT = 16
|
|
28
|
+
|
|
29
|
+
def initialize(client:, store:, queue:, allowed_modes:, fixture: false, images_dir: Images::DEFAULT_DIR,
|
|
30
|
+
jobs_dir: JobState::DEFAULT_DIR, trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
|
|
31
|
+
@client = client
|
|
32
|
+
@store = store
|
|
33
|
+
@queue = queue
|
|
34
|
+
@allowed_modes = allowed_modes
|
|
35
|
+
@fixture = fixture
|
|
36
|
+
@images_dir = images_dir
|
|
37
|
+
@jobs_dir = jobs_dir
|
|
38
|
+
@trust = trust
|
|
39
|
+
@settings = settings
|
|
40
|
+
@bridge_wait = bridge_wait
|
|
41
|
+
@mutex = Mutex.new
|
|
42
|
+
@spawn_lock = Mutex.new
|
|
43
|
+
@keys = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# => [status, headers, body, note], `note` being what `N` lists, if
|
|
47
|
+
# anything; or raises Http::Error.
|
|
48
|
+
def call(request, io, via) = send(ROUTES.fetch(request.path)[1], request, io, via)
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def options(_request, _io, _via) = json(200, choices)
|
|
53
|
+
|
|
54
|
+
def json(status, body) = [status, Http::JSON_TYPE, JSON.generate(@fixture ? body.merge(fixture: true) : body)]
|
|
55
|
+
|
|
56
|
+
def choices
|
|
57
|
+
paths = dir_paths
|
|
58
|
+
{
|
|
59
|
+
models: AgentsClient::MODELS,
|
|
60
|
+
efforts: AgentsClient::EFFORTS,
|
|
61
|
+
permission_modes: AgentsClient::PERMISSION_MODES & @allowed_modes,
|
|
62
|
+
dirs: paths.map { |path| {path: path, label: SessionRequest.label(path, paths), defaults: @settings.call(path).to_h} }
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Where sessions ran lately, newest first, then every directory whose
|
|
67
|
+
# trust dialog was accepted: the likely choices first. Only an order;
|
|
68
|
+
# the CLI still decides where a session may start.
|
|
69
|
+
def dir_paths
|
|
70
|
+
recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
|
|
71
|
+
(recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Checked cheapest first, and nothing is written to disk until every
|
|
75
|
+
# check has passed. A retry with the same Idempotency-Key gets the
|
|
76
|
+
# first answer instead of a second session; the key sent with a
|
|
77
|
+
# different request is refused, since that answer isn't this one's.
|
|
78
|
+
def post_session(request, io, via)
|
|
79
|
+
params = read_json(io, request)
|
|
80
|
+
key = request.headers["idempotency-key"]
|
|
81
|
+
key = nil if key.to_s.empty?
|
|
82
|
+
digest = OpenSSL::Digest.digest("SHA256", JSON.generate(params)) if key
|
|
83
|
+
images = take_images(params)
|
|
84
|
+
earlier = key && claim(key, digest)
|
|
85
|
+
return json(200, earlier) if earlier
|
|
86
|
+
started = nil
|
|
87
|
+
begin
|
|
88
|
+
started = launch(params, images, via)
|
|
89
|
+
[*json(201, started), "started #{started[:id]}"]
|
|
90
|
+
ensure
|
|
91
|
+
settle(key, digest, started) if key
|
|
92
|
+
end
|
|
93
|
+
rescue AgentsClient::Error => e
|
|
94
|
+
reason = Text.printable(e.message.lines.first.to_s.strip)
|
|
95
|
+
@queue << [:notice, "remote start failed: #{reason}"]
|
|
96
|
+
raise Http::Error.new(500, e.message, source: "claude", note: reason)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def read_json(io, request)
|
|
100
|
+
type = request.headers["content-type"].to_s.split(";").first.to_s.strip
|
|
101
|
+
raise Http::Error.new(415, "send the request as application/json") unless type.casecmp?("application/json")
|
|
102
|
+
body = request.read_body(io, max: MAX_BODY, deadline: Http.monotonic + BODY_TIMEOUT).force_encoding(Encoding::UTF_8)
|
|
103
|
+
raise Http::Error.new(400, "the body isn't UTF-8") unless body.valid_encoding?
|
|
104
|
+
params = JSON.parse(body)
|
|
105
|
+
raise Http::Error.new(400, "the body must be a JSON object") unless params.is_a?(Hash)
|
|
106
|
+
params
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
raise Http::Error.new(400, "the body isn't JSON")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Decoded and sniffed now, saved only once the request has passed.
|
|
112
|
+
def take_images(params)
|
|
113
|
+
images = params.delete("images") || []
|
|
114
|
+
raise Http::Error.new(422, "images is a list of {\"data\": base64}", field: "images") unless images.is_a?(Array)
|
|
115
|
+
raise Http::Error.new(413, "at most #{MAX_IMAGES} images", field: "images") if images.size > MAX_IMAGES
|
|
116
|
+
images.each_with_index.map do |image, i|
|
|
117
|
+
bytes = decode(image)
|
|
118
|
+
next bytes if bytes && Images.extension(bytes)
|
|
119
|
+
raise Http::Error.new(415, "image #{i + 1} isn't a PNG, JPEG, GIF or WebP image", field: "images", index: i)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def decode(image)
|
|
124
|
+
data = image["data"] if image.is_a?(Hash)
|
|
125
|
+
data.gsub(/\s+/, "").unpack1("m0") if data.is_a?(String)
|
|
126
|
+
rescue ArgumentError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The body an earlier request with this key got, or nil once the key
|
|
131
|
+
# is this request's; one still starting is a 409.
|
|
132
|
+
def claim(key, digest)
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
sent, earlier = @keys[key]
|
|
135
|
+
if sent
|
|
136
|
+
raise Http::Error.new(422, "this Idempotency-Key was sent with a different request") unless sent == digest
|
|
137
|
+
raise Http::Error.new(409, "already starting") if earlier == :spawning
|
|
138
|
+
return earlier
|
|
139
|
+
end
|
|
140
|
+
@keys.shift while @keys.size >= KEYS_KEPT
|
|
141
|
+
@keys[key] = [digest, :spawning]
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A failed start frees its key, so a retry goes through rather than
|
|
147
|
+
# getting 409 for ever.
|
|
148
|
+
def settle(key, digest, started)
|
|
149
|
+
@mutex.synchronize do
|
|
150
|
+
if started then @keys[key] = [digest, started]
|
|
151
|
+
else @keys.delete(key)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def launch(params, images, via)
|
|
157
|
+
values = SessionRequest.from_params(params, dirs: dir_paths)
|
|
158
|
+
field, message = SessionRequest.problem(values)
|
|
159
|
+
raise Http::Error.new(422, message, field: field) if field
|
|
160
|
+
defaults = @settings.call(values[:cwd])
|
|
161
|
+
values = SessionRequest.resolve(values, defaults)
|
|
162
|
+
values[:permission_mode] = capped_mode(values[:permission_mode] || defaults.permission_mode || builtin_mode)
|
|
163
|
+
paths = images.each_with_index.map { |bytes, i| Images.save(bytes, dir: @images_dir, index: i + 1) }
|
|
164
|
+
values[:prompt] = SessionRequest.attach(values[:prompt], paths)
|
|
165
|
+
@queue << [:notice, "remote: starting session…"]
|
|
166
|
+
id = Http.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
|
|
167
|
+
url = remote_url(id, wait: values[:remote] ? @bridge_wait : 0)
|
|
168
|
+
@queue << [:remote_started, id, via]
|
|
169
|
+
{id: id, name: values[:name], cwd: values[:cwd], url: url}
|
|
170
|
+
rescue SessionRequest::Invalid => e
|
|
171
|
+
raise Http::Error.new(422, e.message, field: e.field)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Unset, the mode is what the settings files say, so a project defaulting
|
|
175
|
+
# to bypassPermissions can't pass as "default". The spawn always names the
|
|
176
|
+
# mode that passed: the flag beats every settings file short of managed
|
|
177
|
+
# policy, so one the inbox doesn't read can't widen it.
|
|
178
|
+
def capped_mode(mode)
|
|
179
|
+
return mode if @allowed_modes.include?(mode)
|
|
180
|
+
raise Http::Error.new(403, "permission mode #{mode} isn't allowed from another device", field: "permission_mode")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Unset in settings, the CLI's own default is auto or manual, never
|
|
184
|
+
# wider. Passed as "default" the CLI takes manual, so auto is named
|
|
185
|
+
# outright wherever it is allowed.
|
|
186
|
+
def builtin_mode = @allowed_modes.include?("auto") ? "auto" : "default"
|
|
187
|
+
|
|
188
|
+
# The session registers its bridge a moment after `claude --bg`
|
|
189
|
+
# returns; without it in time the reply has no URL, only the id.
|
|
190
|
+
# Without --remote-control it seldom registers one (docs/cli-quirks.md),
|
|
191
|
+
# so that start looks once rather than waits.
|
|
192
|
+
def remote_url(id, wait:)
|
|
193
|
+
deadline = Http.monotonic + wait
|
|
194
|
+
loop do
|
|
195
|
+
url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @jobs_dir)).remote_url
|
|
196
|
+
return url if url || Http.monotonic >= deadline
|
|
197
|
+
sleep 0.2
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pastel"
|
|
4
|
-
require "tty-cursor"
|
|
5
4
|
require_relative "text"
|
|
6
5
|
require_relative "palette"
|
|
7
6
|
require_relative "theme"
|
|
@@ -20,11 +19,17 @@ module ClaudeInbox
|
|
|
20
19
|
|
|
21
20
|
Frame = Struct.new(:lines, :items, :top, :list_width)
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
CHROME_ROWS = 2 # header + footer
|
|
23
|
+
MIN_LIST_WIDTH = 44 # with the peek open, the list keeps at least this many columns
|
|
24
|
+
|
|
25
|
+
def self.body_height(height) = height - CHROME_ROWS
|
|
26
|
+
|
|
27
|
+
# What App hands Renderer for one frame. `peek` is a Peek::View and
|
|
28
|
+
# `listening` a Remote::Listener::Snapshot.
|
|
24
29
|
View = Data.define(:width, :height, :now, :selected, :top, :expanded, :peek, :modal, :screen,
|
|
25
|
-
:status, :usage, :filter, :filter_editing, :tick, :loading) do
|
|
30
|
+
:status, :usage, :filter, :filter_editing, :tick, :loading, :listening) do
|
|
26
31
|
def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {}, peek: nil, modal: nil, screen: nil,
|
|
27
|
-
status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil) = super
|
|
32
|
+
status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil, listening: nil) = super
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
SECTION_TITLES = {
|
|
@@ -37,6 +42,10 @@ module ClaudeInbox
|
|
|
37
42
|
|
|
38
43
|
SECTION_HUES = {pinned: :cyan, needs_you: :red, active: :yellow, snoozed: :purple}.freeze
|
|
39
44
|
|
|
45
|
+
# GitHub's colors. A draft has no hue, so it dims; a state missing from
|
|
46
|
+
# the table isn't named on the badge at all.
|
|
47
|
+
PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
|
|
48
|
+
|
|
40
49
|
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
41
50
|
|
|
42
51
|
# The first poll usually lands inside a second. Past LOADING_QUIET the
|
|
@@ -61,11 +70,10 @@ module ClaudeInbox
|
|
|
61
70
|
["za", "fold"], ["/", "filter"], ["q", "quit"]
|
|
62
71
|
].freeze
|
|
63
72
|
|
|
64
|
-
def initialize(color: true,
|
|
73
|
+
def initialize(color: true, home: Dir.home)
|
|
65
74
|
@p = Pastel.new(enabled: color)
|
|
66
75
|
@theme = Theme.new(enabled: color)
|
|
67
76
|
@palette = Palette.new(enabled: color)
|
|
68
|
-
@min_left = min_left
|
|
69
77
|
@home = home
|
|
70
78
|
end
|
|
71
79
|
|
|
@@ -75,7 +83,7 @@ module ClaudeInbox
|
|
|
75
83
|
return full_screen(sections, view) if view.screen
|
|
76
84
|
width, height, now, selected = view.width, view.height, view.now, view.selected
|
|
77
85
|
list_w = width_for_list(width, view.peek)
|
|
78
|
-
view_h = height
|
|
86
|
+
view_h = Renderer.body_height(height)
|
|
79
87
|
body, items =
|
|
80
88
|
if view.loading
|
|
81
89
|
[loading_state(list_w, view_h, view.loading, view.tick), []]
|
|
@@ -106,7 +114,7 @@ module ClaudeInbox
|
|
|
106
114
|
|
|
107
115
|
def width_for_list(width, peek)
|
|
108
116
|
return width unless peek
|
|
109
|
-
[(width * 0.4).floor,
|
|
117
|
+
[(width * 0.4).floor, MIN_LIST_WIDTH].max.clamp(0, width)
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
def clamp_top(top, size, view_h, items, selected)
|
|
@@ -118,33 +126,43 @@ module ClaudeInbox
|
|
|
118
126
|
|
|
119
127
|
def full_screen(sections, view)
|
|
120
128
|
width = view.width
|
|
121
|
-
view_h = view.height
|
|
129
|
+
view_h = Renderer.body_height(view.height)
|
|
122
130
|
body = view.screen[:lines].first(view_h)
|
|
123
131
|
body += [""] * (view_h - body.size)
|
|
124
132
|
lines = [header(sections, width, view)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + view.screen[:footer], width)]
|
|
125
|
-
Frame.new(lines, [nil] *
|
|
133
|
+
Frame.new(lines, [nil] * view.height, view.top, width)
|
|
126
134
|
end
|
|
127
135
|
|
|
128
136
|
# ----- chrome -------------------------------------------------------------
|
|
129
137
|
|
|
130
138
|
def header(sections, width, view)
|
|
131
139
|
brand = " " + @theme.cyan_bold("▌ claude-inbox")
|
|
132
|
-
right =
|
|
133
|
-
right += " " unless right.empty?
|
|
140
|
+
right = header_right(view, width - Text.width(brand) - 3)
|
|
134
141
|
room = width - Text.width(brand) - Text.width(right) - 3
|
|
135
|
-
chips = view.loading ? "" : header_chips(sections, compact: false)
|
|
136
|
-
chips = header_chips(sections, compact: true) if Text.width(chips) > room
|
|
142
|
+
chips = view.loading ? "" : header_chips(sections, compact: false, listening: view.listening)
|
|
143
|
+
chips = header_chips(sections, compact: true, listening: view.listening) if Text.width(chips) > room
|
|
137
144
|
chips = "" if Text.width(chips) > room
|
|
138
145
|
Text.pad(brand + " " + chips, width - Text.width(right)) + right
|
|
139
146
|
end
|
|
140
147
|
|
|
148
|
+
# The notice outranks the usage meters: they shed their reset times, then
|
|
149
|
+
# go, before the notice itself is cut to fit.
|
|
150
|
+
def header_right(view, room)
|
|
151
|
+
meters = view.usage ? [usage_meters(view.usage, view.now), usage_meters(view.usage, view.now, resets: false)] : []
|
|
152
|
+
(meters + [nil]).each do |m|
|
|
153
|
+
right = [view.status && @p.dim(view.status), m].compact.join(@p.dim(" · "))
|
|
154
|
+
return right.empty? ? right : right + " " if Text.width(right) < room
|
|
155
|
+
end
|
|
156
|
+
(view.status && room > 1) ? @p.dim(Text.truncate(view.status, room - 1)) + " " : ""
|
|
157
|
+
end
|
|
158
|
+
|
|
141
159
|
# "session ██░░░░░░░░ 24% · 3h left" per window, drawn and colored the way
|
|
142
160
|
# the context bar in the user's status line is, so the two read alike.
|
|
143
|
-
def usage_meters(windows, now)
|
|
161
|
+
def usage_meters(windows, now, resets: true)
|
|
144
162
|
windows.map do |w|
|
|
145
163
|
filled = w.percent / 10
|
|
146
164
|
bar = "█" * filled + "░" * (10 - filled)
|
|
147
|
-
left = w.resets_at
|
|
165
|
+
left = w.resets_at.to_i - now.to_i if resets && w.resets_at
|
|
148
166
|
figure = left&.positive? ? "#{w.percent}% · #{Text.age(left)} left" : "#{w.percent}%"
|
|
149
167
|
"#{@p.dim(w.label)} #{@theme.public_send(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
|
|
150
168
|
end.join(" ")
|
|
@@ -157,7 +175,7 @@ module ClaudeInbox
|
|
|
157
175
|
end
|
|
158
176
|
end
|
|
159
177
|
|
|
160
|
-
def header_chips(sections, compact:)
|
|
178
|
+
def header_chips(sections, compact:, listening: nil)
|
|
161
179
|
pinned = sections.pinned.size
|
|
162
180
|
needing = sections.needs_you.size
|
|
163
181
|
working = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
|
|
@@ -177,7 +195,16 @@ module ClaudeInbox
|
|
|
177
195
|
header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
|
|
178
196
|
].compact
|
|
179
197
|
chips << @p.dim("nothing running") if sections.all.empty?
|
|
180
|
-
chips.join(compact ? " " : @p.dim(" · "))
|
|
198
|
+
[*chips, listening_chip(listening)].compact.join(compact ? " " : @p.dim(" · "))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Red when the listener was asked for and isn't up. Nothing when it
|
|
202
|
+
# wasn't asked for.
|
|
203
|
+
def listening_chip(listening)
|
|
204
|
+
case listening&.state
|
|
205
|
+
when :listening then @theme.blue("◉ #{"lan" if listening.lan}:#{listening.port}")
|
|
206
|
+
when :in_use, :held, :failed then @theme.red("◉ !")
|
|
207
|
+
end
|
|
181
208
|
end
|
|
182
209
|
|
|
183
210
|
def header_chip(count, glyph, words, compact)
|
|
@@ -189,11 +216,16 @@ module ClaudeInbox
|
|
|
189
216
|
text =
|
|
190
217
|
if view.filter_editing then " " + @theme.cyan_bold("/") + line(view.filter, width - 2)
|
|
191
218
|
elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
|
|
192
|
-
else " " +
|
|
219
|
+
else " " + footer_keys(view).map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
193
220
|
end
|
|
194
221
|
Text.pad(text, width)
|
|
195
222
|
end
|
|
196
223
|
|
|
224
|
+
def footer_keys(view)
|
|
225
|
+
return KEYS unless view.listening&.lan
|
|
226
|
+
KEYS.flat_map { |key| (key[0] == "n") ? [key, ["N", "pair"]] : [key] }
|
|
227
|
+
end
|
|
228
|
+
|
|
197
229
|
def line(buffer, width) = buffer.row(width, cursor: caret)
|
|
198
230
|
|
|
199
231
|
def section_title(name, count, width)
|
|
@@ -203,8 +235,9 @@ module ClaudeInbox
|
|
|
203
235
|
Text.pad(" " + section_color(name, "▎") + section_color(name, @p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
|
|
204
236
|
end
|
|
205
237
|
|
|
206
|
-
def section_color(name, s)
|
|
207
|
-
|
|
238
|
+
def section_color(name, s) = tint(SECTION_HUES[name], s)
|
|
239
|
+
|
|
240
|
+
def tint(hue, s)
|
|
208
241
|
hue ? @theme.public_send(hue, s) : @p.dim(s)
|
|
209
242
|
end
|
|
210
243
|
|
|
@@ -366,19 +399,13 @@ module ClaudeInbox
|
|
|
366
399
|
pr ? base + @p.dim(" · ") + pr : base
|
|
367
400
|
end
|
|
368
401
|
|
|
369
|
-
# "#885 open"
|
|
370
|
-
# red closed. Only the first PR is shown; the peek subtitle lists them all.
|
|
402
|
+
# "#885 open". Only the first PR is shown; the peek subtitle lists them all.
|
|
371
403
|
def pr_badge(s, section)
|
|
372
404
|
pr = s.pr
|
|
373
405
|
return nil unless pr
|
|
374
406
|
return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
when "DRAFT" then @p.dim("#{pr.short} draft")
|
|
378
|
-
when "MERGED" then @theme.purple("#{pr.short} merged")
|
|
379
|
-
when "CLOSED" then @theme.red("#{pr.short} closed")
|
|
380
|
-
else @p.dim(pr.short)
|
|
381
|
-
end
|
|
407
|
+
return @p.dim(pr.short) unless PR_HUES.key?(pr.state)
|
|
408
|
+
tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
|
|
382
409
|
end
|
|
383
410
|
|
|
384
411
|
def state_badge(s)
|
|
@@ -439,31 +466,4 @@ module ClaudeInbox
|
|
|
439
466
|
out
|
|
440
467
|
end
|
|
441
468
|
end
|
|
442
|
-
|
|
443
|
-
# Diffs successive frames and writes only changed rows. No erase-to-end-of-
|
|
444
|
-
# line after a row: in the terminal's last column the cursor stays put
|
|
445
|
-
# (pending wrap), so EL would eat the glyph just drawn.
|
|
446
|
-
class Painter
|
|
447
|
-
def initialize(out, cursor: TTY::Cursor)
|
|
448
|
-
@out = out
|
|
449
|
-
@cursor = cursor
|
|
450
|
-
@prev = []
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def paint(lines)
|
|
454
|
-
buf = +""
|
|
455
|
-
lines.each_with_index do |line, i|
|
|
456
|
-
next if @prev[i] == line
|
|
457
|
-
buf << @cursor.move_to(0, i) << line
|
|
458
|
-
end
|
|
459
|
-
if @prev.size > lines.size
|
|
460
|
-
(lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
|
|
461
|
-
end
|
|
462
|
-
@out.print buf unless buf.empty?
|
|
463
|
-
@out.flush
|
|
464
|
-
@prev = lines
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
def invalidate = @prev = []
|
|
468
|
-
end
|
|
469
469
|
end
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -14,10 +14,8 @@ module ClaudeInbox
|
|
|
14
14
|
) do
|
|
15
15
|
# Every member is optional so the parser and the specs name only what they
|
|
16
16
|
# have; prs is [] rather than nil so nobody asks whether PullRequests has run.
|
|
17
|
-
def initialize(
|
|
18
|
-
|
|
19
|
-
super(id: id, cwd: cwd, kind: kind, started_at: started_at, session_id: session_id, name: name,
|
|
20
|
-
state: state, pid: pid, status: status, waiting_for: waiting_for, origin: origin, prs: prs || [], job_state: job_state, bridge_id: bridge_id)
|
|
17
|
+
def initialize(prs: nil, **given)
|
|
18
|
+
super(**self.class.members.to_h { |m| [m, nil] }, **given, prs: prs || [])
|
|
21
19
|
end
|
|
22
20
|
|
|
23
21
|
def self.from_hash(h)
|
|
@@ -59,9 +57,9 @@ module ClaudeInbox
|
|
|
59
57
|
|
|
60
58
|
def terminal? = interactive? && !remote? && !unattended?
|
|
61
59
|
|
|
62
|
-
# Where claude.ai/code shows this session.
|
|
63
|
-
#
|
|
64
|
-
# remote-control` server spawned carries it on its command line.
|
|
60
|
+
# Where claude.ai/code shows this session. A background session with
|
|
61
|
+
# Remote Control on records its bridge in the job file; a worker a
|
|
62
|
+
# `claude remote-control` server spawned carries it on its command line.
|
|
65
63
|
def remote_url
|
|
66
64
|
bridge = bridge_id || job_state&.bridge_id
|
|
67
65
|
bridge && "https://claude.ai/code/session_#{bridge.delete_prefix("cse_")}"
|
|
@@ -84,20 +82,8 @@ module ClaudeInbox
|
|
|
84
82
|
def intent = job_state&.intent
|
|
85
83
|
|
|
86
84
|
# The session's own one-line account of where it is, the same line
|
|
87
|
-
# `claude agents` prints under a row
|
|
88
|
-
|
|
89
|
-
# file, or before the session has said anything.
|
|
90
|
-
def summary
|
|
91
|
-
return nil unless job_state
|
|
92
|
-
line =
|
|
93
|
-
case effective_state
|
|
94
|
-
when "blocked" then job_state.needs || job_state.detail
|
|
95
|
-
when "done" then job_state.result || job_state.detail
|
|
96
|
-
else job_state.detail
|
|
97
|
-
end
|
|
98
|
-
line = line.to_s.gsub(/\s+/, " ").strip
|
|
99
|
-
line.empty? ? nil : line
|
|
100
|
-
end
|
|
85
|
+
# `claude agents` prints under a row. Nil without a job file.
|
|
86
|
+
def summary = job_state&.summary(effective_state)
|
|
101
87
|
|
|
102
88
|
def needs_you? = %w[blocked failed].include?(effective_state)
|
|
103
89
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "agents_client"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# The rules a new session's settings follow, whichever way it is asked
|
|
7
|
+
# for: the `n` form, or a request that arrives as a hash of JSON values.
|
|
8
|
+
module SessionRequest
|
|
9
|
+
class Invalid < StandardError
|
|
10
|
+
attr_reader :field
|
|
11
|
+
|
|
12
|
+
def initialize(field, message)
|
|
13
|
+
@field = field
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
KEYS = %w[prompt name cwd model effort permission_mode worktree remote].freeze
|
|
19
|
+
CHOICES = {
|
|
20
|
+
model: AgentsClient::MODELS,
|
|
21
|
+
effort: AgentsClient::EFFORTS,
|
|
22
|
+
permission_mode: AgentsClient::PERMISSION_MODES
|
|
23
|
+
}.freeze
|
|
24
|
+
FLAGS = {true => true, false => false, "yes" => true, "no" => false}.freeze
|
|
25
|
+
MAX_PROMPT = 100_000
|
|
26
|
+
MAX_NAME = 100
|
|
27
|
+
|
|
28
|
+
# The hash NewSessionForm#values builds, before `resolve`, from a
|
|
29
|
+
# request's fields, or Invalid. "default" is nil. An unknown key is
|
|
30
|
+
# refused rather than ignored, so a misspelt setting can't fall back to
|
|
31
|
+
# its default unnoticed. A blank prompt, or a directory that does not
|
|
32
|
+
# exist, passes here; `problem` reports it as the form does.
|
|
33
|
+
def self.from_params(params, dirs: [])
|
|
34
|
+
params = params.transform_keys(&:to_s)
|
|
35
|
+
unknown = params.keys - KEYS
|
|
36
|
+
raise Invalid.new(unknown.first.to_sym, "unknown key: #{unknown.first.inspect}") if unknown.any?
|
|
37
|
+
{
|
|
38
|
+
prompt: prompt_param(params["prompt"]),
|
|
39
|
+
name: name_param(params["name"]),
|
|
40
|
+
cwd: cwd_param(params["cwd"], dirs),
|
|
41
|
+
model: choice_param(:model, params["model"]),
|
|
42
|
+
effort: choice_param(:effort, params["effort"]),
|
|
43
|
+
permission_mode: choice_param(:permission_mode, params["permission_mode"]),
|
|
44
|
+
worktree: flag_param(:worktree, params["worktree"]) || false,
|
|
45
|
+
remote: flag_param(:remote, params["remote"])
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Unset Remote Control follows /config, then is passed either way so
|
|
50
|
+
# the daemon records it in respawnFlags. The rest stay nil, for the CLI.
|
|
51
|
+
def self.resolve(values, defaults)
|
|
52
|
+
values[:remote].nil? ? values.merge(remote: defaults.remote == "yes") : values
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# What stops these values from starting a session, as [field, message]
|
|
56
|
+
# for the form to show and focus, or nil when nothing does.
|
|
57
|
+
def self.problem(values)
|
|
58
|
+
return [:prompt, "a prompt is required"] if values[:prompt].empty?
|
|
59
|
+
return [:cwd, "no such directory: #{values[:cwd]}"] unless File.directory?(values[:cwd])
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Saved images go into the prompt as the @ mentions the CLI reads. An
|
|
64
|
+
# `[Image #n]` becomes the nth path, the way a chip expands in the form;
|
|
65
|
+
# a path the prompt never points at goes on the end.
|
|
66
|
+
def self.attach(prompt, paths)
|
|
67
|
+
used = []
|
|
68
|
+
text = prompt.gsub(/\[Image #(\d+)\]/i) do |token|
|
|
69
|
+
n = token[/\d+/].to_i
|
|
70
|
+
next token unless n.between?(1, paths.size)
|
|
71
|
+
used << n
|
|
72
|
+
AgentsClient.mention(paths[n - 1])
|
|
73
|
+
end
|
|
74
|
+
rest = paths.reject.with_index(1) { |_, n| used.include?(n) }.map { |path| AgentsClient.mention(path) }
|
|
75
|
+
[text, rest.join("\n")].reject(&:empty?).join("\n\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A session's cwd may sit inside a worktree another agent is using;
|
|
79
|
+
# carrying that into a new prompt would spawn the new agent there too,
|
|
80
|
+
# writing over the same files. Fall back to the repo it was cut from.
|
|
81
|
+
def self.strip_worktree(cwd) = cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
|
|
82
|
+
|
|
83
|
+
# The daemon's summary line can echo the prompt to the terminal, so no
|
|
84
|
+
# control character but tab and newline gets that far. A CRLF, the
|
|
85
|
+
# newline an HTTP client may send, is taken as a newline.
|
|
86
|
+
def self.prompt_param(value)
|
|
87
|
+
prompt = string_param(:prompt, value || "").strip.gsub(/\r\n?/, "\n")
|
|
88
|
+
raise Invalid.new(:prompt, "the prompt is over #{MAX_PROMPT} characters") if prompt.length > MAX_PROMPT
|
|
89
|
+
if prompt.match?(/[[:cntrl:]&&[^\t\n]]/)
|
|
90
|
+
raise Invalid.new(:prompt, "the prompt has a control character other than tab or newline")
|
|
91
|
+
end
|
|
92
|
+
prompt
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The name comes back as the row's label, printed as it is.
|
|
96
|
+
def self.name_param(value)
|
|
97
|
+
return nil if value.nil?
|
|
98
|
+
name = string_param(:name, value).strip
|
|
99
|
+
raise Invalid.new(:name, "a name is one line") if name.match?(/[[:cntrl:]\u2028\u2029]/)
|
|
100
|
+
raise Invalid.new(:name, "the name is over #{MAX_NAME} characters") if name.length > MAX_NAME
|
|
101
|
+
name.empty? ? nil : name
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The fewest trailing components that name `path` alone among `dirs`.
|
|
105
|
+
def self.label(path, dirs)
|
|
106
|
+
parts = path.split("/").reject(&:empty?)
|
|
107
|
+
(1..parts.size).each do |n|
|
|
108
|
+
label = parts.last(n).join("/")
|
|
109
|
+
return label if dirs.one? { |other| other.end_with?("/#{label}") }
|
|
110
|
+
end
|
|
111
|
+
path
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A path is taken as given. Anything else names one of `dirs` by its
|
|
115
|
+
# `label`.
|
|
116
|
+
def self.cwd_param(value, dirs)
|
|
117
|
+
cwd = string_param(:cwd, value || "").strip
|
|
118
|
+
raise Invalid.new(:cwd, "a directory is required") if cwd.empty?
|
|
119
|
+
raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(/[[:cntrl:]\u2028\u2029]/)
|
|
120
|
+
return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
|
|
121
|
+
found = dirs.select { |dir| dir.end_with?("/#{cwd}") }
|
|
122
|
+
raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
|
|
123
|
+
raise Invalid.new(:cwd, "#{cwd} could be any of #{found.join(", ")}") if found.size > 1
|
|
124
|
+
found.first
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.choice_param(key, value)
|
|
128
|
+
return nil if value.nil? || value == "default"
|
|
129
|
+
return value if CHOICES[key].include?(value)
|
|
130
|
+
raise Invalid.new(key, "#{key} is one of #{CHOICES[key].join(", ")}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.flag_param(key, value)
|
|
134
|
+
return nil if value.nil?
|
|
135
|
+
FLAGS.fetch(value) { raise Invalid.new(key, "#{key} is true, false, yes or no") }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Each of these ends up in an argv or a path, where a NUL byte raises.
|
|
139
|
+
# The bytes are checked as UTF-8 whatever the string's tag says: a
|
|
140
|
+
# binary string always passes `valid_encoding?`.
|
|
141
|
+
def self.string_param(key, value)
|
|
142
|
+
raise Invalid.new(key, "#{key} must be a string") unless value.is_a?(String)
|
|
143
|
+
text = String.new(value, encoding: Encoding::UTF_8)
|
|
144
|
+
raise Invalid.new(key, "#{key} is not valid UTF-8") unless text.valid_encoding?
|
|
145
|
+
raise Invalid.new(key, "#{key} contains a NUL byte") if text.include?("\0")
|
|
146
|
+
text
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param
|
|
150
|
+
end
|
|
151
|
+
end
|