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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +93 -165
  3. data/exe/claude-inbox +49 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +15 -10
  6. data/lib/claude_inbox/app.rb +58 -18
  7. data/lib/claude_inbox/headless.rb +30 -0
  8. data/lib/claude_inbox/job_state.rb +4 -4
  9. data/lib/claude_inbox/keymap.rb +0 -2
  10. data/lib/claude_inbox/mod.rb +71 -0
  11. data/lib/claude_inbox/new_session_form.rb +67 -31
  12. data/lib/claude_inbox/painter.rb +3 -6
  13. data/lib/claude_inbox/paste.rb +2 -0
  14. data/lib/claude_inbox/poller.rb +46 -25
  15. data/lib/claude_inbox/pull_requests.rb +32 -18
  16. data/lib/claude_inbox/rate_limits.rb +2 -4
  17. data/lib/claude_inbox/reaper.rb +1 -3
  18. data/lib/claude_inbox/remote/http.rb +11 -8
  19. data/lib/claude_inbox/remote/listener.rb +4 -9
  20. data/lib/claude_inbox/remote/start.rb +4 -11
  21. data/lib/claude_inbox/renderer.rb +8 -16
  22. data/lib/claude_inbox/session_request.rb +10 -0
  23. data/lib/claude_inbox/settings.rb +1 -0
  24. data/lib/claude_inbox/slash_commands.rb +13 -12
  25. data/lib/claude_inbox/snapshot.rb +47 -0
  26. data/lib/claude_inbox/store/entry.rb +0 -2
  27. data/lib/claude_inbox/store/row.rb +7 -5
  28. data/lib/claude_inbox/store.rb +24 -9
  29. data/lib/claude_inbox/switch.rb +38 -0
  30. data/lib/claude_inbox/terminal.rb +8 -0
  31. data/lib/claude_inbox/text_buffer.rb +84 -33
  32. data/lib/claude_inbox/writer_lock.rb +58 -0
  33. data/lib/claude_inbox.rb +1 -10
  34. data/mod/.claude-plugin/plugin.json +20 -0
  35. data/mod/hooks/hooks.json +1 -0
  36. data/mod/hooks/register.tsx +491 -0
  37. data/mod/tests/contract.test.tsx +21 -0
  38. data/mod/tests/pane.test.tsx +217 -0
  39. data/mod/tests/snapshot.fixture.ts +44 -0
  40. data/mod/tests/snooze.test.tsx +31 -0
  41. data/mod/tsconfig.json +3 -0
  42. data/mod/types/index.d.ts +28 -0
  43. metadata +16 -2
  44. data/lib/claude_inbox/remote.rb +0 -10
@@ -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, listen: nil, out: $stdout, input: $stdin, color: true,
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, jobs_dir: client.jobs_dir, **listen) : Remote::Listener.disabled
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
- sections = filtered(@store.sections(now))
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.selection)
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]&.kind == :row)
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
- @store.acknowledge(id)
394
- @poller.pause
395
- @terminal.release { @client.attach(id) }
396
- ensure
397
- @poller.resume
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
- @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
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: DEFAULT_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: DEFAULT_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 :detail, :needs, :result, :pr_urls, :color, :intent, :bridge_id
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
@@ -38,8 +38,6 @@ module ClaudeInbox
38
38
  "z" => {"o" => :fold_open, "c" => :fold_close, "a" => :fold_toggle}
39
39
  }.freeze
40
40
 
41
- attr_reader :pending
42
-
43
41
  def initialize(clock: -> { Time.now })
44
42
  @clock = clock
45
43
  @pending = nil
@@ -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:, pastel:, home: Dir.home, clipboard: Images.method(:from_clipboard))
31
- @p = pastel
32
- @theme = Theme.new(enabled: pastel.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 = command_query
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 && command_query && command_query != 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
- def command_query
107
- return nil unless focused.key == :prompt
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 = command_query
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(v[:cwd]))
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(cwd = directory)
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 { |f| out << " " + field_label(f) + field_value(f, inner_w - 16) }
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
- focused.value.insert((focused.kind == :multiline) ? text : text.tr("\n", " "))
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 = command_query
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(cmd)
237
- focused.value.replace_before(command_query.grapheme_clusters.size + 1, "#{cmd} ")
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
- focused.value.replace("#{matches.first}/")
365
+ settle_dir("#{matches.first}/")
330
366
  else
331
367
  @candidates = matches.map { |d| File.basename(d) }
332
- focused.value.replace(common_prefix(matches))
368
+ settle_dir(common_prefix(matches))
333
369
  end
334
370
  :changed
335
371
  end
@@ -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, cursor: TTY::Cursor)
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 << @cursor.move_to(0, i) << line
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
@@ -20,6 +20,8 @@ module ClaudeInbox
20
20
  @open = nil
21
21
  end
22
22
 
23
+ def pasting? = !@open.nil?
24
+
23
25
  def feed(raw)
24
26
  out = []
25
27
  rest = raw.to_s