claude-inbox 0.2.0 → 0.4.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -177
  3. data/exe/claude-inbox +67 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +68 -62
  6. data/lib/claude_inbox/app.rb +88 -38
  7. data/lib/claude_inbox/config.rb +24 -0
  8. data/lib/claude_inbox/dialog.rb +3 -1
  9. data/lib/claude_inbox/headless.rb +31 -0
  10. data/lib/claude_inbox/images.rb +37 -6
  11. data/lib/claude_inbox/job_state.rb +25 -10
  12. data/lib/claude_inbox/keymap.rb +1 -3
  13. data/lib/claude_inbox/mod.rb +71 -0
  14. data/lib/claude_inbox/new_session_form.rb +43 -52
  15. data/lib/claude_inbox/painter.rb +31 -0
  16. data/lib/claude_inbox/palette.rb +4 -7
  17. data/lib/claude_inbox/paste.rb +2 -0
  18. data/lib/claude_inbox/peek.rb +2 -2
  19. data/lib/claude_inbox/poller.rb +48 -24
  20. data/lib/claude_inbox/pull_requests.rb +36 -22
  21. data/lib/claude_inbox/rate_limits.rb +1 -3
  22. data/lib/claude_inbox/records.rb +7 -2
  23. data/lib/claude_inbox/remote/http.rb +140 -0
  24. data/lib/claude_inbox/remote/icon.png +0 -0
  25. data/lib/claude_inbox/remote/icon.svg +8 -0
  26. data/lib/claude_inbox/remote/listener.rb +435 -0
  27. data/lib/claude_inbox/remote/page.html +477 -0
  28. data/lib/claude_inbox/remote/pairing.rb +111 -0
  29. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  30. data/lib/claude_inbox/remote/start.rb +201 -0
  31. data/lib/claude_inbox/remote.rb +10 -0
  32. data/lib/claude_inbox/renderer.rb +61 -61
  33. data/lib/claude_inbox/session.rb +7 -21
  34. data/lib/claude_inbox/session_request.rb +151 -0
  35. data/lib/claude_inbox/sessions.rb +0 -1
  36. data/lib/claude_inbox/settings.rb +20 -7
  37. data/lib/claude_inbox/slash_commands.rb +1 -0
  38. data/lib/claude_inbox/snapshot.rb +48 -0
  39. data/lib/claude_inbox/store/entry.rb +0 -2
  40. data/lib/claude_inbox/store/row.rb +7 -3
  41. data/lib/claude_inbox/store.rb +24 -11
  42. data/lib/claude_inbox/subprocess.rb +9 -1
  43. data/lib/claude_inbox/switch.rb +40 -0
  44. data/lib/claude_inbox/terminal.rb +9 -3
  45. data/lib/claude_inbox/text.rb +7 -1
  46. data/lib/claude_inbox/text_buffer.rb +84 -33
  47. data/lib/claude_inbox/trust.rb +23 -0
  48. data/lib/claude_inbox/vt_screen.rb +1 -1
  49. data/lib/claude_inbox/writer_lock.rb +65 -0
  50. data/lib/claude_inbox.rb +1 -10
  51. data/mod/.claude-plugin/plugin.json +20 -0
  52. data/mod/hooks/hooks.json +1 -0
  53. data/mod/hooks/register.tsx +470 -0
  54. data/mod/tests/contract.test.tsx +23 -0
  55. data/mod/tests/pane.test.tsx +199 -0
  56. data/mod/tests/snapshot.fixture.ts +47 -0
  57. data/mod/tests/snooze.test.tsx +31 -0
  58. data/mod/tsconfig.json +3 -0
  59. data/mod/types/index.d.ts +30 -0
  60. metadata +29 -1
@@ -7,21 +7,33 @@ module ClaudeInbox
7
7
  # settings files it reads, most specific wins. Values are strings, or
8
8
  # nil when nothing sets them — then the CLI decides at launch.
9
9
  module Settings
10
- Defaults = Struct.new(:model, :effort, :permission_mode)
10
+ Defaults = Struct.new(:model, :effort, :permission_mode, :remote)
11
11
 
12
12
  def self.defaults(cwd, home: Dir.home)
13
- layers = [
14
- File.join(home, ".claude", "settings.json"),
15
- File.join(cwd, ".claude", "settings.json"),
16
- File.join(cwd, ".claude", "settings.local.json")
17
- ].map { |path| read(path) }
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]
18
17
  Defaults.new(
19
18
  model: pick(layers, "model"),
20
19
  effort: pick(layers, "effortLevel"),
21
- permission_mode: pick(layers, "permissions", "defaultMode")
20
+ permission_mode: pick(layers, "permissions", "defaultMode"),
21
+ remote: remote_control(user, project, local, read(File.join(home, ".claude.json")))
22
22
  )
23
23
  end
24
24
 
25
+ # "Enable Remote Control for all sessions" in /config. The CLI lets a
26
+ # repo turn it off but only user scope turn it on, and still honours the
27
+ # copy older versions kept in ~/.claude.json.
28
+ def self.remote_control(user, project, local, legacy)
29
+ return "no" if project["remoteControlAtStartup"] == false || local["remoteControlAtStartup"] == false
30
+ [user, legacy].each do |h|
31
+ v = h["remoteControlAtStartup"]
32
+ return v ? "yes" : "no" if [true, false].include?(v)
33
+ end
34
+ nil
35
+ end
36
+
25
37
  def self.read(path)
26
38
  JSON.parse(File.read(path))
27
39
  rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
@@ -35,5 +47,6 @@ module ClaudeInbox
35
47
  end
36
48
  nil
37
49
  end
50
+ private_class_method :remote_control, :pick
38
51
  end
39
52
  end
@@ -112,5 +112,6 @@ module ClaudeInbox
112
112
  s = s[1..-2] if quoted
113
113
  s.gsub("''", "'")
114
114
  end
115
+ private_class_method :from_dir, :skills, :commands, :qualify, :plugins, :synced, :frontmatter, :unquote
115
116
  end
116
117
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+
5
+ module ClaudeInbox
6
+ # The sections as the screen shows them, written to a file for another
7
+ # front end to read, so the triage rules run once, here. Rewritten when
8
+ # the rows change and every HEARTBEAT seconds regardless, so a reader can
9
+ # tell a quiet inbox from one that has quit.
10
+ class Snapshot
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "snapshot.json")
12
+ HEARTBEAT = 5
13
+
14
+ def self.disabled = new(path: nil)
15
+
16
+ def initialize(path: DEFAULT_PATH)
17
+ @path = path
18
+ @body = nil
19
+ @written_at = nil
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Called from the poller's threads and the screen's alike.
24
+ def write(sections, now)
25
+ return unless @path
26
+ body = sections.to_h.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }
27
+ @mutex.synchronize do
28
+ return if body == @body && now.to_i - @written_at < HEARTBEAT
29
+ @body = body
30
+ @written_at = now.to_i
31
+ Records.save(@path, {"version" => 1, "written_at" => @written_at, "sections" => body})
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def row_hash(row)
38
+ s = row.session
39
+ pr = s.pr
40
+ {
41
+ "id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
42
+ "waiting" => s.waiting_on_work? || nil, "terminal" => s.terminal? || nil,
43
+ "cwd" => s.cwd, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
44
+ "pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
45
+ }.compact
46
+ end
47
+ end
48
+ end
@@ -69,8 +69,6 @@ module ClaudeInbox
69
69
  # When `claude rm` last refused this session, so the Reaper backs off.
70
70
  def reap_failed_at = @h["reap_failed_at"]
71
71
 
72
- def reap_error = @h["reap_error"]
73
-
74
72
  def stale?(now) = last_seen && now.to_i - last_seen > PRUNE_AFTER
75
73
 
76
74
  def observe(session, now)
@@ -66,12 +66,16 @@ module ClaudeInbox
66
66
  # Resolved PRs settle a `blocked` row too: asking "anything need
67
67
  # changing?" leaves it blocked, and merging answers it. `failed` stays
68
68
  # in view even if its PR landed; a PR nobody knows the state of yet
69
- # (no gh, offline) is ignored.
69
+ # (no gh, offline) is ignored. So is one resolved before the session
70
+ # started: reviewing an old PR, or the CLI resolving a bare PR number
71
+ # against the wrong repo, would otherwise hide a session waiting on you.
72
+ # Until gh says when a PR was resolved, it holds the row in view.
70
73
  def settled?
71
74
  return true if hand_settled?
72
75
  return false if UNSETTLEABLE.include?(session.effective_state)
73
- known = session.prs.select(&:known?)
74
- known.any? && known.all?(&:resolved?)
76
+ started = session.started_at.to_i
77
+ ours = session.prs.select(&:known?).reject { |pr| pr.resolved_before?(started) }
78
+ ours.any? && ours.all? { |pr| pr.resolved_since?(started) }
75
79
  end
76
80
 
77
81
  # Idle time is the only clock, not the Settled section: the PR rule holds
@@ -20,6 +20,7 @@ module ClaudeInbox
20
20
  # should see it, but after REAP_AFTER of not seeing it, you never will.
21
21
  UNREAPABLE = %w[working].freeze
22
22
  SECTIONS = %i[pinned needs_you active snoozed settled].freeze
23
+ NotWritable = Class.new(StandardError)
23
24
  # Sections long enough to be worth hiding behind a fold toggle.
24
25
  FOLDABLE_SECTIONS = %i[snoozed settled].freeze
25
26
 
@@ -56,11 +57,15 @@ module ClaudeInbox
56
57
 
57
58
  DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "state.json")
58
59
 
59
- attr_reader :path
60
-
61
- def initialize(path: DEFAULT_PATH, clock: -> { Time.now })
60
+ # A store that is not `writable` reads the file but never saves it: the
61
+ # second inbox open at once, which reads along with the first rather
62
+ # than overwriting what it and the pane's requests settled, and sends
63
+ # its own edits to the first through the `relay` as requests.
64
+ def initialize(path: DEFAULT_PATH, clock: -> { Time.now }, writable: true, relay: nil)
62
65
  @path = path
63
66
  @clock = clock
67
+ @writable = writable
68
+ @relay = relay
64
69
  @mutex = Mutex.new
65
70
  @sessions = []
66
71
  @hidden = Set.new
@@ -76,7 +81,7 @@ module ClaudeInbox
76
81
  @hidden &= keys
77
82
  @forgotten &= keys
78
83
  @sessions = sessions.reject { |s| @hidden.include?(s.key) || @forgotten.include?(s.key) }
79
- @entries = self.class.merge_entries(@entries, @sessions, @clock.call)
84
+ @entries = self.class.merge_entries(@writable ? @entries : load, @sessions, @clock.call)
80
85
  save
81
86
  end
82
87
  end
@@ -87,17 +92,19 @@ module ClaudeInbox
87
92
 
88
93
  def sessions = @mutex.synchronize { @sessions.dup }
89
94
 
90
- def snooze(id, choice) = edit(id) { |e| e.snooze(choice, @clock.call) }
95
+ def writable? = @writable
96
+
97
+ def snooze(id, choice) = edit(id, "snooze", choice: choice.to_s) { |e| e.snooze(choice, @clock.call) }
91
98
 
92
- def wake(id) = edit(id) { |e| e.wake(@clock.call) }
99
+ def wake(id) = edit(id, "wake") { |e| e.wake(@clock.call) }
93
100
 
94
- def acknowledge(id) = edit(id) { |e| e.acknowledge(@clock.call) }
101
+ def acknowledge(id) = edit(id, "acknowledge") { |e| e.acknowledge(@clock.call) }
95
102
 
96
- def settle(id) = edit(id) { |e| e.settle(@clock.call) }
103
+ def settle(id) = edit(id, "settle") { |e| e.settle(@clock.call) }
97
104
 
98
105
  def mark_reap_failed(id, message) = edit(id) { |e| e.mark_reap_failed(@clock.call, message) }
99
106
 
100
- def toggle_pin(id) = edit(id) { |e| e.toggle_pin(@clock.call) }
107
+ def toggle_pin(id) = edit(id, "pin") { |e| e.toggle_pin(@clock.call) }
101
108
 
102
109
  def set_alias(id, name) = edit(id) { |e| e.alias = name }
103
110
 
@@ -146,11 +153,17 @@ module ClaudeInbox
146
153
 
147
154
  private
148
155
 
149
- def edit(id)
156
+ # A store that cannot save still makes the change here, for the screen,
157
+ # and asks the writer for it by `action` through the relay, so the next
158
+ # poll finds it done rather than undone. An edit with no request form
159
+ # would only be undone, so it is refused outright.
160
+ def edit(id, action = nil, **detail)
161
+ raise NotWritable, "another claude-inbox owns state.json" if !@writable && action.nil?
150
162
  @mutex.synchronize do
151
163
  yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
152
164
  save
153
165
  end
166
+ @relay&.request(action, id, **detail) if !@writable && action
154
167
  end
155
168
 
156
169
  def load
@@ -158,7 +171,7 @@ module ClaudeInbox
158
171
  end
159
172
 
160
173
  def save
161
- return unless @path
174
+ return unless @path && @writable
162
175
  Records.save(@path, {"version" => 1, "sessions" => @entries})
163
176
  end
164
177
  end
@@ -10,8 +10,16 @@ module ClaudeInbox
10
10
  def success? = status.success?
11
11
  end
12
12
 
13
+ # 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
17
+
13
18
  module_function
14
19
 
20
+ # 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
22
+
15
23
  def capture(*argv, chdir: nil)
16
24
  out_r, out_w = IO.pipe
17
25
  err_r, err_w = IO.pipe
@@ -23,7 +31,7 @@ module ClaudeInbox
23
31
  out_r.close
24
32
  err_r.close
25
33
  Dir.chdir(chdir) if chdir
26
- exec(*argv)
34
+ exec(*command(*argv))
27
35
  rescue SystemCallError => e
28
36
  $stderr.write(e.message)
29
37
  exit! 127
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+
5
+ module ClaudeInbox
6
+ # A request from another front end to attach to a different session: one
7
+ # file, written by the asker and taken by the inbox attached to the asker's
8
+ # own session. Only that inbox can act on it, which is why the asker gives
9
+ # up on a request nobody takes, and why the request names who asked.
10
+ class Switch
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "switch.json")
12
+
13
+ def self.disabled = new(path: nil)
14
+
15
+ def initialize(path: DEFAULT_PATH)
16
+ @path = path
17
+ end
18
+
19
+ def request(id, from, now) = @path && Records.save(@path, {"id" => id, "from" => from, "at" => now.to_i})
20
+
21
+ # Whether the session `from` has asked to go elsewhere.
22
+ def requested_for?(from)
23
+ return false if @path.nil? || from.nil? || !File.exist?(@path)
24
+ Records.read(@path)["from"] == from
25
+ end
26
+
27
+ # The requested id if it is one of `attachable`, and the request is gone either way.
28
+ def take(attachable)
29
+ id = Records.read(@path)["id"]
30
+ clear
31
+ attachable.include?(id) ? id : nil
32
+ end
33
+
34
+ def clear
35
+ File.delete(@path) if @path
36
+ rescue Errno::ENOENT
37
+ nil
38
+ end
39
+ end
40
+ end
@@ -3,7 +3,7 @@
3
3
  require "io/console"
4
4
  require "tty-cursor"
5
5
  require "tty-screen"
6
- require_relative "renderer"
6
+ require_relative "painter"
7
7
 
8
8
  module ClaudeInbox
9
9
  # The screen the inbox draws on: the alt screen and the mouse and wheel
@@ -40,10 +40,16 @@ module ClaudeInbox
40
40
  @size = nil
41
41
  end
42
42
 
43
+ # The tty's mode is kept as found, not assumed: `cooked!` sets a stock
44
+ # mode that differs from a shell's (istrip on, imaxbel off), and the
45
+ # shell inherits whatever is left on quit.
43
46
  def enter
44
47
  @out.print ALT_ON, WHEEL_KEYS_ON, MOUSE_ON, PASTE_ON, TTY::Cursor.hide, TTY::Cursor.clear_screen
45
48
  @out.flush
46
- @input.raw! if @input.respond_to?(:raw!) && @input.tty?
49
+ if @input.respond_to?(:raw!) && @input.tty?
50
+ @mode = @input.console_mode
51
+ @input.raw!
52
+ end
47
53
  @restored = false
48
54
  resized
49
55
  end
@@ -52,7 +58,7 @@ module ClaudeInbox
52
58
  def restore
53
59
  return if @restored
54
60
  @restored = true
55
- @input.cooked! if @input.respond_to?(:cooked!) && @input.tty?
61
+ @input.console_mode = @mode if @mode && @input.tty?
56
62
  @out.print TTY::Cursor.show, PASTE_OFF, MOUSE_OFF, WHEEL_KEYS_OFF, ALT_OFF
57
63
  @out.flush
58
64
  rescue
@@ -13,6 +13,12 @@ module ClaudeInbox
13
13
 
14
14
  def strip_ansi(s) = s.gsub(ANSI, "")
15
15
 
16
+ # Control characters as \xNN, so quoted text can't reach the terminal
17
+ # as an escape. Bytes that aren't UTF-8 become U+FFFD.
18
+ def printable(s)
19
+ String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD").gsub(/[[:cntrl:]]/) { |c| format("\\x%02x", c.ord) }
20
+ end
21
+
16
22
  def width(s) = Unicode::DisplayWidth.of(strip_ansi(s))
17
23
 
18
24
  # Truncate plain (uncolored) text to `w` columns, appending an ellipsis
@@ -57,7 +63,7 @@ module ClaudeInbox
57
63
  s = truncate(strip_ansi(s), w)
58
64
  cur = width(s)
59
65
  end
60
- s + (" " * (w - cur))
66
+ s + (" " * [w - cur, 0].max)
61
67
  end
62
68
 
63
69
  # Greedy word wrap on display width. Words wider than `w` are split.
@@ -9,20 +9,34 @@ module ClaudeInbox
9
9
  #
10
10
  # Positions count cells, not bytes or characters. A cell is a grapheme
11
11
  # cluster — prompts and session names carry emoji, and character indexes
12
- # cut them in half — or a Chip, an attached image that shows as one
13
- # `[Image #1]` token and moves and deletes as one unit, the way Claude
14
- # Code's own prompt treats a pasted image.
12
+ # cut them in half — or a chip: an attached Image or a long Pasted text,
13
+ # each shown as one token (`[Image #1]`, `[Pasted text #1 +40 lines]`)
14
+ # that moves and deletes as one unit, the way Claude Code's own prompt
15
+ # treats them.
15
16
  #
16
17
  # b = TextBuffer.new("ab")
17
18
  # b.press(:left, "\e[D")
18
19
  # b.press("X", "X")
19
20
  # b.to_s # => "aXb"
20
21
  class TextBuffer
21
- Chip = Struct.new(:n, :path) do
22
+ Image = Struct.new(:n, :path) do
22
23
  def to_s = "[Image ##{n}]"
23
24
  end
24
25
 
26
+ Pasted = Struct.new(:n, :text) do
27
+ def to_s
28
+ lines = text.count("\n")
29
+ "[Pasted text ##{n} #{(lines > 0) ? "+#{lines} lines" : "#{text.size} chars"}]"
30
+ end
31
+ end
32
+
33
+ # Past either, a paste folds into a Pasted chip: laid out in full it
34
+ # buries the prompt around it, and wrapping it costs every frame.
35
+ FOLD_CHARS = 800
36
+ FOLD_LINES = 10
37
+
25
38
  def initialize(text = "")
39
+ @widths = Hash.new { |h, s| h[s] = Text.width(s) }
26
40
  @g = text.grapheme_clusters
27
41
  @cursor = @g.size
28
42
  end
@@ -31,13 +45,17 @@ module ClaudeInbox
31
45
 
32
46
  def empty? = @g.empty?
33
47
 
34
- # Grapheme offset of the cursor. Rendering goes through #row / #view;
35
- # this is here for callers that need to reason about position (tests).
36
- attr_reader :cursor
37
-
38
- def chips = @g.grep(Chip)
39
-
40
- def expand = @g.map { |c| c.is_a?(Chip) ? yield(c) : c }.join
48
+ # The text as it will be sent: a Pasted chip unfolds to its text, and
49
+ # the block says what each Image becomes.
50
+ def expand
51
+ @g.map { |c|
52
+ case c
53
+ when Image then yield(c)
54
+ when Pasted then c.text
55
+ else c
56
+ end
57
+ }.join
58
+ end
41
59
 
42
60
  # Swaps the whole text out and parks the cursor at the end — what
43
61
  # completion wants after it extends a path.
@@ -62,13 +80,13 @@ module ClaudeInbox
62
80
  @cursor += g.size
63
81
  end
64
82
 
65
- # Numbered after the chips already in the text, so a second image is
83
+ # Chips are numbered per kind and never reused, so a second image is
66
84
  # `[Image #2]` even after the first was deleted, as Claude Code does.
67
- def attach(path)
68
- chip = Chip.new(@next_chip = (@next_chip || 0) + 1, path)
69
- @g.insert(@cursor, chip)
70
- @cursor += 1
71
- chip
85
+ def attach(path) = place(Image.new(@images = (@images || 0) + 1, path))
86
+
87
+ def paste(text)
88
+ return insert(text) if text.size <= FOLD_CHARS && text.count("\n") < FOLD_LINES
89
+ place(Pasted.new(@pastes = (@pastes || 0) + 1, text))
72
90
  end
73
91
 
74
92
  # Handles one keypress — readline's editing keys, plus printable text —
@@ -98,12 +116,18 @@ module ClaudeInbox
98
116
  # gets plain truncated text instead.
99
117
  def row(width, cursor: nil)
100
118
  return Text.truncate(to_s, width) unless cursor
101
- first = 0
102
- first += 1 while width_of(@g[first...@cursor]) > width - 1
119
+ first = @cursor
120
+ used = 0
121
+ while first > 0 && used + cell_width(@g[first - 1]) <= width - 1
122
+ first -= 1
123
+ used += cell_width(@g[first])
124
+ end
103
125
  cells = []
126
+ used = 0
104
127
  @g[first..].each do |c|
105
- break if width_of(cells) + width_of([c]) > width
128
+ break if used + cell_width(c) > width
106
129
  cells << c
130
+ used += cell_width(c)
107
131
  end
108
132
  paint(cells, @cursor - first, cursor)
109
133
  end
@@ -129,12 +153,14 @@ module ClaudeInbox
129
153
 
130
154
  private
131
155
 
132
- def width_of(cells) = cells.sum { |c| Text.width(c.to_s) }
156
+ def width_of(cells) = cells.sum { |c| cell_width(c) }
157
+
158
+ def cell_width(cell) = @widths[cell.to_s]
133
159
 
134
160
  def paint(cells, offset, cursor, chip = nil)
135
161
  out = cells.each_with_index.map do |c, i|
136
162
  s = c.to_s
137
- s = chip.call(s) if chip && c.is_a?(Chip)
163
+ s = chip.call(s) if chip && !c.is_a?(String)
138
164
  (i == offset) ? cursor.call(s) : s
139
165
  end
140
166
  out << cursor.call(" ") if offset && offset >= cells.size
@@ -160,29 +186,48 @@ module ClaudeInbox
160
186
 
161
187
  # Text.wrap on cells, keeping the trailing spaces the cursor may sit on:
162
188
  # a chip is one cell however wide its label, so the wrap has to measure
163
- # cells rather than a joined string.
189
+ # cells rather than a joined string. Widths are kept running rather than
190
+ # re-measured per word: this runs every frame, over the whole prompt.
164
191
  def segments(cells, width)
165
- return [cells] if width_of(cells) <= width
166
- words = cells.slice_when { |c, _| c == " " }.to_a
167
192
  lines = []
168
193
  line = []
169
- words.each do |word|
170
- if !line.empty? && width_of(line) + width_of(word.reverse.drop_while { |c| c == " " }) > width
194
+ used = 0
195
+ cells.slice_when { |c, _| c == " " }.each do |word|
196
+ word_w = width_of(word)
197
+ bare_w = (word.last == " ") ? word_w - 1 : word_w
198
+ if !line.empty? && used + bare_w > width
171
199
  lines << line
172
200
  line = []
201
+ used = 0
173
202
  end
174
- while width_of(word) > width
175
- cut = word.size - 1
176
- cut -= 1 while cut > 0 && width_of(word[0...cut]) > width
177
- lines << word[0...cut]
178
- word = word[cut..]
203
+ at = 0
204
+ while word_w > width
205
+ cut, cut_w = fit(word, at, width)
206
+ lines << word[at...at + cut]
207
+ at += cut
208
+ word_w -= cut_w
179
209
  end
180
- line += word
210
+ line.concat(word[at..])
211
+ used += word_w
181
212
  end
182
213
  lines << line unless line.empty?
183
214
  lines
184
215
  end
185
216
 
217
+ # How many cells of `word` from `at` fit in `width`, and their width.
218
+ # Always at least one, so a glyph wider than the box still moves on.
219
+ def fit(word, at, width)
220
+ count = 0
221
+ used = 0
222
+ (at...word.size).each do |i|
223
+ w = cell_width(word[i])
224
+ break if count > 0 && used + w > width
225
+ count += 1
226
+ used += w
227
+ end
228
+ [count, used]
229
+ end
230
+
186
231
  # The newline itself belongs to the line it ends, so a cursor sitting on
187
232
  # it lands past the end of that line rather than at the start of the next.
188
233
  def logical_lines
@@ -200,6 +245,12 @@ module ClaudeInbox
200
245
 
201
246
  def cursor_row(rows) = rows.rindex { |_, start| start <= @cursor } || 0
202
247
 
248
+ def place(chip)
249
+ @g.insert(@cursor, chip)
250
+ @cursor += 1
251
+ chip
252
+ end
253
+
203
254
  def delete(at, length)
204
255
  return if at < 0 || length <= 0
205
256
  @g.slice!(at, length)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeInbox
6
+ # The directories whose trust dialog has been accepted, read from where
7
+ # the CLI keeps the answer (see docs/cli-quirks.md). For choosing which
8
+ # directories to offer and in what order, never as a gate: whether a
9
+ # session may start somewhere stays the CLI's call.
10
+ module Trust
11
+ def self.projects(path: File.join(Dir.home, ".claude.json"))
12
+ data = JSON.parse(File.read(path))
13
+ projects = data["projects"] if data.is_a?(Hash)
14
+ return [] unless projects.is_a?(Hash)
15
+ projects.filter_map do |dir, entry|
16
+ dir if dir.start_with?("/") && !dir.include?("\0") &&
17
+ entry.is_a?(Hash) && entry["hasTrustDialogAccepted"] == true
18
+ end
19
+ rescue SystemCallError, IOError, JSON::ParserError
20
+ []
21
+ end
22
+ end
23
+ end
@@ -90,7 +90,7 @@ module ClaudeInbox
90
90
  n = nums[0] || 1
91
91
  case final
92
92
  when "H", "f"
93
- @row = ((nums[0] || 1) - 1).clamp(0, @rows - 1)
93
+ @row = (n - 1).clamp(0, @rows - 1)
94
94
  @col = ((nums[1] || 1) - 1).clamp(0, @cols - 1)
95
95
  when "A" then @row = [@row - n, 0].max
96
96
  when "B" then @row = [@row + n, @rows - 1].min
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module ClaudeInbox
6
+ # One snapshot writer per machine: the inbox you can see, else a headless
7
+ # one. The file names the holder's role beside its pid, so an inbox takes
8
+ # the lock over from a headless process, which it ends, and never from
9
+ # another inbox; a headless process that finds the lock held exits.
10
+ class WriterLock
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "writer.lock")
12
+ EVICT_TRIES = 20
13
+
14
+ def self.disabled = new(path: nil)
15
+
16
+ def initialize(path: DEFAULT_PATH)
17
+ @path = path
18
+ @file = nil
19
+ end
20
+
21
+ # True once held. Only a headless holder is evicted, and only by an inbox.
22
+ def take(role:)
23
+ return true unless @path
24
+ FileUtils.mkdir_p(File.dirname(@path))
25
+ file = File.open(@path, File::RDWR | File::CREAT, 0o600)
26
+ return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
27
+ holder_role, holder = file.read.split
28
+ return refuse(file) unless role == "inbox" && holder_role == "headless"
29
+ Process.kill("TERM", holder.to_i) if holder.to_i.positive? && holder.to_i != Process.pid
30
+ EVICT_TRIES.times do
31
+ return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
32
+ sleep 0.1
33
+ end
34
+ refuse(file)
35
+ rescue Errno::ESRCH, Errno::EPERM
36
+ # The holder the file names is gone already: the lock is free.
37
+ file.flock(File::LOCK_EX | File::LOCK_NB) ? hold(file, role) : refuse(file)
38
+ end
39
+
40
+ def held? = !@file.nil?
41
+
42
+ def release
43
+ @file&.close
44
+ @file = nil
45
+ end
46
+
47
+ private
48
+
49
+ # After reading the holder the position sits at the end, and truncate does
50
+ # not move it, so the write is rewound first or it lands after NUL padding.
51
+ def hold(file, role)
52
+ file.rewind
53
+ file.truncate(0)
54
+ file.write("#{role} #{Process.pid}")
55
+ file.flush
56
+ @file = file
57
+ true
58
+ end
59
+
60
+ def refuse(file)
61
+ file.close
62
+ false
63
+ end
64
+ end
65
+ end
data/lib/claude_inbox.rb CHANGED
@@ -1,14 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
6
-
7
- require_relative "claude_inbox/palette"
8
- require_relative "claude_inbox/session"
9
- require_relative "claude_inbox/job_state"
10
- require_relative "claude_inbox/pull_requests"
11
- require_relative "claude_inbox/agents_client"
12
- require_relative "claude_inbox/sessions"
13
- require_relative "claude_inbox/store"
14
- require_relative "claude_inbox/reaper"