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.
- checksums.yaml +4 -4
- data/README.md +107 -177
- data/exe/claude-inbox +67 -4
- data/lib/claude_inbox/actions.rb +91 -0
- data/lib/claude_inbox/agents_client.rb +68 -62
- data/lib/claude_inbox/app.rb +88 -38
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/headless.rb +31 -0
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +25 -10
- data/lib/claude_inbox/keymap.rb +1 -3
- data/lib/claude_inbox/mod.rb +71 -0
- data/lib/claude_inbox/new_session_form.rb +43 -52
- data/lib/claude_inbox/painter.rb +31 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/paste.rb +2 -0
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +48 -24
- data/lib/claude_inbox/pull_requests.rb +36 -22
- data/lib/claude_inbox/rate_limits.rb +1 -3
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +140 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +435 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +201 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +61 -61
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +20 -7
- data/lib/claude_inbox/slash_commands.rb +1 -0
- data/lib/claude_inbox/snapshot.rb +48 -0
- data/lib/claude_inbox/store/entry.rb +0 -2
- data/lib/claude_inbox/store/row.rb +7 -3
- data/lib/claude_inbox/store.rb +24 -11
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/switch.rb +40 -0
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/text_buffer.rb +84 -33
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox/writer_lock.rb +65 -0
- data/lib/claude_inbox.rb +1 -10
- data/mod/.claude-plugin/plugin.json +20 -0
- data/mod/hooks/hooks.json +1 -0
- data/mod/hooks/register.tsx +470 -0
- data/mod/tests/contract.test.tsx +23 -0
- data/mod/tests/pane.test.tsx +199 -0
- data/mod/tests/snapshot.fixture.ts +47 -0
- data/mod/tests/snooze.test.tsx +31 -0
- data/mod/tsconfig.json +3 -0
- data/mod/types/index.d.ts +30 -0
- metadata +29 -1
|
@@ -38,19 +38,19 @@ module ClaudeInbox
|
|
|
38
38
|
# Each background session with its job file read onto `job_state`, or nil
|
|
39
39
|
# when there is none: interactive sessions have no job file, and neither
|
|
40
40
|
# does one the daemon has already forgotten. Sessions.load calls this.
|
|
41
|
-
def self.enrich(sessions, jobs_dir:
|
|
41
|
+
def self.enrich(sessions, jobs_dir:)
|
|
42
42
|
sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# => JobState, or nil when there is no readable file for this id.
|
|
46
|
-
def self.read(id, jobs_dir:
|
|
46
|
+
def self.read(id, jobs_dir:)
|
|
47
47
|
return nil unless id
|
|
48
48
|
new(JSON.parse(File.read(File.join(jobs_dir, id, "state.json"))))
|
|
49
49
|
rescue JSON::ParserError, SystemCallError
|
|
50
50
|
nil
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
attr_reader :
|
|
53
|
+
attr_reader :pr_urls, :color, :intent, :bridge_id
|
|
54
54
|
|
|
55
55
|
def initialize(hash)
|
|
56
56
|
@detail = hash["detail"]
|
|
@@ -70,16 +70,22 @@ module ClaudeInbox
|
|
|
70
70
|
# a wake, are the only record of Remote Control being on it.
|
|
71
71
|
def remote_control? = flags.include?("--remote-control")
|
|
72
72
|
|
|
73
|
-
# The agent itself is not thinking. On its own this means little — a
|
|
74
|
-
# session whose process died leaves the same reading behind — so it only
|
|
75
|
-
# says something paired with work still in flight.
|
|
76
|
-
def agent_idle? = tempo == "idle"
|
|
77
|
-
|
|
78
|
-
def in_flight? = tasks.positive?
|
|
79
|
-
|
|
80
73
|
# True when the agent has stopped and is only waiting on what it started.
|
|
81
74
|
def waiting_on_work? = agent_idle? && in_flight?
|
|
82
75
|
|
|
76
|
+
# What the session needs while blocked, what it produced once done,
|
|
77
|
+
# otherwise its status line; nil before it has said anything.
|
|
78
|
+
def summary(state)
|
|
79
|
+
line =
|
|
80
|
+
case state
|
|
81
|
+
when "blocked" then needs || detail
|
|
82
|
+
when "done" then result || detail
|
|
83
|
+
else detail
|
|
84
|
+
end
|
|
85
|
+
line = line.to_s.gsub(/\s+/, " ").strip
|
|
86
|
+
line.empty? ? nil : line
|
|
87
|
+
end
|
|
88
|
+
|
|
83
89
|
# "1 shell", "2 agents · 1 shell". Falls back to a bare count for a state
|
|
84
90
|
# file that counts the open tasks without naming them.
|
|
85
91
|
def in_flight_label
|
|
@@ -90,6 +96,15 @@ module ClaudeInbox
|
|
|
90
96
|
|
|
91
97
|
private
|
|
92
98
|
|
|
99
|
+
attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :flags
|
|
100
|
+
|
|
101
|
+
# The agent itself is not thinking. On its own this means little — a
|
|
102
|
+
# session whose process died leaves the same reading behind — so it only
|
|
103
|
+
# says something paired with work still in flight.
|
|
104
|
+
def agent_idle? = tempo == "idle"
|
|
105
|
+
|
|
106
|
+
def in_flight? = tasks.positive?
|
|
107
|
+
|
|
93
108
|
def count_label = "#{tasks} #{plural("task", tasks)}"
|
|
94
109
|
|
|
95
110
|
def plural(word, n) = (n == 1) ? word : "#{word}s"
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -27,7 +27,7 @@ module ClaudeInbox
|
|
|
27
27
|
"s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
|
|
28
28
|
:ctrl_x => :delete,
|
|
29
29
|
"o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
|
|
30
|
-
"R" => :refresh, "p" => :toggle_peek, "n" => :new_session,
|
|
30
|
+
"R" => :refresh, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
|
|
31
31
|
:tab => :next_section, :back_tab => :prev_section,
|
|
32
32
|
"/" => :filter, :escape => :escape,
|
|
33
33
|
"q" => :quit, :ctrl_c => :quit
|
|
@@ -38,8 +38,6 @@ module ClaudeInbox
|
|
|
38
38
|
"z" => {"o" => :fold_open, "c" => :fold_close, "a" => :fold_toggle}
|
|
39
39
|
}.freeze
|
|
40
40
|
|
|
41
|
-
attr_reader :pending
|
|
42
|
-
|
|
43
41
|
def initialize(clock: -> { Time.now })
|
|
44
42
|
@clock = clock
|
|
45
43
|
@pending = nil
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# Where the Claude Code pane ships, and a path to it that holds still: the
|
|
8
|
+
# gem's own folder moves with every version, so a link under ~/.config is
|
|
9
|
+
# what settings.json names, refreshed by the installed gem on every launch.
|
|
10
|
+
module Mod
|
|
11
|
+
DIR = File.expand_path("../../mod", __dir__)
|
|
12
|
+
LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
|
|
13
|
+
NAME = "inbox-pane"
|
|
14
|
+
CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
|
|
15
|
+
# What the pane runs headless: a checkout's own, or the gem's from PATH.
|
|
16
|
+
COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
|
|
17
|
+
|
|
18
|
+
# Points the link at this gem's mod folder and returns the link. A folder
|
|
19
|
+
# of that name that is not a link is left alone. From a checkout only a
|
|
20
|
+
# missing or dangling link is made, unless forced (`--mod-dir` asks for
|
|
21
|
+
# it), so a worktree's launch never takes the path from the installed gem.
|
|
22
|
+
def self.link(link: LINK, dir: DIR, force: false, checkout: CHECKOUT)
|
|
23
|
+
FileUtils.mkdir_p(File.dirname(link))
|
|
24
|
+
return link if File.exist?(link) && !File.symlink?(link)
|
|
25
|
+
if File.symlink?(link)
|
|
26
|
+
return link if File.readlink(link) == dir
|
|
27
|
+
return link if checkout && !force && File.directory?(File.readlink(link))
|
|
28
|
+
File.delete(link)
|
|
29
|
+
end
|
|
30
|
+
File.symlink(dir, link)
|
|
31
|
+
link
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Installed = Data.define(:added, :removed, :command_set)
|
|
35
|
+
|
|
36
|
+
# Links the mod and adds the link to CLAUDE_CODE_PLUGIN_DIRS in Claude
|
|
37
|
+
# Code's user settings, keeping any folders already there; forced, it
|
|
38
|
+
# drops the other folders holding this plugin, such as a worktree's, and
|
|
39
|
+
# sets the command the pane runs to this copy's. Raises JSON::ParserError
|
|
40
|
+
# rather than rewrite a settings file it cannot read.
|
|
41
|
+
def self.install(settings: File.join(Dir.home, ".claude", "settings.json"), link: LINK, dir: DIR, force: false, command: COMMAND)
|
|
42
|
+
self.link(link: link, dir: dir, force: true)
|
|
43
|
+
json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
|
|
44
|
+
env = json["env"] ||= {}
|
|
45
|
+
dirs = env["CLAUDE_CODE_PLUGIN_DIRS"].to_s.split(File::PATH_SEPARATOR).map(&:strip).reject(&:empty?)
|
|
46
|
+
kept = dirs.reject { |d| force && d != link && plugin?(d) }
|
|
47
|
+
wanted = kept.include?(link) ? kept : [*kept, link]
|
|
48
|
+
options = json.dig("pluginConfigs", NAME, "options") || {}
|
|
49
|
+
replace = force && options["command"] != command
|
|
50
|
+
installed = Installed.new(added: !kept.include?(link), removed: dirs - kept, command_set: replace)
|
|
51
|
+
return installed if wanted == dirs && !replace
|
|
52
|
+
|
|
53
|
+
if replace
|
|
54
|
+
command ? options["command"] = command : options.delete("command")
|
|
55
|
+
((json["pluginConfigs"] ||= {})[NAME] ||= {})["options"] = options
|
|
56
|
+
end
|
|
57
|
+
env["CLAUDE_CODE_PLUGIN_DIRS"] = wanted.join(File::PATH_SEPARATOR)
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(settings))
|
|
59
|
+
File.write("#{settings}.tmp", JSON.pretty_generate(json) + "\n")
|
|
60
|
+
File.rename("#{settings}.tmp", settings)
|
|
61
|
+
installed
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.plugin?(folder)
|
|
65
|
+
JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
|
|
66
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
private_class_method :plugin?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "agents_client"
|
|
4
4
|
require_relative "images"
|
|
5
|
+
require_relative "session_request"
|
|
5
6
|
require_relative "settings"
|
|
6
7
|
require_relative "slash_commands"
|
|
7
8
|
require_relative "text"
|
|
@@ -17,15 +18,18 @@ module ClaudeInbox
|
|
|
17
18
|
# string on :choice ones — `kind` says which.
|
|
18
19
|
Field = Struct.new(:key, :label, :kind, :value, :choices)
|
|
19
20
|
|
|
20
|
-
#
|
|
21
|
-
# the screen shows what that resolves to instead.
|
|
21
|
+
# The screen shows what "default" resolves to; `values` makes it nil.
|
|
22
22
|
DEFAULT = "default"
|
|
23
23
|
|
|
24
24
|
MENU_ROWS = 6
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
# Keys a :choice field answers to, as the step through its choices; an
|
|
27
|
+
# editable field spends these on its own text instead.
|
|
28
|
+
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(cwd:, pastel:, home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
27
31
|
@p = pastel
|
|
28
|
-
@theme =
|
|
32
|
+
@theme = Theme.new(enabled: pastel.enabled)
|
|
29
33
|
@home = home
|
|
30
34
|
@clipboard = clipboard
|
|
31
35
|
@fields = [
|
|
@@ -36,7 +40,7 @@ module ClaudeInbox
|
|
|
36
40
|
Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
|
|
37
41
|
Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
|
|
38
42
|
Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
|
|
39
|
-
Field.new(:remote, "Remote Control", :choice, "no",
|
|
43
|
+
Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
|
|
40
44
|
]
|
|
41
45
|
@focus = 0
|
|
42
46
|
@error = nil
|
|
@@ -99,11 +103,6 @@ module ClaudeInbox
|
|
|
99
103
|
:changed
|
|
100
104
|
end
|
|
101
105
|
|
|
102
|
-
def command_query
|
|
103
|
-
return nil unless focused.key == :prompt
|
|
104
|
-
focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
105
|
-
end
|
|
106
|
-
|
|
107
106
|
def menu
|
|
108
107
|
q = command_query
|
|
109
108
|
return nil if q.nil? || @dismissed == q
|
|
@@ -114,33 +113,24 @@ module ClaudeInbox
|
|
|
114
113
|
def picked = menu&.fetch(@pick.clamp(0, menu.size - 1))
|
|
115
114
|
|
|
116
115
|
def values
|
|
117
|
-
@fields.to_h { |f| [f.key, f.value.to_s] }
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
v = @fields.to_h { |f| [f.key, (f.kind == :choice && f.value == DEFAULT) ? nil : f.value.to_s] }
|
|
117
|
+
v[:prompt] = field(:prompt).value.expand { |chip| AgentsClient.mention(chip.path) }.strip
|
|
118
|
+
v[:worktree] = v[:worktree] == "yes"
|
|
119
|
+
v[:name] = nil if v[:name].strip.empty?
|
|
120
|
+
v[:cwd] = directory
|
|
121
|
+
v[:remote] = SessionRequest::FLAGS[v[:remote]]
|
|
122
|
+
SessionRequest.resolve(v, defaults)
|
|
124
123
|
end
|
|
125
124
|
|
|
126
125
|
# Settings resolve against the directory the session will run in, so
|
|
127
126
|
# they follow the Directory field.
|
|
128
127
|
def defaults
|
|
129
|
-
cwd =
|
|
128
|
+
cwd = directory
|
|
130
129
|
return @defaults if @defaults_for == cwd
|
|
131
130
|
@defaults_for = cwd
|
|
132
131
|
@defaults = Settings.defaults(cwd, home: @home)
|
|
133
132
|
end
|
|
134
133
|
|
|
135
|
-
# Project commands live under the Directory field's path, so they
|
|
136
|
-
# follow it as the defaults do.
|
|
137
|
-
def commands
|
|
138
|
-
cwd = values[:cwd]
|
|
139
|
-
return @commands if @commands_for == cwd
|
|
140
|
-
@commands_for = cwd
|
|
141
|
-
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
142
|
-
end
|
|
143
|
-
|
|
144
134
|
# Full-screen body: a tall prompt editor, then one row per setting with
|
|
145
135
|
# every choice visible. Exactly `height` lines.
|
|
146
136
|
def screen(width, height)
|
|
@@ -189,6 +179,20 @@ module ClaudeInbox
|
|
|
189
179
|
|
|
190
180
|
private
|
|
191
181
|
|
|
182
|
+
def command_query
|
|
183
|
+
return nil unless focused.key == :prompt
|
|
184
|
+
focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Project commands live under the Directory field's path, so they
|
|
188
|
+
# follow it as the defaults do.
|
|
189
|
+
def commands
|
|
190
|
+
cwd = directory
|
|
191
|
+
return @commands if @commands_for == cwd
|
|
192
|
+
@commands_for = cwd
|
|
193
|
+
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
194
|
+
end
|
|
195
|
+
|
|
192
196
|
# Esc with a prompt typed asks first, so a stray keypress can't lose it.
|
|
193
197
|
def escape_pressed
|
|
194
198
|
return :cancel if field(:prompt).value.empty?
|
|
@@ -216,7 +220,7 @@ module ClaudeInbox
|
|
|
216
220
|
|
|
217
221
|
def insert(text)
|
|
218
222
|
return unless editable?
|
|
219
|
-
|
|
223
|
+
(focused.kind == :multiline) ? focused.value.paste(text) : focused.value.insert(text.tr("\n", " "))
|
|
220
224
|
end
|
|
221
225
|
|
|
222
226
|
def menu_press(name)
|
|
@@ -344,38 +348,25 @@ module ClaudeInbox
|
|
|
344
348
|
|
|
345
349
|
def field(key) = @fields.find { |f| f.key == key }
|
|
346
350
|
|
|
351
|
+
def directory
|
|
352
|
+
path = field(:cwd).value.to_s.strip
|
|
353
|
+
File.expand_path(path.empty? ? "." : path)
|
|
354
|
+
end
|
|
355
|
+
|
|
347
356
|
def focus_on(key) = @focus = @fields.index { |f| f.key == key }
|
|
348
357
|
|
|
349
|
-
# Keys a :choice field answers to; an editable field spends these on its
|
|
350
|
-
# own text instead.
|
|
351
358
|
def choose(name, raw)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
when :right then cycle(1)
|
|
355
|
-
else
|
|
356
|
-
case raw
|
|
357
|
-
when "h" then cycle(-1)
|
|
358
|
-
when "l", " " then cycle(1)
|
|
359
|
-
end
|
|
360
|
-
end
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
def cycle(d)
|
|
359
|
+
d = CYCLE[name] || CYCLE[raw]
|
|
360
|
+
return unless d
|
|
364
361
|
f = focused
|
|
365
|
-
return unless f.kind == :choice
|
|
366
362
|
f.value = f.choices[(f.choices.index(f.value) + d) % f.choices.size]
|
|
367
363
|
end
|
|
368
364
|
|
|
369
365
|
def submit(attach:)
|
|
370
|
-
|
|
371
|
-
if
|
|
372
|
-
@error =
|
|
373
|
-
focus_on(
|
|
374
|
-
return :changed
|
|
375
|
-
end
|
|
376
|
-
unless File.directory?(v[:cwd])
|
|
377
|
-
@error = "no such directory: #{v[:cwd]}"
|
|
378
|
-
focus_on(:cwd)
|
|
366
|
+
field, message = SessionRequest.problem(values)
|
|
367
|
+
if field
|
|
368
|
+
@error = message
|
|
369
|
+
focus_on(field)
|
|
379
370
|
return :changed
|
|
380
371
|
end
|
|
381
372
|
@busy = true
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-cursor"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# Diffs successive frames and writes only changed rows. No erase-to-end-of-
|
|
7
|
+
# line after a row: in the terminal's last column the cursor stays put
|
|
8
|
+
# (pending wrap), so EL would eat the glyph just drawn.
|
|
9
|
+
class Painter
|
|
10
|
+
def initialize(out)
|
|
11
|
+
@out = out
|
|
12
|
+
@prev = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def paint(lines)
|
|
16
|
+
buf = +""
|
|
17
|
+
lines.each_with_index do |line, i|
|
|
18
|
+
next if @prev[i] == line
|
|
19
|
+
buf << TTY::Cursor.move_to(0, i) << line
|
|
20
|
+
end
|
|
21
|
+
if @prev.size > lines.size
|
|
22
|
+
(lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
|
|
23
|
+
end
|
|
24
|
+
@out.print buf unless buf.empty?
|
|
25
|
+
@out.flush
|
|
26
|
+
@prev = lines
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def invalidate = @prev = []
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/claude_inbox/palette.rb
CHANGED
|
@@ -17,13 +17,10 @@ module ClaudeInbox
|
|
|
17
17
|
|
|
18
18
|
INDEXED = {"orange" => 208, "pink" => 205}.freeze
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
SEQUENCES = ANSI.transform_values { |n| "\e[#{n}m" }
|
|
21
|
+
.merge(INDEXED.transform_values { |n| "\e[38;5;#{n}m" }).freeze
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
return "\e[#{ANSI[name]}m" if ANSI.key?(name)
|
|
24
|
-
return "\e[38;5;#{INDEXED[name]}m" if INDEXED.key?(name)
|
|
25
|
-
nil
|
|
26
|
-
end
|
|
23
|
+
RESET = "\e[39m"
|
|
27
24
|
|
|
28
25
|
def initialize(enabled: true)
|
|
29
26
|
@enabled = enabled
|
|
@@ -31,7 +28,7 @@ module ClaudeInbox
|
|
|
31
28
|
|
|
32
29
|
def paint(text, name)
|
|
33
30
|
return text unless @enabled
|
|
34
|
-
seq =
|
|
31
|
+
seq = SEQUENCES[name]
|
|
35
32
|
seq ? seq + text + RESET : text
|
|
36
33
|
end
|
|
37
34
|
end
|
data/lib/claude_inbox/paste.rb
CHANGED
data/lib/claude_inbox/peek.rb
CHANGED
|
@@ -44,9 +44,9 @@ module ClaudeInbox
|
|
|
44
44
|
|
|
45
45
|
# `row` is the selected row as the frame shows it, or nil when nothing is
|
|
46
46
|
# selected. Answers nil when there is no pane to paint.
|
|
47
|
-
def view(row,
|
|
47
|
+
def view(row, body_h)
|
|
48
48
|
return nil unless @open && @selected&.row?
|
|
49
|
-
View.new(scrolled(body(row),
|
|
49
|
+
View.new(scrolled(body(row), body_h), row&.label || @selected.key, subtitle(row))
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
private
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -1,47 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "actions"
|
|
4
4
|
require_relative "sessions"
|
|
5
|
+
require_relative "snapshot"
|
|
5
6
|
|
|
6
7
|
module ClaudeInbox
|
|
7
8
|
# Asks `claude agents` for the list off the main thread, every INTERVAL
|
|
8
|
-
# seconds and on demand,
|
|
9
|
-
# queue: [:sessions, list] for each
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
9
|
+
# seconds and on demand, puts what it finds in the store and the snapshot,
|
|
10
|
+
# and tells App over the shared queue: [:sessions, list] for each
|
|
11
|
+
# hand-over, [:error, msg] when a poll fails, [:notice, text] when the
|
|
12
|
+
# reaper took something. Between polls it applies what another front end
|
|
13
|
+
# asked for. Nothing here touches App's state directly; the queue is the
|
|
14
|
+
# whole of the interface. One worker runs every poll, so two can never
|
|
15
|
+
# overlap and publish the list out of order.
|
|
14
16
|
class Poller
|
|
15
17
|
INTERVAL = 4
|
|
18
|
+
ACTIONS_INTERVAL = 0.5
|
|
16
19
|
|
|
17
|
-
def initialize(client:, store:, pull_requests:, reaper:, queue:,
|
|
20
|
+
def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot: Snapshot.disabled, actions: Actions.disabled,
|
|
21
|
+
interval: INTERVAL, clock: -> { Time.now })
|
|
18
22
|
@client = client
|
|
19
23
|
@clock = clock
|
|
20
24
|
@store = store
|
|
21
25
|
@pull_requests = pull_requests
|
|
22
26
|
@reaper = reaper
|
|
23
27
|
@queue = queue
|
|
28
|
+
@snapshot = snapshot
|
|
29
|
+
@actions = actions
|
|
24
30
|
@interval = interval
|
|
25
31
|
@wake = Queue.new
|
|
26
|
-
@paused = false
|
|
27
|
-
@lock = Mutex.new
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def start
|
|
31
35
|
return if @thread&.alive?
|
|
32
36
|
soon
|
|
33
37
|
@thread = Thread.new { worker }
|
|
38
|
+
@applier = Thread.new { applier }
|
|
34
39
|
end
|
|
35
40
|
|
|
36
|
-
def stop
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
# another process holds the terminal. A poll already under way finishes.
|
|
40
|
-
def pause = @lock.synchronize { @paused = true }
|
|
41
|
-
|
|
42
|
-
def resume
|
|
43
|
-
@lock.synchronize { @paused = false }
|
|
44
|
-
soon
|
|
41
|
+
def stop
|
|
42
|
+
@thread&.kill
|
|
43
|
+
@applier&.kill
|
|
45
44
|
end
|
|
46
45
|
|
|
47
46
|
def soon = @wake << true
|
|
@@ -58,35 +57,60 @@ module ClaudeInbox
|
|
|
58
57
|
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
|
|
59
58
|
doomed = @reaper.due(sessions, now)
|
|
60
59
|
@store.hide(doomed)
|
|
61
|
-
|
|
60
|
+
publish(sessions)
|
|
62
61
|
reaped = []
|
|
63
62
|
begin
|
|
64
63
|
reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
|
|
65
64
|
ensure
|
|
66
65
|
@store.release(doomed - reaped)
|
|
67
66
|
end
|
|
68
|
-
|
|
67
|
+
publish(sessions) if reaped != doomed
|
|
69
68
|
@queue << [:notice, @reaper.report(reaped)] if reaped.any?
|
|
70
69
|
fresh, moved = @pull_requests.refresh(sessions)
|
|
71
|
-
|
|
70
|
+
publish(fresh) if moved
|
|
72
71
|
rescue => e
|
|
73
72
|
@queue << [:error, e.message]
|
|
74
73
|
end
|
|
75
74
|
|
|
75
|
+
# Applies the requests another front end left, and says whether any landed.
|
|
76
|
+
def apply
|
|
77
|
+
return false unless @actions.drain(@store) > 0
|
|
78
|
+
write_snapshot
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
76
82
|
private
|
|
77
83
|
|
|
84
|
+
def publish(sessions)
|
|
85
|
+
@store.update(sessions)
|
|
86
|
+
write_snapshot
|
|
87
|
+
@queue << [:sessions, sessions]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_snapshot
|
|
91
|
+
now = @clock.call
|
|
92
|
+
@snapshot.write(@store.sections(now), now)
|
|
93
|
+
end
|
|
94
|
+
|
|
78
95
|
# Past StandardError `once` does not catch, and a dead worker would end
|
|
79
96
|
# polling with nothing on screen to say so.
|
|
80
97
|
def worker
|
|
81
98
|
loop do
|
|
82
99
|
@wake.pop(timeout: @interval)
|
|
83
100
|
@wake.clear
|
|
84
|
-
once
|
|
101
|
+
once
|
|
85
102
|
rescue SystemStackError, ScriptError, SecurityError => e
|
|
86
103
|
@queue << [:error, e.message]
|
|
87
104
|
end
|
|
88
105
|
end
|
|
89
106
|
|
|
90
|
-
def
|
|
107
|
+
def applier
|
|
108
|
+
loop do
|
|
109
|
+
sleep ACTIONS_INTERVAL
|
|
110
|
+
apply
|
|
111
|
+
rescue => e
|
|
112
|
+
@queue << [:error, e.message]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
91
115
|
end
|
|
92
116
|
end
|