claude-inbox 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +93 -165
- data/exe/claude-inbox +49 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +15 -10
- data/lib/claude_inbox/app.rb +58 -18
- data/lib/claude_inbox/headless.rb +30 -0
- data/lib/claude_inbox/job_state.rb +4 -4
- data/lib/claude_inbox/keymap.rb +0 -2
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +67 -31
- data/lib/claude_inbox/painter.rb +3 -6
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/poller.rb +46 -25
- data/lib/claude_inbox/pull_requests.rb +32 -18
- data/lib/claude_inbox/rate_limits.rb +2 -4
- data/lib/claude_inbox/reaper.rb +1 -3
- data/lib/claude_inbox/remote/http.rb +11 -8
- data/lib/claude_inbox/remote/listener.rb +4 -9
- data/lib/claude_inbox/remote/start.rb +4 -11
- data/lib/claude_inbox/renderer.rb +8 -16
- data/lib/claude_inbox/session_request.rb +10 -0
- data/lib/claude_inbox/settings.rb +1 -0
- data/lib/claude_inbox/slash_commands.rb +13 -12
- data/lib/claude_inbox/snapshot.rb +47 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -5
- data/lib/claude_inbox/store.rb +24 -9
- data/lib/claude_inbox/switch.rb +38 -0
- data/lib/claude_inbox/terminal.rb +8 -0
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/writer_lock.rb +58 -0
- data/lib/claude_inbox.rb +1 -10
- data/mod/.claude-plugin/plugin.json +20 -0
- data/mod/hooks/hooks.json +1 -0
- data/mod/hooks/register.tsx +491 -0
- data/mod/tests/contract.test.tsx +21 -0
- data/mod/tests/pane.test.tsx +217 -0
- data/mod/tests/snapshot.fixture.ts +44 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +28 -0
- metadata +16 -2
- data/lib/claude_inbox/remote.rb +0 -10
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -11,7 +11,7 @@ require_relative "terminal"
|
|
|
11
11
|
require_relative "logs"
|
|
12
12
|
require_relative "peek"
|
|
13
13
|
require_relative "keymap"
|
|
14
|
-
require_relative "remote"
|
|
14
|
+
require_relative "remote/listener"
|
|
15
15
|
require_relative "mouse"
|
|
16
16
|
require_relative "new_session_form"
|
|
17
17
|
require_relative "paste"
|
|
@@ -20,6 +20,10 @@ require_relative "reaper"
|
|
|
20
20
|
require_relative "poller"
|
|
21
21
|
require_relative "rate_limits"
|
|
22
22
|
require_relative "session_request"
|
|
23
|
+
require_relative "actions"
|
|
24
|
+
require_relative "snapshot"
|
|
25
|
+
require_relative "switch"
|
|
26
|
+
require_relative "trust"
|
|
23
27
|
|
|
24
28
|
module ClaudeInbox
|
|
25
29
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
@@ -28,19 +32,24 @@ module ClaudeInbox
|
|
|
28
32
|
# The reaper deletes sessions and the listener lets other machines in,
|
|
29
33
|
# so both are off unless `bin/claude-inbox` switches them on.
|
|
30
34
|
def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
|
|
31
|
-
rate_limits: RateLimits.new, reaper: Reaper.disabled,
|
|
35
|
+
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, trust: Trust.method(:projects),
|
|
32
37
|
terminal: Terminal.new(out, input), queue: Queue.new)
|
|
33
38
|
@client = client
|
|
34
39
|
@store = store
|
|
40
|
+
@snapshot = snapshot
|
|
41
|
+
@switch = switch
|
|
35
42
|
@rate_limits = rate_limits
|
|
36
43
|
@terminal = terminal
|
|
37
44
|
@color = color
|
|
45
|
+
@trust = trust
|
|
38
46
|
@renderer = Renderer.new(color: color)
|
|
47
|
+
@input = input
|
|
39
48
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
40
49
|
@queue = queue
|
|
41
50
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
42
|
-
reaper: reaper, queue: @queue)
|
|
43
|
-
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue,
|
|
51
|
+
reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions)
|
|
52
|
+
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
|
|
44
53
|
@logs = Logs.new(client)
|
|
45
54
|
@peek = Peek.new(@logs)
|
|
46
55
|
@selected = nil
|
|
@@ -80,8 +89,10 @@ module ClaudeInbox
|
|
|
80
89
|
@resize = false
|
|
81
90
|
@terminal.resized
|
|
82
91
|
end
|
|
83
|
-
render
|
|
84
92
|
handle_input(input) if input
|
|
93
|
+
# A paste arrives a character per read and shows nothing until it
|
|
94
|
+
# closes, so a frame per character would only stall a long one.
|
|
95
|
+
render unless @paste.pasting?
|
|
85
96
|
end
|
|
86
97
|
|
|
87
98
|
private
|
|
@@ -109,7 +120,6 @@ module ClaudeInbox
|
|
|
109
120
|
kind, *rest = @queue.pop(true)
|
|
110
121
|
case kind
|
|
111
122
|
when :sessions
|
|
112
|
-
@store.update(rest[0])
|
|
113
123
|
@last_poll = Time.now
|
|
114
124
|
@error = nil
|
|
115
125
|
when :error then @error = rest[0]
|
|
@@ -128,7 +138,19 @@ module ClaudeInbox
|
|
|
128
138
|
# ----- main loop --------------------------------------------------------
|
|
129
139
|
|
|
130
140
|
def main_loop
|
|
131
|
-
step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
|
|
141
|
+
step(@paste.pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# As much of an open paste as has arrived, read straight off the tty:
|
|
145
|
+
# the reader switches echo off and back on around every character, and
|
|
146
|
+
# on a long paste that is most of the wait.
|
|
147
|
+
def read_pasted
|
|
148
|
+
return nil unless @input.wait_readable(0.1)
|
|
149
|
+
out = +""
|
|
150
|
+
while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
|
|
151
|
+
out << c
|
|
152
|
+
end
|
|
153
|
+
out
|
|
132
154
|
end
|
|
133
155
|
|
|
134
156
|
def handle_input(raw)
|
|
@@ -153,7 +175,9 @@ module ClaudeInbox
|
|
|
153
175
|
def render
|
|
154
176
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
177
|
now = Time.now
|
|
156
|
-
|
|
178
|
+
all = @store.sections(now)
|
|
179
|
+
@snapshot.write(all, now)
|
|
180
|
+
sections = filtered(all)
|
|
157
181
|
width, height = @terminal.size
|
|
158
182
|
body_h = Renderer.body_height(height)
|
|
159
183
|
ensure_selection(sections)
|
|
@@ -229,6 +253,13 @@ module ClaudeInbox
|
|
|
229
253
|
false
|
|
230
254
|
end
|
|
231
255
|
|
|
256
|
+
# The edits with no request form, which a second inbox cannot keep.
|
|
257
|
+
def require_writable(verb)
|
|
258
|
+
return true if @store.writable?
|
|
259
|
+
notice("another claude-inbox owns state.json — #{verb} there")
|
|
260
|
+
false
|
|
261
|
+
end
|
|
262
|
+
|
|
232
263
|
def notice(msg)
|
|
233
264
|
@notice = [msg, Time.now + 4]
|
|
234
265
|
end
|
|
@@ -276,7 +307,7 @@ module ClaudeInbox
|
|
|
276
307
|
return if @list_width && col > @list_width
|
|
277
308
|
item = row_item_at(row)
|
|
278
309
|
return unless item
|
|
279
|
-
select(item
|
|
310
|
+
select(item)
|
|
280
311
|
activate
|
|
281
312
|
end
|
|
282
313
|
|
|
@@ -284,7 +315,7 @@ module ClaudeInbox
|
|
|
284
315
|
# no item of its own; a click there resolves to the row above it.
|
|
285
316
|
def row_item_at(row)
|
|
286
317
|
idx = row - 1
|
|
287
|
-
@row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.
|
|
318
|
+
@row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
|
|
288
319
|
end
|
|
289
320
|
|
|
290
321
|
def perform(action)
|
|
@@ -389,12 +420,20 @@ module ClaudeInbox
|
|
|
389
420
|
notice("settled — u brings it back")
|
|
390
421
|
end
|
|
391
422
|
|
|
423
|
+
# The pane in the attached session may ask for a different one: the
|
|
424
|
+
# attach ends and the next starts without passing through the list. The
|
|
425
|
+
# poller keeps running meanwhile, so the pane's snapshot stays fresh. A
|
|
426
|
+
# failed attach leaves its reason on screen until a key is pressed.
|
|
392
427
|
def attach(id)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
428
|
+
while id
|
|
429
|
+
@store.acknowledge(id)
|
|
430
|
+
@switch.clear
|
|
431
|
+
from = session_for(id)&.session_id
|
|
432
|
+
@terminal.release do
|
|
433
|
+
@terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
|
|
434
|
+
end
|
|
435
|
+
id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
|
|
436
|
+
end
|
|
398
437
|
end
|
|
399
438
|
|
|
400
439
|
# ----- modals -----------------------------------------------------------
|
|
@@ -410,13 +449,13 @@ module ClaudeInbox
|
|
|
410
449
|
end
|
|
411
450
|
|
|
412
451
|
def open_alias_editor
|
|
413
|
-
return unless require_storable
|
|
452
|
+
return unless require_storable && require_writable("rename")
|
|
414
453
|
current = @store.alias_for(@selected.key) || ""
|
|
415
454
|
@modal = Dialog::Prompt.new(:alias, @selected.key, current)
|
|
416
455
|
end
|
|
417
456
|
|
|
418
457
|
def open_pr_editor
|
|
419
|
-
return unless require_storable
|
|
458
|
+
return unless require_storable && require_writable("link a pull request")
|
|
420
459
|
current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
|
|
421
460
|
@modal = Dialog::Prompt.new(:pr, @selected.key, current)
|
|
422
461
|
end
|
|
@@ -441,7 +480,8 @@ module ClaudeInbox
|
|
|
441
480
|
|
|
442
481
|
def open_new_session
|
|
443
482
|
cwd = selected_session&.cwd || Dir.pwd
|
|
444
|
-
|
|
483
|
+
dirs = SessionRequest.directories(@store.sessions, @trust.call)
|
|
484
|
+
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
|
|
445
485
|
end
|
|
446
486
|
|
|
447
487
|
# `attach:` hands the terminal over as soon as the session starts. Without
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "actions"
|
|
4
|
+
require_relative "poller"
|
|
5
|
+
require_relative "pull_requests"
|
|
6
|
+
require_relative "reaper"
|
|
7
|
+
require_relative "snapshot"
|
|
8
|
+
require_relative "writer_lock"
|
|
9
|
+
|
|
10
|
+
module ClaudeInbox
|
|
11
|
+
# The poller with no terminal, for a front end that only reads the
|
|
12
|
+
# snapshot: it polls, writes the file and applies the requests. It never
|
|
13
|
+
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
14
|
+
# exits at once while an inbox holds the writer lock.
|
|
15
|
+
class Headless
|
|
16
|
+
def initialize(client:, store:, lock: WriterLock.new)
|
|
17
|
+
@lock = lock
|
|
18
|
+
@queue = Queue.new
|
|
19
|
+
@poller = Poller.new(client: client, store: store, pull_requests: PullRequests.new, reaper: Reaper.disabled,
|
|
20
|
+
queue: @queue, snapshot: Snapshot.new, actions: Actions.new)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# False when the lock is held, by an inbox or another headless process.
|
|
24
|
+
def run
|
|
25
|
+
return false unless @lock.take(role: "headless")
|
|
26
|
+
@poller.start
|
|
27
|
+
loop { @queue.pop }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -38,19 +38,19 @@ module ClaudeInbox
|
|
|
38
38
|
# Each background session with its job file read onto `job_state`, or nil
|
|
39
39
|
# when there is none: interactive sessions have no job file, and neither
|
|
40
40
|
# does one the daemon has already forgotten. Sessions.load calls this.
|
|
41
|
-
def self.enrich(sessions, jobs_dir:
|
|
41
|
+
def self.enrich(sessions, jobs_dir:)
|
|
42
42
|
sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# => JobState, or nil when there is no readable file for this id.
|
|
46
|
-
def self.read(id, jobs_dir:
|
|
46
|
+
def self.read(id, jobs_dir:)
|
|
47
47
|
return nil unless id
|
|
48
48
|
new(JSON.parse(File.read(File.join(jobs_dir, id, "state.json"))))
|
|
49
49
|
rescue JSON::ParserError, SystemCallError
|
|
50
50
|
nil
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
attr_reader :
|
|
53
|
+
attr_reader :pr_urls, :color, :intent, :bridge_id
|
|
54
54
|
|
|
55
55
|
def initialize(hash)
|
|
56
56
|
@detail = hash["detail"]
|
|
@@ -96,7 +96,7 @@ module ClaudeInbox
|
|
|
96
96
|
|
|
97
97
|
private
|
|
98
98
|
|
|
99
|
-
attr_reader :tempo, :kinds, :tasks, :flags
|
|
99
|
+
attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :flags
|
|
100
100
|
|
|
101
101
|
# The agent itself is not thinking. On its own this means little — a
|
|
102
102
|
# session whose process died leaves the same reading behind — so it only
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# Where the Claude Code pane ships, and a path to it that holds still: the
|
|
8
|
+
# gem's own folder moves with every version, so a link under ~/.config is
|
|
9
|
+
# what settings.json names, refreshed by the installed gem on every launch.
|
|
10
|
+
module Mod
|
|
11
|
+
DIR = File.expand_path("../../mod", __dir__)
|
|
12
|
+
LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
|
|
13
|
+
NAME = "inbox-pane"
|
|
14
|
+
CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
|
|
15
|
+
# What the pane runs headless: a checkout's own, or the gem's from PATH.
|
|
16
|
+
COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
|
|
17
|
+
|
|
18
|
+
# Points the link at this gem's mod folder and returns the link. A folder
|
|
19
|
+
# of that name that is not a link is left alone. From a checkout only a
|
|
20
|
+
# missing or dangling link is made, unless forced (`--mod-dir` asks for
|
|
21
|
+
# it), so a worktree's launch never takes the path from the installed gem.
|
|
22
|
+
def self.link(link: LINK, dir: DIR, force: false, checkout: CHECKOUT)
|
|
23
|
+
FileUtils.mkdir_p(File.dirname(link))
|
|
24
|
+
return link if File.exist?(link) && !File.symlink?(link)
|
|
25
|
+
if File.symlink?(link)
|
|
26
|
+
return link if File.readlink(link) == dir
|
|
27
|
+
return link if checkout && !force && File.directory?(File.readlink(link))
|
|
28
|
+
File.delete(link)
|
|
29
|
+
end
|
|
30
|
+
File.symlink(dir, link)
|
|
31
|
+
link
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Installed = Data.define(:added, :removed, :command_set)
|
|
35
|
+
|
|
36
|
+
# Links the mod and adds the link to CLAUDE_CODE_PLUGIN_DIRS in Claude
|
|
37
|
+
# Code's user settings, keeping any folders already there; forced, it
|
|
38
|
+
# drops the other folders holding this plugin, such as a worktree's, and
|
|
39
|
+
# sets the command the pane runs to this copy's. Raises JSON::ParserError
|
|
40
|
+
# rather than rewrite a settings file it cannot read.
|
|
41
|
+
def self.install(settings: File.join(Dir.home, ".claude", "settings.json"), link: LINK, dir: DIR, force: false, command: COMMAND)
|
|
42
|
+
self.link(link: link, dir: dir, force: true)
|
|
43
|
+
json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
|
|
44
|
+
env = json["env"] ||= {}
|
|
45
|
+
dirs = env["CLAUDE_CODE_PLUGIN_DIRS"].to_s.split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
|
|
46
|
+
kept = dirs.reject { |d| force && d != link && plugin?(d) }
|
|
47
|
+
wanted = kept.include?(link) ? kept : [*kept, link]
|
|
48
|
+
options = json.dig("pluginConfigs", NAME, "options") || {}
|
|
49
|
+
replace = force && options["command"] != command
|
|
50
|
+
installed = Installed.new(added: !kept.include?(link), removed: dirs - kept, command_set: replace)
|
|
51
|
+
return installed if wanted == dirs && !replace
|
|
52
|
+
|
|
53
|
+
if replace
|
|
54
|
+
command ? options["command"] = command : options.delete("command")
|
|
55
|
+
((json["pluginConfigs"] ||= {})[NAME] ||= {})["options"] = options
|
|
56
|
+
end
|
|
57
|
+
env["CLAUDE_CODE_PLUGIN_DIRS"] = wanted.join(File::PATH_SEPARATOR)
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(settings))
|
|
59
|
+
File.write("#{settings}.tmp", JSON.pretty_generate(json) + "\n")
|
|
60
|
+
File.rename("#{settings}.tmp", settings)
|
|
61
|
+
installed
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.plugin?(folder)
|
|
65
|
+
JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
|
|
66
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
private_class_method :plugin?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pastel"
|
|
3
4
|
require_relative "agents_client"
|
|
4
5
|
require_relative "images"
|
|
5
6
|
require_relative "session_request"
|
|
@@ -18,6 +19,12 @@ module ClaudeInbox
|
|
|
18
19
|
# string on :choice ones — `kind` says which.
|
|
19
20
|
Field = Struct.new(:key, :label, :kind, :value, :choices)
|
|
20
21
|
|
|
22
|
+
# A directory the Directory field offers, shown by its shortest
|
|
23
|
+
# distinct label as the phone's form shows it.
|
|
24
|
+
Place = Struct.new(:path, :label, :description) do
|
|
25
|
+
def to_s = label
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
# The screen shows what "default" resolves to; `values` makes it nil.
|
|
22
29
|
DEFAULT = "default"
|
|
23
30
|
|
|
@@ -27,11 +34,13 @@ module ClaudeInbox
|
|
|
27
34
|
# editable field spends these on its own text instead.
|
|
28
35
|
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
29
36
|
|
|
30
|
-
def initialize(cwd:,
|
|
31
|
-
@p =
|
|
32
|
-
@theme = Theme.new(enabled:
|
|
37
|
+
def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
38
|
+
@p = Pastel.new(enabled: color)
|
|
39
|
+
@theme = Theme.new(enabled: color)
|
|
33
40
|
@home = home
|
|
34
41
|
@clipboard = clipboard
|
|
42
|
+
@places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
|
|
43
|
+
@dir_settled = cwd.to_s
|
|
35
44
|
@fields = [
|
|
36
45
|
Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
|
|
37
46
|
Field.new(:name, "Name", :text, TextBuffer.new),
|
|
@@ -66,7 +75,7 @@ module ClaudeInbox
|
|
|
66
75
|
@error = nil
|
|
67
76
|
@candidates = nil
|
|
68
77
|
return :changed if menu && menu_press(name)
|
|
69
|
-
before =
|
|
78
|
+
before = menu_query
|
|
70
79
|
case name
|
|
71
80
|
when :escape then return escape_pressed
|
|
72
81
|
when :ctrl_s then return submit(attach: false)
|
|
@@ -80,7 +89,7 @@ module ClaudeInbox
|
|
|
80
89
|
else
|
|
81
90
|
editable? ? focused.value.press(name, raw) : choose(name, raw)
|
|
82
91
|
end
|
|
83
|
-
edited = before &&
|
|
92
|
+
edited = before && menu_query && menu_query != before
|
|
84
93
|
@pick = 0 if edited
|
|
85
94
|
@dismissed = nil if edited
|
|
86
95
|
:changed
|
|
@@ -103,15 +112,12 @@ module ClaudeInbox
|
|
|
103
112
|
:changed
|
|
104
113
|
end
|
|
105
114
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
109
|
-
end
|
|
110
|
-
|
|
115
|
+
# Slash commands while one is being typed in the prompt; the recent and
|
|
116
|
+
# trusted directories once the Directory field is edited.
|
|
111
117
|
def menu
|
|
112
|
-
q =
|
|
118
|
+
q = menu_query
|
|
113
119
|
return nil if q.nil? || @dismissed == q
|
|
114
|
-
found = SlashCommands.match(commands, q)
|
|
120
|
+
found = (focused.key == :cwd) ? places(q) : SlashCommands.match(commands, q)
|
|
115
121
|
found.empty? ? nil : found
|
|
116
122
|
end
|
|
117
123
|
|
|
@@ -124,26 +130,18 @@ module ClaudeInbox
|
|
|
124
130
|
v[:name] = nil if v[:name].strip.empty?
|
|
125
131
|
v[:cwd] = directory
|
|
126
132
|
v[:remote] = SessionRequest::FLAGS[v[:remote]]
|
|
127
|
-
SessionRequest.resolve(v, defaults
|
|
133
|
+
SessionRequest.resolve(v, defaults)
|
|
128
134
|
end
|
|
129
135
|
|
|
130
136
|
# Settings resolve against the directory the session will run in, so
|
|
131
137
|
# they follow the Directory field.
|
|
132
|
-
def defaults
|
|
138
|
+
def defaults
|
|
139
|
+
cwd = directory
|
|
133
140
|
return @defaults if @defaults_for == cwd
|
|
134
141
|
@defaults_for = cwd
|
|
135
142
|
@defaults = Settings.defaults(cwd, home: @home)
|
|
136
143
|
end
|
|
137
144
|
|
|
138
|
-
# Project commands live under the Directory field's path, so they
|
|
139
|
-
# follow it as the defaults do.
|
|
140
|
-
def commands
|
|
141
|
-
cwd = directory
|
|
142
|
-
return @commands if @commands_for == cwd
|
|
143
|
-
@commands_for = cwd
|
|
144
|
-
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
145
|
# Full-screen body: a tall prompt editor, then one row per setting with
|
|
148
146
|
# every choice visible. Exactly `height` lines.
|
|
149
147
|
def screen(width, height)
|
|
@@ -157,9 +155,12 @@ module ClaudeInbox
|
|
|
157
155
|
out << ""
|
|
158
156
|
out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
|
|
159
157
|
out += prompt_box(field(:prompt), inner_w, prompt_h)
|
|
160
|
-
out += menu_rows
|
|
158
|
+
out += menu_rows if focused.key == :prompt
|
|
161
159
|
out << ""
|
|
162
|
-
@fields[1..].each
|
|
160
|
+
@fields[1..].each do |f|
|
|
161
|
+
out << " " + field_label(f) + field_value(f, inner_w - 16)
|
|
162
|
+
out += menu_rows if f.key == :cwd && focused.key == :cwd
|
|
163
|
+
end
|
|
163
164
|
out.first(height) + [""] * [height - out.size, 0].max
|
|
164
165
|
end
|
|
165
166
|
|
|
@@ -175,6 +176,7 @@ module ClaudeInbox
|
|
|
175
176
|
when :choice then [["← → h l", "change"], ["⏎", "next"]]
|
|
176
177
|
else [["⏎", "next"]]
|
|
177
178
|
end
|
|
179
|
+
keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
|
|
178
180
|
keys += [["^S", "start"], ["^O", "start & open"],
|
|
179
181
|
["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
|
|
180
182
|
hints(keys)
|
|
@@ -192,6 +194,36 @@ module ClaudeInbox
|
|
|
192
194
|
|
|
193
195
|
private
|
|
194
196
|
|
|
197
|
+
def menu_query
|
|
198
|
+
case focused.key
|
|
199
|
+
when :prompt then focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
200
|
+
when :cwd then focused.value.to_s unless focused.value.to_s == @dir_settled
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def places(query)
|
|
205
|
+
q = query.downcase
|
|
206
|
+
found = @places.select { |d| d.path.downcase.include?(q) || d.description.downcase.include?(q) }
|
|
207
|
+
starts, rest = found.partition { |d| d.label.downcase.start_with?(q) }
|
|
208
|
+
starts + rest
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A path put in the field by a pick or a completion rather than typed,
|
|
212
|
+
# so the menu stays shut until the next edit.
|
|
213
|
+
def settle_dir(path)
|
|
214
|
+
field(:cwd).value.replace(path)
|
|
215
|
+
@dir_settled = path
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Project commands live under the Directory field's path, so they
|
|
219
|
+
# follow it as the defaults do.
|
|
220
|
+
def commands
|
|
221
|
+
cwd = directory
|
|
222
|
+
return @commands if @commands_for == cwd
|
|
223
|
+
@commands_for = cwd
|
|
224
|
+
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
225
|
+
end
|
|
226
|
+
|
|
195
227
|
# Esc with a prompt typed asks first, so a stray keypress can't lose it.
|
|
196
228
|
def escape_pressed
|
|
197
229
|
return :cancel if field(:prompt).value.empty?
|
|
@@ -219,7 +251,7 @@ module ClaudeInbox
|
|
|
219
251
|
|
|
220
252
|
def insert(text)
|
|
221
253
|
return unless editable?
|
|
222
|
-
|
|
254
|
+
(focused.kind == :multiline) ? focused.value.paste(text) : focused.value.insert(text.tr("\n", " "))
|
|
223
255
|
end
|
|
224
256
|
|
|
225
257
|
def menu_press(name)
|
|
@@ -227,14 +259,18 @@ module ClaudeInbox
|
|
|
227
259
|
when :up, :ctrl_p then @pick = (@pick - 1) % menu.size
|
|
228
260
|
when :down, :ctrl_n then @pick = (@pick + 1) % menu.size
|
|
229
261
|
when :tab, :return, :enter then accept(picked)
|
|
230
|
-
when :escape then @dismissed =
|
|
262
|
+
when :escape then @dismissed = menu_query
|
|
231
263
|
else return false
|
|
232
264
|
end
|
|
233
265
|
true
|
|
234
266
|
end
|
|
235
267
|
|
|
236
|
-
def accept(
|
|
237
|
-
focused.
|
|
268
|
+
def accept(item)
|
|
269
|
+
if focused.key == :cwd
|
|
270
|
+
settle_dir(item.path)
|
|
271
|
+
else
|
|
272
|
+
focused.value.replace_before(menu_query.grapheme_clusters.size + 1, "#{item} ")
|
|
273
|
+
end
|
|
238
274
|
@pick = 0
|
|
239
275
|
end
|
|
240
276
|
|
|
@@ -326,10 +362,10 @@ module ClaudeInbox
|
|
|
326
362
|
matches = Dir.glob(listing ? "#{base}/*" : "#{base}*").select { |d| File.directory?(d) }.sort
|
|
327
363
|
return nil if matches.empty?
|
|
328
364
|
if matches.size == 1
|
|
329
|
-
|
|
365
|
+
settle_dir("#{matches.first}/")
|
|
330
366
|
else
|
|
331
367
|
@candidates = matches.map { |d| File.basename(d) }
|
|
332
|
-
|
|
368
|
+
settle_dir(common_prefix(matches))
|
|
333
369
|
end
|
|
334
370
|
:changed
|
|
335
371
|
end
|
data/lib/claude_inbox/painter.rb
CHANGED
|
@@ -7,9 +7,8 @@ module ClaudeInbox
|
|
|
7
7
|
# line after a row: in the terminal's last column the cursor stays put
|
|
8
8
|
# (pending wrap), so EL would eat the glyph just drawn.
|
|
9
9
|
class Painter
|
|
10
|
-
def initialize(out
|
|
10
|
+
def initialize(out)
|
|
11
11
|
@out = out
|
|
12
|
-
@cursor = cursor
|
|
13
12
|
@prev = []
|
|
14
13
|
end
|
|
15
14
|
|
|
@@ -17,11 +16,9 @@ module ClaudeInbox
|
|
|
17
16
|
buf = +""
|
|
18
17
|
lines.each_with_index do |line, i|
|
|
19
18
|
next if @prev[i] == line
|
|
20
|
-
buf <<
|
|
21
|
-
end
|
|
22
|
-
if @prev.size > lines.size
|
|
23
|
-
(lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
|
|
19
|
+
buf << TTY::Cursor.move_to(0, i) << line
|
|
24
20
|
end
|
|
21
|
+
(lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
|
|
25
22
|
@out.print buf unless buf.empty?
|
|
26
23
|
@out.flush
|
|
27
24
|
@prev = lines
|