claude-inbox 0.12.0 → 0.12.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfb9f33ca25bbd7a081fcab7316c724af065f9d078cd9a329e4c5d2a4f25d49d
4
- data.tar.gz: d733d6f8cd233cc37c0d4b0e43abe6f48936b17e1731b9d8d696990f8ded4046
3
+ metadata.gz: 39ef2bafdac9f7b1c8ba1d4e6f28f5105a5fd5c42320f627caf61fd07f9c314d
4
+ data.tar.gz: 0ed5aadb2d02b61b1eabca62a7de74820f7e884f5ffdc6f0bbb68382ba26cc13
5
5
  SHA512:
6
- metadata.gz: a4c6efddf42c8363e1f383941f854ec2ba35e38f2cfe687749aa4b18b88efe1fbb400cdcb2e3e727493daabda7c7b82753d637078b397ca7f8992305d6bb3df6
7
- data.tar.gz: 7ddfe74e86730f795581cc454fc4c09a0b7f8b1b8f49c45e7e9cbabb504387c7411902177eb84b8578d36653b421dc0794afb01f6ae55a848fa2a657073b56e0
6
+ metadata.gz: 393709c2bc4fc083ded424de49ca3ad102bcf41d75fd72b5c445d8b1f23f7e80f7df0ac1623d4081994e14699168db7c58a2bc64f486b00dd83941c2d691600f
7
+ data.tar.gz: fb06b9a05422bff8e3a5474169a11574041219ae700e30a5178a1e507e74df95cd0c069a1c395a070a7f7856bbe6bcdd6380c2a6011a03ff36a1210c05cdae2e
data/README.md CHANGED
@@ -105,7 +105,8 @@ Vim bindings. Arrows and the mouse work too.
105
105
  `n` opens a form for the prompt, name, directory, model, effort, permissions,
106
106
  worktree, Remote Control and color, which tints the row as `/color` would.
107
107
  `Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
108
- it and attaches.
108
+ it and attaches. `Option-S` or `Option-O` by mistake only says which key you
109
+ meant; it no longer reads as `Esc` and asks to discard the prompt.
109
110
 
110
111
  Paste an image on macOS, or drop a file into the prompt, and refer to it as
111
112
  `[Image #1]`. Type `/` for a menu of your skills.
@@ -53,7 +53,7 @@ module ClaudeInbox
53
53
 
54
54
  def open_confirm(kind)
55
55
  return unless require_actionable
56
- @modal = Dialog::Confirm.new(kind, @selected.key)
56
+ @modal = Dialog::Confirm.new(kind, @selected.key, label_for(@selected.key))
57
57
  end
58
58
 
59
59
  def stop_session(id)
@@ -64,11 +64,12 @@ module ClaudeInbox
64
64
  end
65
65
 
66
66
  def delete_session(id)
67
- notice("deleting #{id}…")
67
+ label = label_for(id)
68
+ notice("deleting #{label}…")
68
69
  in_background do
69
70
  @client.rm(id)
70
71
  @store.forget(id)
71
- @queue << [:notice, "deleted #{id}"]
72
+ @queue << [:notice, "deleted #{label}"]
72
73
  @poller.soon
73
74
  end
74
75
  end
@@ -38,6 +38,9 @@ module ClaudeInbox
38
38
 
39
39
  def selected_session = session_for(@selected&.key)
40
40
 
41
+ # The alias if one is set, as the row shows it, rather than the bare id.
42
+ def label_for(key) = session_for(key)&.then { |s| @store.row(s).label } || key
43
+
41
44
  def page = [Renderer.body_height(@terminal.size[1]), 1].max
42
45
 
43
46
  def move(delta)
@@ -173,7 +173,12 @@ module ClaudeInbox
173
173
  @input.typed(text).each { |name, c| handle_key(name, c) } if typing
174
174
  end
175
175
 
176
+ # Only the form gives an Option chord a meaning; anywhere else it is the
177
+ # Esc and letter it was before Input named it.
176
178
  def handle_key(name, key)
179
+ if Input.alt?(name) && !(@modal.is_a?(NewSessionForm) && NewSessionForm::SLIPS.key?(name))
180
+ return Input.unglued(key).each { |n, k| handle_key(n, k) }
181
+ end
177
182
  return handle_modal_key(name, key) if @modal
178
183
  return handle_line_key(name, key) if filter_editing?
179
184
 
@@ -116,13 +116,19 @@ module ClaudeInbox
116
116
  class Confirm < Dialog
117
117
  TITLES = {stop: " Stop ", delete: " Delete ", adopt: " Adopt "}.freeze
118
118
 
119
+ # `label` is the name the list shows; the id is what App acts on.
120
+ def initialize(kind, id, label = id)
121
+ super(kind, id)
122
+ @label = label
123
+ end
124
+
119
125
  def title = TITLES.fetch(kind)
120
126
 
121
- def lines(_width, _caret)
127
+ def lines(width, _caret)
122
128
  case kind
123
- when :stop then [" Stop session #{id}?", "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
129
+ when :stop then [ask("Stop", width), "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
124
130
  when :delete
125
- [" Delete session #{id}?", " Its worktree and conversation", " go with it.",
131
+ [ask("Delete", width), " Its worktree and conversation", " go with it.",
126
132
  "", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
127
133
  when :adopt
128
134
  [" Pull this session into the daemon?", " Its phone session ends and a",
@@ -135,6 +141,11 @@ module ClaudeInbox
135
141
  return :cancel if dismiss?(name, key) || key == "n"
136
142
  nil
137
143
  end
144
+
145
+ private
146
+
147
+ # One line, so a long name is cut rather than wrapped mid-word.
148
+ def ask(verb, width) = " #{verb} #{Text.truncate(@label.to_s, width - Text.width(verb) - 4)}?"
138
149
  end
139
150
 
140
151
  # The first launch without the Claude Code pane asks once whether to add
@@ -9,6 +9,15 @@ module ClaudeInbox
9
9
  # keys. Hides what tty-reader does with ESC, echo and a paste, so App sees
10
10
  # events and not the raw bytes.
11
11
  class Input
12
+ # macOS sends Option-s as "\es", so an Esc glued to one lowercase letter
13
+ # is taken as Option held with it.
14
+ ALT = /\A\e([a-z])\z/
15
+
16
+ def self.alt?(name) = name.to_s.start_with?("alt_")
17
+
18
+ # An Option chord as the quick Esc-then-letter it may also have been.
19
+ def self.unglued(raw) = [[:escape, "\e"], [raw[1], raw[1]]]
20
+
12
21
  def initialize(input, out)
13
22
  @input = input
14
23
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
@@ -51,7 +60,7 @@ module ClaudeInbox
51
60
  out
52
61
  end
53
62
 
54
- def key_name(key) = @reader.console.keys[key] || key
63
+ def key_name(key) = @reader.console.keys[key] || key[ALT, 1]&.then { |c| :"alt_#{c}" } || key
55
64
 
56
65
  # tty-reader glues ESC to whatever arrives within 100ms, so a fast
57
66
  # "esc gg" comes in as one unknown key "\egg". Vim hands make that
@@ -59,6 +68,7 @@ module ClaudeInbox
59
68
  def split_keys(key)
60
69
  return [key] if key.size <= 1 || @reader.console.keys.key?(key)
61
70
  return [key] unless key.start_with?("\e")
71
+ return [key] if key.match?(ALT)
62
72
  ["\e"] + key[1..].chars
63
73
  end
64
74
  end
@@ -35,6 +35,10 @@ module ClaudeInbox
35
35
  # editable field spends these on its own text instead.
36
36
  CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
37
37
 
38
+ # Option slips for Ctrl on the two submit keys. Option-s arrives as Esc
39
+ # then s, and that Esc asked to discard the prompt.
40
+ SLIPS = {alt_s: "that's option-s — ctrl-s starts it", alt_o: "that's option-o — ctrl-o starts and attaches"}.freeze
41
+
38
42
  def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
39
43
  @p = Pastel.new(enabled: true)
40
44
  @theme = Theme.new
@@ -83,6 +87,7 @@ module ClaudeInbox
83
87
  when :escape then return escape_pressed
84
88
  when :ctrl_s then return submit(attach: false)
85
89
  when :ctrl_o then return submit(attach: true)
90
+ when *SLIPS.keys then @error = SLIPS[name]
86
91
  when :ctrl_v then paste_clipboard
87
92
  when :tab then complete_dir || move(1)
88
93
  when :down then move(1)
@@ -200,11 +200,11 @@ module ClaudeInbox
200
200
  authenticated(slot) if slot
201
201
  Http.write(io, *answer(request, io, via))
202
202
  rescue Http::Error => e
203
- Http.write(io, e.status, Http::JSON_TYPE.merge(e.headers), JSON.generate(e.body))
203
+ refuse(io, e)
204
204
  rescue => e
205
205
  # A message can name a path under the home directory, which is
206
206
  # nobody's business before the token checks out.
207
- Http.write(io, 500, Http::JSON_TYPE, JSON.generate(error: authorized ? Http.utf8(e.message) : "internal error"))
207
+ refuse(io, Http::Error.new(500, authorized ? e.message : "internal error"))
208
208
  end
209
209
 
210
210
  private
@@ -336,11 +336,13 @@ module ClaudeInbox
336
336
  @unauthenticated.delete(via) if @unauthenticated[via].zero?
337
337
  end
338
338
 
339
+ def refuse(io, error) = Http.write(io, error.status, Http::JSON_TYPE.merge(error.headers), JSON.generate(error.body))
340
+
339
341
  # `handle` answers every failure it can; what is left is the socket
340
342
  # itself going away, and a thread must not report that on stderr.
341
343
  def serve(sock, slot)
342
344
  if slot[:busy]
343
- Http.write(sock, 503, Http::JSON_TYPE.merge("Retry-After" => "2"), JSON.generate(error: "busy: try again in a moment"))
345
+ refuse(sock, Http::Error.new(503, "busy: try again in a moment", headers: {"Retry-After" => "2"}))
344
346
  else
345
347
  handle(sock, via: slot[:via], slot: slot)
346
348
  end
@@ -87,7 +87,7 @@ module ClaudeInbox
87
87
  def key_lines(s)
88
88
  return [" paired phones get 401 until they pair again —", " rotate? y/n"] if @asking
89
89
  keys = (s.state == :listening) ? [%w[c copy], ["r", "new token"], %w[esc close]] : [%w[esc close]]
90
- [" " + keys.map { |k, action| "#{key(k)} #{@theme.dim(action)}" }.join(" ")]
90
+ [" " + @theme.hints(keys)]
91
91
  end
92
92
  end
93
93
  end
@@ -134,11 +134,13 @@ module ClaudeInbox
134
134
  parts = path.split("/").reject(&:empty?)
135
135
  (1..parts.size).each do |n|
136
136
  label = parts.last(n).join("/")
137
- return label if dirs.one? { |other| other.end_with?("/#{label}") }
137
+ return label if dirs_called(dirs, label).one?
138
138
  end
139
139
  path
140
140
  end
141
141
 
142
+ def self.dirs_called(dirs, label) = dirs.select { |dir| dir.end_with?("/#{label}") }
143
+
142
144
  # A path is taken as given. Anything else names one of `dirs` by its
143
145
  # `label`.
144
146
  def self.cwd_param(value, dirs)
@@ -146,7 +148,7 @@ module ClaudeInbox
146
148
  raise Invalid.new(:cwd, "a directory is required") if cwd.empty?
147
149
  raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(NOT_ONE_LINE)
148
150
  return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
149
- found = dirs.select { |dir| dir.end_with?("/#{cwd}") }
151
+ found = dirs_called(dirs, cwd)
150
152
  raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
151
153
  raise Invalid.new(:cwd, "#{cwd} could be any of #{found.join(", ")}") if found.size > 1
152
154
  found.first
@@ -174,6 +176,6 @@ module ClaudeInbox
174
176
  text
175
177
  end
176
178
 
177
- private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param
179
+ private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param, :dirs_called
178
180
  end
179
181
  end
@@ -3,17 +3,15 @@
3
3
  require "json"
4
4
 
5
5
  module ClaudeInbox
6
- # What `claude` will use when a flag is left off: read from the same
7
- # settings files it reads, most specific wins. Values are strings, or
8
- # nil when nothing sets them — then the CLI decides at launch.
6
+ # What a `claude` session in a directory gets from the settings files it
7
+ # reads, most specific wins: the defaults for flags left off, and the
8
+ # plugins it loads.
9
9
  module Settings
10
+ # Strings, or nil when nothing sets them: then the CLI decides at launch.
10
11
  Defaults = Struct.new(:model, :effort, :permission_mode, :remote)
11
12
 
12
13
  def self.defaults(cwd, home: Dir.home)
13
- user = read(File.join(home, ".claude", "settings.json"))
14
- project = read(File.join(cwd, ".claude", "settings.json"))
15
- local = read(File.join(cwd, ".claude", "settings.local.json"))
16
- layers = [user, project, local]
14
+ user, project, local = layers = layers(cwd, home)
17
15
  Defaults.new(
18
16
  model: pick(layers, "model"),
19
17
  effort: pick(layers, "effortLevel"),
@@ -22,6 +20,23 @@ module ClaudeInbox
22
20
  )
23
21
  end
24
22
 
23
+ # The "name@marketplace" keys of the plugins a session in cwd loads.
24
+ # Its install scope plays no part; see docs/cli-quirks.md.
25
+ def self.enabled_plugins(cwd, home: Dir.home)
26
+ layers(cwd, home).each_with_object({}) do |h, on|
27
+ plugins = h["enabledPlugins"]
28
+ on.merge!(plugins) if plugins.is_a?(Hash)
29
+ end.select { |_, v| v == true }.keys
30
+ end
31
+
32
+ def self.layers(cwd, home)
33
+ [
34
+ read(File.join(home, ".claude", "settings.json")),
35
+ read(File.join(cwd, ".claude", "settings.json")),
36
+ read(File.join(cwd, ".claude", "settings.local.json"))
37
+ ]
38
+ end
39
+
25
40
  # "Enable Remote Control for all sessions" in /config. The CLI lets a
26
41
  # repo turn it off but only user scope turn it on, and still honours the
27
42
  # copy older versions kept in ~/.claude.json.
@@ -47,6 +62,6 @@ module ClaudeInbox
47
62
  end
48
63
  nil
49
64
  end
50
- private_class_method :remote_control, :pick
65
+ private_class_method :layers, :remote_control, :pick
51
66
  end
52
67
  end
@@ -6,10 +6,11 @@ module ClaudeInbox
6
6
  # The slash commands `claude` would offer at its own prompt, read from the
7
7
  # same places it reads them: `.claude/commands/*.md` and
8
8
  # `.claude/skills/*/SKILL.md` under the project and the home directory,
9
- # the skills and commands of installed plugins (named `plugin:name`), and
10
- # the skills claude.ai syncs down (`anthropic-skills:name`). Built-ins
11
- # live inside the CLI and are not listed; typing one still works, since
12
- # the prompt goes to `claude --bg` verbatim.
9
+ # the skills and commands of the plugins enabled there (named
10
+ # `plugin:name`), and the skills claude.ai syncs down
11
+ # (`anthropic-skills:name`). Built-ins live inside the CLI and are not
12
+ # listed; typing one still works, since the prompt goes to `claude --bg`
13
+ # verbatim.
13
14
  #
14
15
  # Pure: filesystem in, plain values out. Nothing here runs `claude`.
15
16
  module SlashCommands
@@ -27,7 +28,7 @@ module ClaudeInbox
27
28
  [
28
29
  *from_dir(File.join(cwd, ".claude")),
29
30
  *from_dir(File.join(home, ".claude")),
30
- *plugins(home),
31
+ *plugins(cwd, home),
31
32
  *synced(home)
32
33
  ].uniq(&:name).sort_by(&:name)
33
34
  end
@@ -65,9 +66,10 @@ module ClaudeInbox
65
66
 
66
67
  # `installed_plugins.json` maps "name@marketplace" to where the plugin
67
68
  # was unpacked: one entry, or a list of them, one per install scope.
68
- def plugins(home)
69
+ def plugins(cwd, home)
69
70
  installed = Settings.read(File.join(home, ".claude", "plugins", "installed_plugins.json"))
70
- (installed["plugins"] || {}).flat_map do |key, entries|
71
+ enabled = Settings.enabled_plugins(cwd, home:)
72
+ (installed["plugins"] || {}).slice(*enabled).flat_map do |key, entries|
71
73
  plugin = key.split("@").first
72
74
  Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
73
75
  from_dir(root, prefix: plugin)
@@ -10,15 +10,27 @@ module ClaudeInbox
10
10
  def success? = status.success?
11
11
  end
12
12
 
13
+ # `asdf exec`, which the ruby shim runs us through, puts the ruby it
14
+ # picked ahead of every shim on PATH and names it in ASDF_INSTALL_PATH.
15
+ # Left in place, every session runs our ruby whatever its project's
16
+ # .tool-versions says.
17
+ def self.unpinned(env)
18
+ install = env["ASDF_INSTALL_PATH"] or return env
19
+ prefix = File.join(install, "")
20
+ path = env.fetch("PATH", "").split(File::PATH_SEPARATOR).reject { |dir| dir.start_with?(prefix) }
21
+ env.reject { |k, _| k.start_with?("ASDF_INSTALL_") }.merge("PATH" => path.join(File::PATH_SEPARATOR))
22
+ end
23
+
13
24
  # Children start in the environment from before Bundler, or every ruby a
14
- # session runs, in any project, loads our Gemfile. Handed to exec rather
15
- # than set on ENV, which the poll and background threads share.
16
- CHILD_ENV = defined?(Bundler.unbundled_env) ? Bundler.unbundled_env.freeze : nil
25
+ # session runs, in any project, loads our Gemfile, and from before asdf
26
+ # pinned our ruby. Handed to exec rather than set on ENV, which the poll
27
+ # and background threads share.
28
+ CHILD_ENV = unpinned(defined?(Bundler.unbundled_env) ? Bundler.unbundled_env : ENV.to_h).freeze
17
29
 
18
30
  module_function
19
31
 
20
32
  # argv as exec and Process.spawn take it, with CHILD_ENV in place of ours.
21
- def command(*argv) = CHILD_ENV ? [CHILD_ENV, *argv, {unsetenv_others: true}] : argv
33
+ def command(*argv) = [CHILD_ENV, *argv, {unsetenv_others: true}]
22
34
 
23
35
  # `input` is written before any output is read, so it is for the few
24
36
  # bytes a clipboard takes, not a stream.
data/lib/claude_inbox.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.12.0"
4
+ VERSION = "0.12.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-inbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron