claude-inbox 0.6.0 → 0.6.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: b325ed415502366088de4b6e9b7634835aea1fb0a6be708e211344dfeba8fa33
4
- data.tar.gz: 53fbfbeac341e7f5643724911eb5feecb4165a6aaa367dad73c2a4002be9bee9
3
+ metadata.gz: cea52c058efc847eede80d9633f507fb0bcc88352351d2e8df98d57d531cbd57
4
+ data.tar.gz: 73953113b090f01dfd646bbcb9295be96d2f6616ecccab116b9f75c2f43f69ed
5
5
  SHA512:
6
- metadata.gz: 072a5ce3e44c1e5317d105fd49aea27fc5e1365c146c165e8ad87b61b50c24f0046bd659278a41b3848ec15523c1eaa255748f5f080a4e26b25476ba6f7e1218
7
- data.tar.gz: 31715b40f83b6749d3064c323018c74f62e1176f248bcc00b3cf16769b81c202b49cb0e7228e3ca8bd3b5519c8d7ca99001c026868d29605b488430a6662f5eb
6
+ metadata.gz: '080712923012ed2b164ce10dc6e4fbbf511319b7065ee54da07ffac2a6561deb62888f73012d8b7bf1ed3a78ae2d8fae078680fd4102a962bb4a05e345346c77'
7
+ data.tar.gz: dd9a7ca33067f30d0eb8cb98742bcc229cb981acfdfa0697966d59b43be9cf4c2451a6683378f6389b3226410261d615b9c5fb711df9c1019e659863ca76796c
data/exe/claude-inbox CHANGED
@@ -2,129 +2,6 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "claude_inbox"
5
+ require "claude_inbox/cli"
5
6
 
6
- if ARGV.include?("--version")
7
- puts ClaudeInbox::VERSION
8
- exit
9
- end
10
-
11
- require "claude_inbox/mod"
12
-
13
- # The stable path to the Claude Code pane, for CLAUDE_CODE_PLUGIN_DIRS.
14
- if ARGV.include?("--mod-dir")
15
- puts ClaudeInbox::Mod.link(force: true)
16
- exit
17
- end
18
-
19
- if ARGV.first == "install"
20
- begin
21
- installed = ClaudeInbox::Mod.install(force: ARGV.include?("--force"))
22
- rescue JSON::ParserError
23
- abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
24
- end
25
- installed.removed.each { |dir| puts "Removed #{dir} from ~/.claude/settings.json." }
26
- puts "The pane now runs #{ClaudeInbox::Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
27
- puts installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
28
- "The pane is already in ~/.claude/settings.json."
29
- exit
30
- end
31
-
32
- if ARGV.first == "update"
33
- require "claude_inbox/updater"
34
- updater = ClaudeInbox::Updater.new
35
- begin
36
- update = updater.check(force: true)
37
- rescue ClaudeInbox::Updater::Error => e
38
- abort "claude-inbox: #{e.message}"
39
- end
40
- unless update
41
- puts((updater.kind == :gem) ? "claude-inbox #{ClaudeInbox::VERSION} is the latest release." : "main is up to date.")
42
- exit
43
- end
44
- puts update.installing
45
- applied = updater.apply
46
- abort "claude-inbox: #{applied.output.strip}" unless applied.success?
47
- puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
48
- exit
49
- end
50
-
51
- require "claude_inbox/app"
52
- require "claude_inbox/config"
53
- require "claude_inbox/updater"
54
- require "claude_inbox/writer_lock"
55
- require "tmpdir"
56
-
57
- fixture = ARGV.include?("--fixture")
58
- # A fixture run takes the listen flags, for trying the API with curl, but
59
- # not the environment or the config file, which are for the real inbox.
60
- begin
61
- argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
62
- listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
63
- rescue ArgumentError => e
64
- abort "claude-inbox: #{e.message}"
65
- end
66
-
67
- # Crash traces go here instead of the alt screen when set.
68
- $stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
69
-
70
- opts = {}
71
- if fixture
72
- agents = argv[argv.index("--fixture") + 1]
73
- fixtures = File.dirname(agents)
74
- opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
75
- opts[:store] = ClaudeInbox::Store.new(path: nil)
76
- opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
77
- # Its own token, lock and images, so a test inbox can neither rotate the
78
- # token phones are paired with nor hold the real inbox's lock.
79
- dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
80
- opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
81
- lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
82
- else
83
- # The only place the reaper is armed, and only with --reap: deleting
84
- # sessions is opt-in. Everything else — tests, --fixture, anything
85
- # embedding App — gets one that deletes nothing. The listener, likewise,
86
- # comes only from this file, and only when asked for.
87
- opts[:client] = ClaudeInbox::AgentsClient.new
88
- opts[:store] = ClaudeInbox::Store.new
89
- opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
90
- opts[:snapshot] = ClaudeInbox::Snapshot.new
91
- opts[:switch] = ClaudeInbox::Switch.new
92
- opts[:actions] = ClaudeInbox::Actions.new
93
- opts[:pane_offer] = ClaudeInbox::Mod::Offer.new
94
- opts[:listen] = listen
95
- opts[:updater] = ClaudeInbox::Updater.new unless argv.include?("--no-update-check")
96
- # The typed arguments, not the config's: a relaunch reads the config again.
97
- opts[:argv] = ARGV
98
- end
99
- opts[:color] = false if argv.include?("--no-color")
100
-
101
- if argv.include?("--headless")
102
- abort "claude-inbox: --headless takes no --fixture" if fixture
103
- require "claude_inbox/headless"
104
- exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store], updater: opts[:updater]).run ? 0 : 1)
105
- end
106
-
107
- # Only what a check within the day already found: a cold launch never waits
108
- # on the network. The relaunch is the new version's launch; a failure leaves
109
- # this one to start as usual.
110
- if argv.include?("--auto-update") && (update = opts[:updater]&.cached)
111
- puts update.installing
112
- applied = opts[:updater].apply
113
- applied.success? ? opts[:updater].relaunch(ARGV) : warn("claude-inbox: #{applied.output.strip}")
114
- end
115
-
116
- # The first inbox you can see is the snapshot's writer: a headless one gives
117
- # way to it, and a second inbox reads along, writing and deleting nothing.
118
- # Held for the life of this process by this reference.
119
- writer_lock = fixture ? ClaudeInbox::WriterLock.disabled : ClaudeInbox::WriterLock.new
120
- unless writer_lock.take(role: "inbox")
121
- opts[:store] = ClaudeInbox::Store.new(writable: false, relay: ClaudeInbox::Actions.new)
122
- opts[:reaper] = ClaudeInbox::Reaper.disabled
123
- opts[:snapshot] = ClaudeInbox::Snapshot.disabled
124
- opts[:actions] = ClaudeInbox::Actions.disabled
125
- opts[:pane_offer] = ClaudeInbox::Mod::Offer.disabled
126
- end
127
- # So the pane settings.json names is the installed version's, after a gem update too.
128
- ClaudeInbox::Mod.link unless fixture
129
-
130
- ClaudeInbox::App.new(**opts).run
7
+ exit ClaudeInbox::CLI.new(ARGV).run
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "tmpdir"
5
+ require_relative "app"
6
+ require_relative "config"
7
+ require_relative "headless"
8
+ require_relative "mod"
9
+ require_relative "updater"
10
+ require_relative "writer_lock"
11
+
12
+ module ClaudeInbox
13
+ # What `claude-inbox` does with its arguments: install the pane, update
14
+ # claude-inbox, or launch the inbox. `run` is true when it succeeded.
15
+ class CLI
16
+ USAGE = <<~USAGE
17
+ Usage: claude-inbox [options]
18
+ claude-inbox install [--force]
19
+ claude-inbox update
20
+ USAGE
21
+
22
+ # `lock` stands in for the writer lock a live launch takes.
23
+ def initialize(argv, env: ENV, config: Config::PATH, lock: nil, out: $stdout)
24
+ @argv = argv
25
+ @env = env
26
+ @config = config
27
+ @lock = lock
28
+ @out = out
29
+ end
30
+
31
+ def run
32
+ case @argv.first
33
+ when "install" then install
34
+ when "update" then update
35
+ else launch
36
+ end
37
+ end
38
+
39
+ # The launch flags, the config file's before the typed ones. A fixture
40
+ # run takes the listen flags, for trying the API with curl, but not the
41
+ # environment or the config file, which are for the real inbox.
42
+ def options
43
+ @options ||= begin
44
+ typed = parse(@argv)
45
+ options = typed[:fixture] ? typed : parse(Config.argv(@argv, path: @config))
46
+ env = typed[:fixture] ? {} : @env
47
+ options.merge(listen: Remote::Listener.options(env, flag: options[:listen], modes: options[:allow_modes]))
48
+ end
49
+ rescue OptionParser::ParseError, ArgumentError => e
50
+ abort "claude-inbox: #{e.message}"
51
+ end
52
+
53
+ # The keywords App is built with. A live launch takes the writer lock
54
+ # here: the first inbox you can see is the snapshot's writer, and a
55
+ # second one reads along, writing and deleting nothing.
56
+ def app_options
57
+ opts = options[:fixture] ? fixture_options : live_options
58
+ opts[:color] = options[:color]
59
+ return opts if options[:fixture] || writer_lock.take(role: "inbox")
60
+ opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
61
+ snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled)
62
+ end
63
+
64
+ private
65
+
66
+ def install
67
+ force = false
68
+ subcommand("install") { |p| p.on("--force", "Replace any other copy of the pane") { force = true } }
69
+ begin
70
+ installed = Mod.install(force: force)
71
+ rescue JSON::ParserError
72
+ abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
73
+ end
74
+ installed.removed.each { |dir| @out.puts "Removed #{dir} from ~/.claude/settings.json." }
75
+ @out.puts "The pane now runs #{Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
76
+ @out.puts(installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
77
+ "The pane is already in ~/.claude/settings.json.")
78
+ true
79
+ end
80
+
81
+ def update
82
+ subcommand("update")
83
+ updater = Updater.new
84
+ begin
85
+ update = updater.check(force: true)
86
+ rescue Updater::Error => e
87
+ abort "claude-inbox: #{e.message}"
88
+ end
89
+ unless update
90
+ @out.puts((updater.kind == :gem) ? "claude-inbox #{VERSION} is the latest release." : "main is up to date.")
91
+ return true
92
+ end
93
+ @out.puts update.installing
94
+ applied = updater.apply
95
+ abort "claude-inbox: #{applied.output.strip}" unless applied.success?
96
+ @out.puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
97
+ true
98
+ end
99
+
100
+ def launch
101
+ options
102
+ # Crash traces go here instead of the alt screen when set.
103
+ $stderr.reopen(@env["CLAUDE_INBOX_STDERR"], "a") if @env["CLAUDE_INBOX_STDERR"]
104
+ return headless if options[:headless]
105
+ auto_update if options[:auto_update]
106
+ # So the pane settings.json names is the installed version's, after a gem update too.
107
+ Mod.link unless options[:fixture]
108
+ App.new(**app_options).run
109
+ true
110
+ end
111
+
112
+ def headless
113
+ abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
114
+ Headless.new(client: AgentsClient.new, store: Store.new, updater: updater).run
115
+ end
116
+
117
+ # Only what a check within the day already found: a cold launch never waits
118
+ # on the network. The relaunch is the new version's launch; a failure leaves
119
+ # this one to start as usual.
120
+ def auto_update
121
+ return unless (update = updater&.cached)
122
+ @out.puts update.installing
123
+ applied = updater.apply
124
+ applied.success? ? updater.relaunch(@argv) : warn("claude-inbox: #{applied.output.strip}")
125
+ end
126
+
127
+ def fixture_options
128
+ fixtures = File.dirname(options[:fixture])
129
+ # Its own token, lock and images, so a test inbox can neither rotate the
130
+ # token phones are paired with nor hold the real inbox's lock.
131
+ dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
132
+ {client: FixtureClient.new(options[:fixture], logs: File.binread(File.join(fixtures, "logs_raw.txt"))),
133
+ store: Store.new(path: nil),
134
+ pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
135
+ listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
136
+ lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
137
+ end
138
+
139
+ # The only place the reaper is armed, and only with --reap: deleting
140
+ # sessions is opt-in. Everything else — tests, --fixture, anything
141
+ # embedding App — gets one that deletes nothing. The listener, likewise,
142
+ # comes only from here, and only when asked for.
143
+ def live_options
144
+ client = AgentsClient.new
145
+ store = Store.new
146
+ {client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
147
+ snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
148
+ listen: options[:listen], updater: updater,
149
+ # The typed arguments, not the config's: a relaunch reads the config again.
150
+ argv: @argv}
151
+ end
152
+
153
+ def updater
154
+ @updater ||= Updater.new if options[:update_check] && !options[:fixture]
155
+ end
156
+
157
+ # Held for the life of this process by this reference.
158
+ def writer_lock = (@lock ||= WriterLock.new)
159
+
160
+ def parse(argv)
161
+ opts = {color: true, update_check: true}
162
+ parser = OptionParser.new(USAGE) do |p|
163
+ p.on("--version", "Print the version") {
164
+ @out.puts VERSION
165
+ exit
166
+ }
167
+ p.on("--mod-dir", "Print the pane's directory, for CLAUDE_CODE_PLUGIN_DIRS") {
168
+ @out.puts Mod.link(force: true)
169
+ exit
170
+ }
171
+ p.on("--fixture PATH", "Read sessions from an agents.json instead of the daemon") { |path| opts[:fixture] = path }
172
+ p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
173
+ p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
174
+ p.on("--no-color", "Draw without color") { opts[:color] = false }
175
+ p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
176
+ p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
177
+ # --listen-lan wins over --listen, whichever came last.
178
+ p.on("--listen[=PORT]", "Let a phone on this Mac start sessions") { |port| opts[:listen] = {lan: false, port: port} unless opts.dig(:listen, :lan) }
179
+ p.on("--listen-lan[=PORT]", "Let a phone on your network start sessions") { |port| opts[:listen] = {lan: true, port: port} }
180
+ p.on("--listen-allow-modes=MODES", "The permission modes a phone may start in") { |modes| opts[:allow_modes] = modes }
181
+ end
182
+ strictly(parser, argv)
183
+ opts
184
+ end
185
+
186
+ def subcommand(name)
187
+ parser = OptionParser.new(USAGE)
188
+ yield parser if block_given?
189
+ strictly(parser, @argv.drop(1))
190
+ rescue OptionParser::ParseError => e
191
+ abort "claude-inbox #{name}: #{e.message}"
192
+ end
193
+
194
+ # Exact names only, so a typo is an error rather than the flag it abbreviates.
195
+ def strictly(parser, argv)
196
+ parser.require_exact = true
197
+ rest = parser.parse(argv)
198
+ raise OptionParser::InvalidArgument, rest.first if rest.any?
199
+ end
200
+ end
201
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
 
5
6
  module ClaudeInbox
6
7
  # What the daemon writes about a background session in
@@ -16,7 +17,7 @@ module ClaudeInbox
16
17
  # `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
17
18
  # transcript, which PullRequests reads through here, the color `/color`
18
19
  # set on the session, which `claude agents --json` drops, and `intent`, the
19
- # prompt the session was started from.
20
+ # prompt the session was started from, and `createdAt`, when it was.
20
21
  #
21
22
  # Never cached. A color changes the moment you type `/color`, so every poll
22
23
  # asks the file again.
@@ -64,7 +65,7 @@ module ClaudeInbox
64
65
  nil
65
66
  end
66
67
 
67
- attr_reader :pr_urls, :color, :intent, :bridge_id
68
+ attr_reader :pr_urls, :color, :intent, :bridge_id, :created_at
68
69
 
69
70
  def initialize(hash)
70
71
  @detail = hash["detail"]
@@ -77,6 +78,7 @@ module ClaudeInbox
77
78
  @color = hash["color"]
78
79
  @intent = hash["intent"]
79
80
  @bridge_id = hash["bridgeSessionId"]
81
+ @created_at = time(hash["createdAt"])
80
82
  @flags = hash["respawnFlags"] || []
81
83
  end
82
84
 
@@ -122,5 +124,11 @@ module ClaudeInbox
122
124
  def count_label = "#{tasks} #{plural("task", tasks)}"
123
125
 
124
126
  def plural(word, n) = (n == 1) ? word : "#{word}s"
127
+
128
+ def time(iso)
129
+ iso && Time.iso8601(iso)
130
+ rescue ArgumentError
131
+ nil
132
+ end
125
133
  end
126
134
  end
@@ -67,13 +67,12 @@ module ClaudeInbox
67
67
  Outcome = Data.define(:at, :via, :result, :count)
68
68
 
69
69
  # App's `listen:` from the flags, else the environment; nil when neither
70
- # asks for it. --listen-lan wins over --listen, whichever came last.
71
- def self.options(argv, env)
72
- flags = argv.filter_map { |arg| arg.match(/\A--listen(-lan)?(?:=(.*))?\z/) }
70
+ # asks for it. `flag` is the listen flag's `lan:` and `port:`, and
71
+ # `modes` --listen-allow-modes, both as typed.
72
+ def self.options(env, flag: nil, modes: nil)
73
73
  lan, port =
74
- if flags.any?
75
- flag = flags.reverse.find { |m| m[1] } || flags.last
76
- [!flag[1].nil?, flag[2]]
74
+ if flag
75
+ flag.values_at(:lan, :port)
77
76
  elsif !(value = env["CLAUDE_INBOX_LISTEN"].to_s.strip).empty?
78
77
  m = value.match(/\A(?:(lan)(?::(.*))?|(\d+))\z/i)
79
78
  raise ArgumentError, "CLAUDE_INBOX_LISTEN is a port, lan or lan:PORT, not #{value}" unless m
@@ -81,7 +80,7 @@ module ClaudeInbox
81
80
  else
82
81
  return nil
83
82
  end
84
- modes = argv.filter_map { |arg| arg[/\A--listen-allow-modes=(.*)\z/, 1] }.last || env["CLAUDE_INBOX_LISTEN_ALLOW_MODES"]
83
+ modes ||= env["CLAUDE_INBOX_LISTEN_ALLOW_MODES"]
85
84
  {port: port_option(port), lan: lan, allowed_modes: modes_option(modes)}
86
85
  end
87
86
 
@@ -78,6 +78,10 @@ module ClaudeInbox
78
78
  # From `/color`; interactive sessions have no job file, so never one.
79
79
  def color = job_state&.color
80
80
 
81
+ # A respawn moves `started_at` to the new process; the job file keeps
82
+ # when the session itself began.
83
+ def created_at = job_state&.created_at || started_at
84
+
81
85
  # The prompt this session was started with.
82
86
  def intent = job_state&.intent
83
87
 
@@ -71,7 +71,7 @@ module ClaudeInbox
71
71
  def settled?
72
72
  return true if hand_settled?
73
73
  return false if UNSETTLEABLE.include?(session.effective_state)
74
- started = session.started_at.to_i
74
+ started = session.created_at.to_i
75
75
  ours = session.prs.select(&:known?).reject { |pr| pr.resolved_before?(started) }
76
76
  ours.any? && ours.all? { |pr| pr.resolved_since?(started) }
77
77
  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.6.0"
4
+ VERSION = "0.6.1"
5
5
  end
@@ -197,14 +197,20 @@ async function requestSwitch($: EngineInterface, host: Host, id: string): Promis
197
197
  })
198
198
  }
199
199
 
200
- // The pane keeps the keys while its session is away, so coming back to it would put the
201
- // person's typing in the pane. The engine has no blur: reopening without `focus` is the
202
- // one way to give the keys to the prompt.
200
+ // Every key that takes the person out of the pane, to another session or the browser, calls
201
+ // this first: the pane would keep the keys while they are away, and their typing on coming
202
+ // back would land in it. The engine has no blur: reopening without `focus` is the one way
203
+ // to give the keys to the prompt.
203
204
  async function handBackKeys($: EngineInterface): Promise<void> {
204
205
  await $.ui.close({ id: PANE })
205
206
  await $.ui.open(OPEN)
206
207
  }
207
208
 
209
+ async function openInBrowser($: EngineInterface, url: string): Promise<void> {
210
+ await handBackKeys($)
211
+ await $.process.run(['open', url])
212
+ }
213
+
208
214
  // Only while no inbox writes the snapshot; the gem's writer lock turns a second one away.
209
215
  function startHeadless($: EngineInterface, host: Host, now: number): void {
210
216
  if (host.isHeadlessRunning || now - host.headlessStartedAt < RESTART_S) return
@@ -410,8 +416,8 @@ export const register: Register = (on, options) => {
410
416
  $.ui.toast(copied.isCopied ? `copied: claude attach ${row.id}` : 'no clipboard here'),
411
417
  )
412
418
  }),
413
- key('o', () => (row?.pr ? void $.process.run(['open', row.pr.url]) : $.ui.toast('no pull request linked'))),
414
- key('w', () => (row?.remote ? void $.process.run(['open', row.remote]) : $.ui.toast('no claude.ai/code page for this session'))),
419
+ key('o', () => (row?.pr ? void openInBrowser($, row.pr.url) : $.ui.toast('no pull request linked'))),
420
+ key('w', () => (row?.remote ? void openInBrowser($, row.remote) : $.ui.toast('no claude.ai/code page for this session'))),
415
421
  key('q', () => void $.ui.close({ id: PANE })),
416
422
  ]
417
423
 
@@ -220,6 +220,38 @@ test('a switch hands the keys back to the prompt before it asks, so a return lan
220
220
  await ui.unmount()
221
221
  })
222
222
 
223
+ test('o and w hand the keys back to the prompt before they open the browser', async ($, on) => {
224
+ mock.env(on, { HOME: '/Users/me' })
225
+ mock.clock(on, { now: 1_700_000_000_000 })
226
+ const remote = { ...ROWS[1], remote: 'https://claude.ai/code/session_b2' }
227
+ on('state.get', ($, e, next) => (e.key === 'rows' ? seeded([remote]) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
228
+ const calls: string[] = []
229
+ on('ui.close', ($, e) => {
230
+ calls.push(`close ${e.id}`)
231
+ return { value: undefined }
232
+ })
233
+ on('ui.open', ($, e) => {
234
+ calls.push(`open ${e.id}${e.focus ? ' focused' : ''}`)
235
+ return { value: { isPlaced: true } }
236
+ })
237
+ on('process.run', ($, e) => {
238
+ calls.push(e.argv.join(' '))
239
+ return { value: { code: 0, stdout: '', stderr: '' } }
240
+ })
241
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
242
+ await ui.press({ key: 'o' })
243
+ await ui.press({ key: 'w' })
244
+ expect(calls).toEqual([
245
+ 'close inbox',
246
+ 'open inbox',
247
+ 'open https://github.com/o/r/pull/7',
248
+ 'close inbox',
249
+ 'open inbox',
250
+ 'open https://claude.ai/code/session_b2',
251
+ ])
252
+ await ui.unmount()
253
+ })
254
+
223
255
  test('a click on a row puts the cursor on it before it asks for the switch', async ($, on) => {
224
256
  mock.env(on, { HOME: '/Users/me' })
225
257
  mock.clock(on, { now: 1_700_000_000_000 })
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.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron
@@ -108,6 +108,7 @@ files:
108
108
  - lib/claude_inbox/actions.rb
109
109
  - lib/claude_inbox/agents_client.rb
110
110
  - lib/claude_inbox/app.rb
111
+ - lib/claude_inbox/cli.rb
111
112
  - lib/claude_inbox/config.rb
112
113
  - lib/claude_inbox/debug.rb
113
114
  - lib/claude_inbox/dialog.rb