claude-inbox 0.6.0 → 0.7.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.
@@ -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]),
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
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-reader"
4
+ require_relative "mouse"
5
+ require_relative "paste"
6
+
7
+ module ClaudeInbox
8
+ # The tty's keypress stream decoded into pastes, mouse reports and named
9
+ # keys. Hides what tty-reader does with ESC, echo and a paste, so App sees
10
+ # events and not the raw bytes.
11
+ class Input
12
+ def initialize(input, out)
13
+ @input = input
14
+ @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
15
+ @paste = Paste.new
16
+ end
17
+
18
+ def pasting? = @paste.pasting?
19
+
20
+ # The next raw read, or nil when nothing has arrived.
21
+ def read
22
+ pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)
23
+ end
24
+
25
+ # `[:paste, text]`, `[:mouse, event]` and `[:key, name, raw]`, in the
26
+ # order they arrived; `name` is the reader's symbol for a key it knows,
27
+ # else the raw string.
28
+ def events(raw)
29
+ @paste.feed(raw).flat_map do |kind, text|
30
+ next [[:paste, text]] if kind == :paste
31
+ mouse = Mouse.events(text)
32
+ next mouse.map { |e| [:mouse, e] } if mouse.any?
33
+ split_keys(text).map { |k| [:key, key_name(k), k] }
34
+ end
35
+ end
36
+
37
+ # A paste typed out: `[name, raw]` per character.
38
+ def typed(text) = text.each_char.map { |c| [key_name(c), c] }
39
+
40
+ private
41
+
42
+ # As much of an open paste as has arrived, read straight off the tty:
43
+ # the reader switches echo off and back on around every character, and
44
+ # on a long paste that is most of the wait.
45
+ def read_pasted
46
+ return nil unless @input.wait_readable(0.1)
47
+ out = +""
48
+ while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
49
+ out << c
50
+ end
51
+ out
52
+ end
53
+
54
+ def key_name(key) = @reader.console.keys[key] || key
55
+
56
+ # tty-reader glues ESC to whatever arrives within 100ms, so a fast
57
+ # "esc gg" comes in as one unknown key "\egg". Vim hands make that
58
+ # constantly. Unknown ESC-prefixed strings become ESC + the rest.
59
+ def split_keys(key)
60
+ return [key] if key.size <= 1 || @reader.console.keys.key?(key)
61
+ return [key] unless key.start_with?("\e")
62
+ ["\e"] + key[1..].chars
63
+ end
64
+ end
65
+ 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
@@ -19,15 +19,13 @@ module ClaudeInbox
19
19
  "G" => :bottom,
20
20
  :ctrl_d => :half_page_down, :ctrl_u => :half_page_up,
21
21
  :ctrl_f => :page_down, :ctrl_b => :page_up,
22
- :ctrl_e => :peek_down, :ctrl_y => :peek_up,
23
- "J" => :peek_down, "K" => :peek_up,
24
22
  # actions
25
23
  :return => :activate, :enter => :activate, "l" => :activate,
26
- "h" => :collapse,
24
+ "h" => :fold_close,
27
25
  "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
28
26
  :ctrl_x => :delete,
29
27
  "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
30
- "R" => :refresh, "U" => :update, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
28
+ "R" => :refresh, "U" => :update, "n" => :new_session, "N" => :remote_pairing,
31
29
  :tab => :next_section, :back_tab => :prev_section,
32
30
  "/" => :filter, :escape => :escape,
33
31
  "q" => :quit, :ctrl_c => :quit
@@ -41,9 +41,9 @@ module ClaudeInbox
41
41
  # rather than rewrite a settings file it cannot read.
42
42
  def self.install(settings: SETTINGS, link: LINK, dir: DIR, force: false, command: COMMAND)
43
43
  self.link(link: link, dir: dir, force: true)
44
- json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
44
+ json = read_settings(settings)
45
45
  env = json["env"] ||= {}
46
- dirs = env["CLAUDE_CODE_PLUGIN_DIRS"].to_s.split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
46
+ dirs = plugin_dirs(env["CLAUDE_CODE_PLUGIN_DIRS"])
47
47
  kept = dirs.reject { |d| force && d != link && plugin?(d) }
48
48
  wanted = kept.include?(link) ? kept : [*kept, link]
49
49
  options = json.dig("pluginConfigs", NAME, "options") || {}
@@ -66,8 +66,7 @@ module ClaudeInbox
66
66
  # worktree's included. A settings file it cannot read counts as yes, since
67
67
  # install would refuse to touch it.
68
68
  def self.installed?(settings: SETTINGS)
69
- json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
70
- json.dig("env", "CLAUDE_CODE_PLUGIN_DIRS").to_s.split(File::PATH_SEPARATOR).any? { |d| plugin?(d.strip) }
69
+ plugin_dirs(read_settings(settings).dig("env", "CLAUDE_CODE_PLUGIN_DIRS")).any? { |d| plugin?(d) }
71
70
  rescue JSON::ParserError, Errno::EACCES
72
71
  true
73
72
  end
@@ -101,6 +100,10 @@ module ClaudeInbox
101
100
  rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
102
101
  false
103
102
  end
104
- private_class_method :plugin?
103
+
104
+ def self.read_settings(path) = File.exist?(path) ? JSON.parse(File.read(path)) : {}
105
+
106
+ def self.plugin_dirs(value) = value.to_s.split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
107
+ private_class_method :plugin?, :read_settings, :plugin_dirs
105
108
  end
106
109
  end
@@ -11,7 +11,7 @@ module ClaudeInbox
11
11
  Event = Struct.new(:kind, :row, :col)
12
12
 
13
13
  # A raw keypress can glue several reports together the same way a fast
14
- # "esc gg" glues onto one read (see App#split_keys), so this scans
14
+ # "esc gg" glues onto one read (see Input#split_keys), so this scans
15
15
  # rather than matching once.
16
16
  def self.events(raw)
17
17
  raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
@@ -57,11 +57,10 @@ module ClaudeInbox
57
57
  class PullRequests
58
58
  REFRESH_AFTER = 60
59
59
 
60
- CLAUDE_DIR = File.join(Dir.home, ".claude")
60
+ CACHE_PATH = File.join(Dir.home, ".claude", "gh-pr-status-cache.json")
61
61
  RESOLVED_PATH = File.join(Dir.home, ".config", "claude-inbox", "prs.json")
62
62
 
63
- def initialize(cache_path: File.join(CLAUDE_DIR, "gh-pr-status-cache.json"), resolved_path: RESOLVED_PATH,
64
- gh: "gh", clock: -> { Time.now })
63
+ def initialize(cache_path: CACHE_PATH, resolved_path: RESOLVED_PATH, gh: "gh", clock: -> { Time.now })
65
64
  @cache_path = cache_path
66
65
  @resolved_path = resolved_path
67
66
  @gh = gh
@@ -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
 
@@ -11,18 +11,17 @@ module ClaudeInbox
11
11
  # `width` columns wide) plus a parallel Array of selectable items.
12
12
  # Pure: no terminal, no IO, no clock beyond the `now` it is handed.
13
13
  class Renderer
14
- Frame = Struct.new(:lines, :items, :top, :list_width)
14
+ Frame = Struct.new(:lines, :items, :top)
15
15
 
16
16
  CHROME_ROWS = 2 # header + footer
17
- MIN_LIST_WIDTH = 44 # with the peek open, the list keeps at least this many columns
18
17
 
19
18
  def self.body_height(height) = height - CHROME_ROWS
20
19
 
21
- # What App hands Renderer for one frame. `peek` is a Peek::View and
22
- # `listening` a Remote::Listener::Snapshot.
23
- View = Data.define(:width, :height, :now, :selected, :top, :expanded, :peek, :modal, :screen,
20
+ # What App hands Renderer for one frame. `listening` is a
21
+ # Remote::Listener::Snapshot.
22
+ View = Data.define(:width, :height, :now, :selected, :top, :expanded, :modal, :screen,
24
23
  :status, :usage, :filter, :filter_editing, :tick, :loading, :listening) do
25
- def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {}, peek: nil, modal: nil, screen: nil,
24
+ def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {}, modal: nil, screen: nil,
26
25
  status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil, listening: nil) = super
27
26
  end
28
27
 
@@ -60,7 +59,7 @@ module ClaudeInbox
60
59
 
61
60
  KEYS = [
62
61
  ["j/k", "move"], ["⏎", "attach"], ["n", "new"], ["t", "pin"], ["s", "snooze"], ["u", "wake"],
63
- ["a", "alias"], ["o", "PR"], ["x", "settle"], ["p", "peek"], ["⇥", "section"],
62
+ ["a", "alias"], ["o", "PR"], ["x", "settle"], ["⇥", "section"],
64
63
  ["za", "fold"], ["/", "filter"], ["q", "quit"]
65
64
  ].freeze
66
65
 
@@ -76,13 +75,12 @@ module ClaudeInbox
76
75
  def frame(sections, view)
77
76
  return full_screen(sections, view) if view.screen
78
77
  width, height, selected = view.width, view.height, view.selected
79
- list_w = width_for_list(width, view.peek)
80
78
  view_h = Renderer.body_height(height)
81
79
  body, items =
82
80
  if view.loading
83
- [loading_state(list_w, view_h, view.loading, view.tick), []]
81
+ [loading_state(width, view_h, view.loading, view.tick), []]
84
82
  else
85
- body_lines(sections, list_w, view)
83
+ body_lines(sections, width, view)
86
84
  end
87
85
 
88
86
  top = clamp_top(view.top, body.size, view_h, items, selected)
@@ -91,26 +89,13 @@ module ClaudeInbox
91
89
  visible += [""] * (view_h - visible.size)
92
90
  visible_items += [nil] * (view_h - visible_items.size)
93
91
 
94
- if view.peek
95
- peek_w = width - list_w - 1
96
- peek_lines = peek_pane(view.peek, peek_w, view_h)
97
- visible = visible.each_with_index.map do |l, i|
98
- Text.pad(l, list_w) + @p.dim("│") + Text.pad(peek_lines[i] || "", peek_w)
99
- end
100
- end
101
-
102
92
  lines = [header(sections, width, view)] + visible.map { |l| Text.pad(l, width) } + [footer(width, view)]
103
93
  lines = overlay(lines, view.modal, width) if view.modal
104
- Frame.new(lines, [nil] + visible_items + [nil], top, list_w)
94
+ Frame.new(lines, [nil] + visible_items + [nil], top)
105
95
  end
106
96
 
107
97
  private
108
98
 
109
- def width_for_list(width, peek)
110
- return width unless peek
111
- [(width * 0.4).floor, MIN_LIST_WIDTH].max.clamp(0, width)
112
- end
113
-
114
99
  def clamp_top(top, size, view_h, items, selected)
115
100
  idx = items.index { |item| item && item.key == selected }
116
101
  top = idx - 2 if idx && idx - 2 < top # keep the section title in view
@@ -124,7 +109,7 @@ module ClaudeInbox
124
109
  body = view.screen[:lines].first(view_h)
125
110
  body += [""] * (view_h - body.size)
126
111
  lines = [header(sections, width, view)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + view.screen[:footer], width)]
127
- Frame.new(lines, [nil] * view.height, view.top, width)
112
+ Frame.new(lines, [nil] * view.height, view.top)
128
113
  end
129
114
 
130
115
  # ----- chrome -------------------------------------------------------------
@@ -242,7 +227,7 @@ module ClaudeInbox
242
227
  return [empty_state(width), []]
243
228
  end
244
229
  selected = view.selected
245
- sections.each_section do |name, rows|
230
+ sections.each_pair do |name, rows|
246
231
  next if rows.empty?
247
232
  lines << "" << section_title(name, rows.size, width)
248
233
  items << nil << nil
@@ -391,7 +376,7 @@ module ClaudeInbox
391
376
  pr ? base + @p.dim(" · ") + pr : base
392
377
  end
393
378
 
394
- # "#885 open". Only the first PR is shown; the peek subtitle lists them all.
379
+ # "#885 open". Only the first PR is shown.
395
380
  def pr_badge(s, section)
396
381
  pr = s.pr
397
382
  return nil unless pr
@@ -427,18 +412,6 @@ module ClaudeInbox
427
412
  path.start_with?(@home) ? path.sub(@home, "~") : path
428
413
  end
429
414
 
430
- # ----- peek ---------------------------------------------------------------
431
-
432
- def peek_pane(peek, width, height)
433
- bar = Text.pad(" " + (peek.title || ""), width)
434
- out = [@p.inverse(bar)]
435
- out << Text.pad(" " + @p.dim(peek.subtitle.to_s), width) if peek.subtitle
436
- body_h = height - out.size
437
- wrapped = peek.lines.flat_map { |l| Text.wrap(l, width - 1) }
438
- wrapped.last(body_h).each { |l| out << Text.pad(" " + l, width) }
439
- out
440
- end
441
-
442
415
  # ----- modal --------------------------------------------------------------
443
416
 
444
417
  # Centre a block of lines over the frame.
@@ -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
 
@@ -24,13 +24,12 @@ module ClaudeInbox
24
24
  # The project wins over the home directory when both define the same
25
25
  # name, as it does in the CLI.
26
26
  def list(cwd:, home:)
27
- found = {}
28
- add = ->(cmd) { found[cmd.name] ||= cmd }
29
- from_dir(File.join(cwd, ".claude")).each(&add)
30
- from_dir(File.join(home, ".claude")).each(&add)
31
- plugins(home).each(&add)
32
- synced(home).each(&add)
33
- found.values.sort_by(&:name)
27
+ [
28
+ *from_dir(File.join(cwd, ".claude")),
29
+ *from_dir(File.join(home, ".claude")),
30
+ *plugins(home),
31
+ *synced(home)
32
+ ].uniq(&:name).sort_by(&:name)
34
33
  end
35
34
 
36
35
  def match(commands, query)
@@ -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
@@ -4,8 +4,6 @@ module ClaudeInbox
4
4
  class Store
5
5
  # The Rows of one poll, grouped and ordered, and where the cursor may land among them.
6
6
  Sections = Struct.new(:pinned, :needs_you, :active, :snoozed, :settled) do
7
- def each_section(&) = each_pair(&)
8
-
9
7
  def all = values.flatten(1)
10
8
 
11
9
  def row(selection)
@@ -20,7 +18,7 @@ module ClaudeInbox
20
18
  def section_of(selection)
21
19
  return nil if selection.nil?
22
20
  return selection.key if selection.fold?
23
- each_section { |name, rows| return name if rows.any? { |r| r.key == selection.key } }
21
+ each_pair { |name, rows| return name if rows.any? { |r| r.key == selection.key } }
24
22
  nil
25
23
  end
26
24
 
@@ -35,8 +33,7 @@ module ClaudeInbox
35
33
 
36
34
  # An empty fold is left out, since there is nothing under it to open.
37
35
  def stops(expanded)
38
- members.filter_map do |name|
39
- rows = self[name]
36
+ each_pair.filter_map do |name, rows|
40
37
  if !Store.folded?(name, expanded) then [name, rows.select(&:selectable?).map { |r| Selection.row(r.key) }]
41
38
  elsif rows.any? then [name, [Selection.fold(name)]]
42
39
  end
@@ -116,7 +116,7 @@ module ClaudeInbox
116
116
  def pr_for(id) = @mutex.synchronize { @entries[id] && Entry.new(@entries[id]).pr }
117
117
 
118
118
  def pr_overrides
119
- @mutex.synchronize { @entries.transform_values { |e| Entry.new(e).pr }.select { |_, url| url } }
119
+ @mutex.synchronize { @entries.transform_values { |e| Entry.new(e).pr }.compact }
120
120
  end
121
121
 
122
122
  # A copy, not the live entry: the reaper decides a deletion on this row
@@ -4,12 +4,10 @@ require "strscan"
4
4
  require "unicode/display_width"
5
5
 
6
6
  module ClaudeInbox
7
- # A deliberately small cursor-addressed screen model. `claude logs` emits a
8
- # replay of the session's terminal output — cursor moves, erase-line, SGR —
9
- # not plain text, and words are often separated by cursor motion instead of
10
- # spaces. Stripping escapes therefore yields garbage; feeding them through a
11
- # grid does not. This is not a VT emulator: it handles the handful of
12
- # sequences the replay actually uses and ignores the rest.
7
+ # A deliberately small cursor-addressed screen model that turns captured
8
+ # terminal output into the lines it would show; bin/screens reads the
9
+ # inbox's own frames through it. This is not a VT emulator: it handles the
10
+ # handful of sequences claude's output actually uses and ignores the rest.
13
11
  class VtScreen
14
12
  CSI = /\e\[([0-9;?]*)([A-Za-z@`])/
15
13
  OSC = /\e\][^\a\e]*(?:\a|\e\\)?/
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.7.0"
5
5
  end