claude-inbox 0.12.0 → 0.12.1

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: 58a50d40ba0f8df6b8b53f3613a75b5982871eaa3c6f9d0c29c46411a1e70323
4
+ data.tar.gz: f8abcc49a7a5d61d339189f79c0f7a62d800b98531ce1e82060efda0a91e0d2e
5
5
  SHA512:
6
- metadata.gz: a4c6efddf42c8363e1f383941f854ec2ba35e38f2cfe687749aa4b18b88efe1fbb400cdcb2e3e727493daabda7c7b82753d637078b397ca7f8992305d6bb3df6
7
- data.tar.gz: 7ddfe74e86730f795581cc454fc4c09a0b7f8b1b8f49c45e7e9cbabb504387c7411902177eb84b8578d36653b421dc0794afb01f6ae55a848fa2a657073b56e0
6
+ metadata.gz: 1c994b3434e3e45fbf60f6c472227fb97610ae8355bb0dd166fb49c83713025fc8be70d2f06660e4475aa0606604a6860ce063c2d721fb836c048e7f18393b86
7
+ data.tar.gz: 591b90b883b64fc4494d8dec115958c618cf88ad13fd5cd3e9a125cbba6364df80b7c370d6d49c6db97bb74e65905882ede46bf9fdcab4b2201d39bc1e582d19
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
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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron