claude-inbox 0.1.0 → 0.3.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 +185 -386
- data/exe/claude-inbox +23 -5
- data/lib/claude_inbox/agents_client.rb +104 -44
- data/lib/claude_inbox/app.rb +114 -62
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +10 -4
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +32 -9
- data/lib/claude_inbox/keymap.rb +2 -2
- data/lib/claude_inbox/logs.rb +5 -2
- data/lib/claude_inbox/new_session_form.rb +64 -52
- data/lib/claude_inbox/painter.rb +32 -0
- data/lib/claude_inbox/palette.rb +4 -9
- data/lib/claude_inbox/peek.rb +4 -4
- data/lib/claude_inbox/poller.rb +9 -14
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/rate_limits.rb +19 -7
- data/lib/claude_inbox/reaper.rb +13 -16
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +137 -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 +436 -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 +202 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +135 -113
- data/lib/claude_inbox/session.rb +19 -19
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +2 -3
- data/lib/claude_inbox/settings.rb +19 -7
- data/lib/claude_inbox/slash_commands.rb +6 -6
- data/lib/claude_inbox/store/row.rb +3 -3
- data/lib/claude_inbox/store.rb +11 -4
- data/lib/claude_inbox/subprocess.rb +12 -3
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +13 -11
- data/lib/claude_inbox/text_buffer.rb +3 -2
- data/lib/claude_inbox/theme.rb +5 -4
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -3
- data/lib/claude_inbox.rb +1 -1
- metadata +15 -2
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -10,6 +10,8 @@ module ClaudeInbox
|
|
|
10
10
|
# the details read off the dialog afterwards. `frame(width)` is the box
|
|
11
11
|
# Renderer overlays. Pure: nothing here touches the store or the client.
|
|
12
12
|
class Dialog
|
|
13
|
+
WIDTH = 44
|
|
14
|
+
|
|
13
15
|
attr_reader :kind, :id
|
|
14
16
|
|
|
15
17
|
def initialize(kind, id)
|
|
@@ -18,7 +20,7 @@ module ClaudeInbox
|
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def frame(width, caret = nil)
|
|
21
|
-
box = [width - 4,
|
|
23
|
+
box = [width - 4, self.class::WIDTH].min
|
|
22
24
|
TTY::Box.frame(lines(box - 4, caret).join("\n"), title: {top_left: title}, padding: [0, 1], width: box)
|
|
23
25
|
.split("\n")
|
|
24
26
|
end
|
|
@@ -49,10 +51,11 @@ module ClaudeInbox
|
|
|
49
51
|
end
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
# `X` stop
|
|
53
|
-
# words differ because the outcomes do: read the
|
|
54
|
+
# `X` stop, `Ctrl-x` delete and Enter on a remote row all ask first and
|
|
55
|
+
# all take `y`, but the words differ because the outcomes do: read the
|
|
56
|
+
# box before answering.
|
|
54
57
|
class Confirm < Dialog
|
|
55
|
-
TITLES = {stop: " Stop ", delete: " Delete "}.freeze
|
|
58
|
+
TITLES = {stop: " Stop ", delete: " Delete ", adopt: " Adopt "}.freeze
|
|
56
59
|
|
|
57
60
|
def title = TITLES.fetch(kind)
|
|
58
61
|
|
|
@@ -62,6 +65,9 @@ module ClaudeInbox
|
|
|
62
65
|
when :delete
|
|
63
66
|
[" Delete session #{id}?", " Its worktree and conversation", " go with it.",
|
|
64
67
|
"", " y delete it", " esc keep it"]
|
|
68
|
+
when :adopt
|
|
69
|
+
[" Pull this session into the daemon?", " Its phone session ends and a",
|
|
70
|
+
" new one takes its place.", "", " y adopt it", " esc cancel"]
|
|
65
71
|
end
|
|
66
72
|
end
|
|
67
73
|
|
data/lib/claude_inbox/images.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
require_relative "subprocess"
|
|
5
6
|
|
|
6
7
|
module ClaudeInbox
|
|
7
|
-
# Images a prompt can carry: a dropped file,
|
|
8
|
-
# clipboard.
|
|
8
|
+
# Images a prompt can carry: a dropped file, whatever is on the
|
|
9
|
+
# clipboard, or the bytes a request sent. Each comes back as a path for
|
|
10
|
+
# the prompt to mention.
|
|
9
11
|
module Images
|
|
10
12
|
DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "images")
|
|
11
13
|
EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .svg].freeze
|
|
@@ -14,9 +16,19 @@ module ClaudeInbox
|
|
|
14
16
|
|
|
15
17
|
Clipboard = Struct.new(:image, :text)
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
19
|
+
class Unsupported < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Bytes from a request carry no file name worth trusting, so the type
|
|
22
|
+
# comes from the magic number at the front.
|
|
23
|
+
MAGIC = {
|
|
24
|
+
".png" => /\A\x89PNG/n,
|
|
25
|
+
".jpg" => /\A\xFF\xD8\xFF/n,
|
|
26
|
+
".gif" => /\AGIF8/n,
|
|
27
|
+
".webp" => /\ARIFF.{4}WEBP/mn
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# macOS only: osascript ships with the OS, and PNG is the flavor a
|
|
31
|
+
# screenshot puts there.
|
|
20
32
|
def self.from_clipboard(dir: DEFAULT_DIR, now: Time.now, run: Subprocess.method(:capture))
|
|
21
33
|
FileUtils.mkdir_p(dir)
|
|
22
34
|
prune(dir, now)
|
|
@@ -26,6 +38,23 @@ module ClaudeInbox
|
|
|
26
38
|
Clipboard.new(nil, (text.success? && !text.out.empty?) ? text.out : nil)
|
|
27
39
|
end
|
|
28
40
|
|
|
41
|
+
# `index` orders the images one request brings; the random part keeps
|
|
42
|
+
# two requests saving in the same millisecond apart. Readable by the
|
|
43
|
+
# owner only: a photo can be anything.
|
|
44
|
+
def self.save(bytes, dir: DEFAULT_DIR, now: Time.now, index: 1)
|
|
45
|
+
data = bytes.b
|
|
46
|
+
ext = extension(data)
|
|
47
|
+
raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
|
|
48
|
+
FileUtils.mkdir_p(dir)
|
|
49
|
+
prune(dir, now)
|
|
50
|
+
path = File.join(dir, "#{now.strftime("%Y%m%d-%H%M%S-%L")}-#{index}-#{SecureRandom.hex(3)}#{ext}")
|
|
51
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(data) }
|
|
52
|
+
path
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ".png", ".jpg", ".gif" or ".webp" by the magic number, or nil.
|
|
56
|
+
def self.extension(bytes) = MAGIC.find { |_, magic| magic.match?(bytes.b) }&.first
|
|
57
|
+
|
|
29
58
|
CLIPBOARD_PNG = <<~APPLESCRIPT
|
|
30
59
|
on run argv
|
|
31
60
|
set png to the clipboard as «class PNGf»
|
|
@@ -46,8 +75,10 @@ module ClaudeInbox
|
|
|
46
75
|
File.file?(path) ? path : nil
|
|
47
76
|
end
|
|
48
77
|
|
|
78
|
+
# Pruned on the way in, because nothing else knows when a session
|
|
79
|
+
# stopped needing its image.
|
|
49
80
|
def self.prune(dir, now)
|
|
50
|
-
Dir.glob(File.join(dir, "
|
|
81
|
+
Dir.glob(EXTENSIONS.map { |ext| File.join(dir, "*#{ext}") }).each do |f|
|
|
51
82
|
File.delete(f) if now - File.mtime(f) > KEEP_FOR
|
|
52
83
|
rescue SystemCallError
|
|
53
84
|
nil
|
|
@@ -13,8 +13,9 @@ module ClaudeInbox
|
|
|
13
13
|
# each outstanding piece of work, and `detail` is the session's own status
|
|
14
14
|
# line, with `needs` naming what it is waiting on while blocked and
|
|
15
15
|
# `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
|
|
16
|
-
# transcript, which PullRequests reads through here,
|
|
17
|
-
# set on the session, which `claude agents --json` drops
|
|
16
|
+
# transcript, which PullRequests reads through here, the color `/color`
|
|
17
|
+
# set on the session, which `claude agents --json` drops, and `intent`, the
|
|
18
|
+
# prompt the session was started from.
|
|
18
19
|
#
|
|
19
20
|
# Never cached. A color changes the moment you type `/color`, so every poll
|
|
20
21
|
# asks the file again.
|
|
@@ -49,7 +50,7 @@ module ClaudeInbox
|
|
|
49
50
|
nil
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
attr_reader :detail, :needs, :result, :
|
|
53
|
+
attr_reader :detail, :needs, :result, :pr_urls, :color, :intent, :bridge_id
|
|
53
54
|
|
|
54
55
|
def initialize(hash)
|
|
55
56
|
@detail = hash["detail"]
|
|
@@ -60,18 +61,31 @@ module ClaudeInbox
|
|
|
60
61
|
@tasks = (hash["inFlight"] || {})["tasks"].to_i
|
|
61
62
|
@pr_urls = (hash["children"] || []).select { |c| c["kind"] == "pr" && c["href"] }.map { |c| c["href"] }
|
|
62
63
|
@color = hash["color"]
|
|
64
|
+
@intent = hash["intent"]
|
|
65
|
+
@bridge_id = hash["bridgeSessionId"]
|
|
66
|
+
@flags = hash["respawnFlags"] || []
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
# The
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
def agent_idle? = tempo == "idle"
|
|
69
|
-
|
|
70
|
-
def in_flight? = tasks.positive?
|
|
69
|
+
# The flags the session was started with, which the daemon puts back on
|
|
70
|
+
# a wake, are the only record of Remote Control being on it.
|
|
71
|
+
def remote_control? = flags.include?("--remote-control")
|
|
71
72
|
|
|
72
73
|
# True when the agent has stopped and is only waiting on what it started.
|
|
73
74
|
def waiting_on_work? = agent_idle? && in_flight?
|
|
74
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
|
+
|
|
75
89
|
# "1 shell", "2 agents · 1 shell". Falls back to a bare count for a state
|
|
76
90
|
# file that counts the open tasks without naming them.
|
|
77
91
|
def in_flight_label
|
|
@@ -82,6 +96,15 @@ module ClaudeInbox
|
|
|
82
96
|
|
|
83
97
|
private
|
|
84
98
|
|
|
99
|
+
attr_reader :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
|
+
|
|
85
108
|
def count_label = "#{tasks} #{plural("task", tasks)}"
|
|
86
109
|
|
|
87
110
|
def plural(word, n) = (n == 1) ? word : "#{word}s"
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -26,8 +26,8 @@ module ClaudeInbox
|
|
|
26
26
|
"h" => :collapse,
|
|
27
27
|
"s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
|
|
28
28
|
:ctrl_x => :delete,
|
|
29
|
-
"o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin,
|
|
30
|
-
"R" => :refresh, "p" => :toggle_peek, "n" => :new_session,
|
|
29
|
+
"o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
|
|
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
|
data/lib/claude_inbox/logs.rb
CHANGED
|
@@ -18,8 +18,11 @@ module ClaudeInbox
|
|
|
18
18
|
@pending = nil
|
|
19
19
|
@pending_at = nil
|
|
20
20
|
@requests = Queue.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
return if @thread&.alive?
|
|
21
25
|
@thread = Thread.new { worker }
|
|
22
|
-
@thread.abort_on_exception = false
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def cached(id)
|
|
@@ -42,7 +45,7 @@ module ClaudeInbox
|
|
|
42
45
|
@requests << id unless fresh
|
|
43
46
|
end
|
|
44
47
|
|
|
45
|
-
def stop = @thread
|
|
48
|
+
def stop = @thread&.kill
|
|
46
49
|
|
|
47
50
|
private
|
|
48
51
|
|
|
@@ -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 = [
|
|
@@ -35,7 +39,8 @@ module ClaudeInbox
|
|
|
35
39
|
Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
|
|
36
40
|
Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
|
|
37
41
|
Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
|
|
38
|
-
Field.new(:worktree, "Worktree", :choice, "no", %w[no yes])
|
|
42
|
+
Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
|
|
43
|
+
Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
|
|
39
44
|
]
|
|
40
45
|
@focus = 0
|
|
41
46
|
@error = nil
|
|
@@ -44,12 +49,19 @@ module ClaudeInbox
|
|
|
44
49
|
@pick = 0
|
|
45
50
|
@dismissed = nil
|
|
46
51
|
@confirm_discard = false
|
|
52
|
+
@busy = false
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def focused = @fields[@focus]
|
|
50
56
|
|
|
51
57
|
# => :cancel | :start | :start_and_attach | :changed
|
|
52
58
|
def press(name, raw)
|
|
59
|
+
# `:start`/`:start_and_attach` leave the form up until the spawn is
|
|
60
|
+
# known to have worked, so a failure (untrusted directory, `claude`
|
|
61
|
+
# missing, ...) can hand the composed prompt back instead of losing
|
|
62
|
+
# it; there is no way to cancel a spawn already under way, so every
|
|
63
|
+
# key is swallowed until the App reports back.
|
|
64
|
+
return :changed if @busy
|
|
53
65
|
return confirm_discard_press(name, raw) if @confirm_discard
|
|
54
66
|
@error = nil
|
|
55
67
|
@candidates = nil
|
|
@@ -106,18 +118,18 @@ module ClaudeInbox
|
|
|
106
118
|
def picked = menu&.fetch(@pick.clamp(0, menu.size - 1))
|
|
107
119
|
|
|
108
120
|
def values
|
|
109
|
-
@fields.to_h { |f| [f.key, f.value.to_s] }
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
v = @fields.to_h { |f| [f.key, (f.kind == :choice && f.value == DEFAULT) ? nil : f.value.to_s] }
|
|
122
|
+
v[:prompt] = field(:prompt).value.expand { |chip| AgentsClient.mention(chip.path) }.strip
|
|
123
|
+
v[:worktree] = v[:worktree] == "yes"
|
|
124
|
+
v[:name] = nil if v[:name].strip.empty?
|
|
125
|
+
v[:cwd] = directory
|
|
126
|
+
v[:remote] = SessionRequest::FLAGS[v[:remote]]
|
|
127
|
+
SessionRequest.resolve(v, defaults(v[:cwd]))
|
|
115
128
|
end
|
|
116
129
|
|
|
117
130
|
# Settings resolve against the directory the session will run in, so
|
|
118
131
|
# they follow the Directory field.
|
|
119
|
-
def defaults
|
|
120
|
-
cwd = values[:cwd]
|
|
132
|
+
def defaults(cwd = directory)
|
|
121
133
|
return @defaults if @defaults_for == cwd
|
|
122
134
|
@defaults_for = cwd
|
|
123
135
|
@defaults = Settings.defaults(cwd, home: @home)
|
|
@@ -126,7 +138,7 @@ module ClaudeInbox
|
|
|
126
138
|
# Project commands live under the Directory field's path, so they
|
|
127
139
|
# follow it as the defaults do.
|
|
128
140
|
def commands
|
|
129
|
-
cwd =
|
|
141
|
+
cwd = directory
|
|
130
142
|
return @commands if @commands_for == cwd
|
|
131
143
|
@commands_for = cwd
|
|
132
144
|
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
@@ -140,27 +152,23 @@ module ClaudeInbox
|
|
|
140
152
|
menu_rows = menu_lines(inner_w, [height - fixed - 3, MENU_ROWS].min)
|
|
141
153
|
prompt_h = [height - fixed - menu_rows.size, 3].max
|
|
142
154
|
out = [""]
|
|
143
|
-
|
|
155
|
+
title = @busy ? @p.dim("Starting session…") : @p.bold("New session")
|
|
156
|
+
out << " " + (@confirm_discard ? @theme.red("Discard this session? (y/n)") : title)
|
|
144
157
|
out << ""
|
|
145
|
-
out << " " + field_label(
|
|
146
|
-
out += prompt_box(
|
|
158
|
+
out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
|
|
159
|
+
out += prompt_box(field(:prompt), inner_w, prompt_h)
|
|
147
160
|
out += menu_rows
|
|
148
161
|
out << ""
|
|
149
|
-
@fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w -
|
|
162
|
+
@fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 16) }
|
|
150
163
|
out.first(height) + [""] * [height - out.size, 0].max
|
|
151
164
|
end
|
|
152
165
|
|
|
153
166
|
def footer
|
|
154
|
-
if @
|
|
155
|
-
|
|
156
|
-
.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
157
|
-
end
|
|
167
|
+
return @p.dim("starting session…") if @busy
|
|
168
|
+
return hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
|
|
158
169
|
return @theme.red(@error) if @error
|
|
159
170
|
return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
|
|
160
|
-
if menu
|
|
161
|
-
return [["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]
|
|
162
|
-
.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
163
|
-
end
|
|
171
|
+
return hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
|
|
164
172
|
keys =
|
|
165
173
|
case focused.kind
|
|
166
174
|
when :multiline then [["⏎", "newline"], ["^V", "image"]]
|
|
@@ -169,14 +177,24 @@ module ClaudeInbox
|
|
|
169
177
|
end
|
|
170
178
|
keys += [["^S", "start"], ["^O", "start & open"],
|
|
171
179
|
["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
|
|
172
|
-
keys
|
|
180
|
+
hints(keys)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The App's `claude --bg` call failed after `:start`/`:start_and_attach`
|
|
184
|
+
# already handed the form off — an untrusted directory, a stale binary,
|
|
185
|
+
# a permissions error. The composed prompt is still here, so the form
|
|
186
|
+
# reopens on it with the reason shown instead of the App just logging
|
|
187
|
+
# the failure and losing the draft.
|
|
188
|
+
def submission_failed(message)
|
|
189
|
+
@busy = false
|
|
190
|
+
@error = message
|
|
173
191
|
end
|
|
174
192
|
|
|
175
193
|
private
|
|
176
194
|
|
|
177
195
|
# Esc with a prompt typed asks first, so a stray keypress can't lose it.
|
|
178
196
|
def escape_pressed
|
|
179
|
-
return :cancel if
|
|
197
|
+
return :cancel if field(:prompt).value.empty?
|
|
180
198
|
@confirm_discard = true
|
|
181
199
|
:changed
|
|
182
200
|
end
|
|
@@ -242,9 +260,11 @@ module ClaudeInbox
|
|
|
242
260
|
rows
|
|
243
261
|
end
|
|
244
262
|
|
|
263
|
+
def hints(pairs) = pairs.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
264
|
+
|
|
245
265
|
def field_label(f)
|
|
246
266
|
on = f.equal?(focused)
|
|
247
|
-
(on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label,
|
|
267
|
+
(on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
|
|
248
268
|
end
|
|
249
269
|
|
|
250
270
|
# The cell the cursor sits on, drawn as a block by inverting it: a bar
|
|
@@ -325,38 +345,30 @@ module ClaudeInbox
|
|
|
325
345
|
|
|
326
346
|
def move(d) = @focus = (@focus + d) % @fields.size
|
|
327
347
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
when :right then cycle(1)
|
|
334
|
-
else
|
|
335
|
-
case raw
|
|
336
|
-
when "h" then cycle(-1)
|
|
337
|
-
when "l", " " then cycle(1)
|
|
338
|
-
end
|
|
339
|
-
end
|
|
348
|
+
def field(key) = @fields.find { |f| f.key == key }
|
|
349
|
+
|
|
350
|
+
def directory
|
|
351
|
+
path = field(:cwd).value.to_s.strip
|
|
352
|
+
File.expand_path(path.empty? ? "." : path)
|
|
340
353
|
end
|
|
341
354
|
|
|
342
|
-
def
|
|
355
|
+
def focus_on(key) = @focus = @fields.index { |f| f.key == key }
|
|
356
|
+
|
|
357
|
+
def choose(name, raw)
|
|
358
|
+
d = CYCLE[name] || CYCLE[raw]
|
|
359
|
+
return unless d
|
|
343
360
|
f = focused
|
|
344
|
-
return unless f.kind == :choice
|
|
345
361
|
f.value = f.choices[(f.choices.index(f.value) + d) % f.choices.size]
|
|
346
362
|
end
|
|
347
363
|
|
|
348
364
|
def submit(attach:)
|
|
349
|
-
|
|
350
|
-
if
|
|
351
|
-
@error =
|
|
352
|
-
|
|
353
|
-
return :changed
|
|
354
|
-
end
|
|
355
|
-
unless File.directory?(v[:cwd])
|
|
356
|
-
@error = "no such directory: #{v[:cwd]}"
|
|
357
|
-
@focus = 2
|
|
365
|
+
field, message = SessionRequest.problem(values)
|
|
366
|
+
if field
|
|
367
|
+
@error = message
|
|
368
|
+
focus_on(field)
|
|
358
369
|
return :changed
|
|
359
370
|
end
|
|
371
|
+
@busy = true
|
|
360
372
|
attach ? :start_and_attach : :start
|
|
361
373
|
end
|
|
362
374
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
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, cursor: TTY::Cursor)
|
|
11
|
+
@out = out
|
|
12
|
+
@cursor = cursor
|
|
13
|
+
@prev = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def paint(lines)
|
|
17
|
+
buf = +""
|
|
18
|
+
lines.each_with_index do |line, i|
|
|
19
|
+
next if @prev[i] == line
|
|
20
|
+
buf << @cursor.move_to(0, i) << line
|
|
21
|
+
end
|
|
22
|
+
if @prev.size > lines.size
|
|
23
|
+
(lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
|
|
24
|
+
end
|
|
25
|
+
@out.print buf unless buf.empty?
|
|
26
|
+
@out.flush
|
|
27
|
+
@prev = lines
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def invalidate = @prev = []
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/claude_inbox/palette.rb
CHANGED
|
@@ -17,15 +17,10 @@ module ClaudeInbox
|
|
|
17
17
|
|
|
18
18
|
INDEXED = {"orange" => 208, "pink" => 205}.freeze
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def self.known?(name) = ANSI.key?(name) || INDEXED.key?(name)
|
|
20
|
+
SEQUENCES = ANSI.transform_values { |n| "\e[#{n}m" }
|
|
21
|
+
.merge(INDEXED.transform_values { |n| "\e[38;5;#{n}m" }).freeze
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
return "\e[#{ANSI[name]}m" if ANSI.key?(name)
|
|
26
|
-
return "\e[38;5;#{INDEXED[name]}m" if INDEXED.key?(name)
|
|
27
|
-
nil
|
|
28
|
-
end
|
|
23
|
+
RESET = "\e[39m"
|
|
29
24
|
|
|
30
25
|
def initialize(enabled: true)
|
|
31
26
|
@enabled = enabled
|
|
@@ -33,7 +28,7 @@ module ClaudeInbox
|
|
|
33
28
|
|
|
34
29
|
def paint(text, name)
|
|
35
30
|
return text unless @enabled
|
|
36
|
-
seq =
|
|
31
|
+
seq = SEQUENCES[name]
|
|
37
32
|
seq ? seq + text + RESET : text
|
|
38
33
|
end
|
|
39
34
|
end
|
data/lib/claude_inbox/peek.rb
CHANGED
|
@@ -7,7 +7,7 @@ module ClaudeInbox
|
|
|
7
7
|
# lands on a warm cache. Main-thread state only.
|
|
8
8
|
class Peek
|
|
9
9
|
TERMINAL_NOTE = "This is a claude you opened in a terminal yourself. The daemon can't attach to it, read its output, or stop it from outside. Switch to that window."
|
|
10
|
-
REMOTE_NOTE = "This is a Remote Control session driven from claude.ai/code. The daemon can't attach to it or read its output from here.
|
|
10
|
+
REMOTE_NOTE = "This is a Remote Control session driven from claude.ai/code. The daemon can't attach to it or read its output from here. Enter pulls it into the daemon; w opens it in the web or mobile app."
|
|
11
11
|
|
|
12
12
|
# What Renderer paints: the body lines, the title bar, the dim line under it.
|
|
13
13
|
View = Struct.new(:lines, :title, :subtitle)
|
|
@@ -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
|
|
@@ -58,7 +58,7 @@ module ClaudeInbox
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def interactive_note(s)
|
|
61
|
-
[s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}"]
|
|
61
|
+
[s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}", s.remote_url].compact
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def subtitle(row)
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "reaper"
|
|
4
3
|
require_relative "sessions"
|
|
5
|
-
require_relative "store"
|
|
6
4
|
|
|
7
5
|
module ClaudeInbox
|
|
8
6
|
# Asks `claude agents` for the list off the main thread, every INTERVAL
|
|
@@ -15,11 +13,11 @@ module ClaudeInbox
|
|
|
15
13
|
class Poller
|
|
16
14
|
INTERVAL = 4
|
|
17
15
|
|
|
18
|
-
def initialize(client:, store:, pull_requests:,
|
|
16
|
+
def initialize(client:, store:, pull_requests:, reaper:, queue:, interval: INTERVAL, clock: -> { Time.now })
|
|
19
17
|
@client = client
|
|
18
|
+
@clock = clock
|
|
20
19
|
@store = store
|
|
21
20
|
@pull_requests = pull_requests
|
|
22
|
-
@jobs_dir = jobs_dir
|
|
23
21
|
@reaper = reaper
|
|
24
22
|
@queue = queue
|
|
25
23
|
@interval = interval
|
|
@@ -36,8 +34,10 @@ module ClaudeInbox
|
|
|
36
34
|
|
|
37
35
|
def stop = @thread&.kill
|
|
38
36
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
37
|
+
# Only skips polls while another process holds the terminal; a poll
|
|
38
|
+
# already under way finishes. Forking from another thread meanwhile, as
|
|
39
|
+
# a remote start does, is fine: Subprocess puts every child in its own
|
|
40
|
+
# session, away from the tty.
|
|
41
41
|
def pause = @lock.synchronize { @paused = true }
|
|
42
42
|
|
|
43
43
|
def resume
|
|
@@ -55,8 +55,8 @@ module ClaudeInbox
|
|
|
55
55
|
# dropped it. The gh refresh comes last and publishes again only if a PR
|
|
56
56
|
# state moved.
|
|
57
57
|
def once
|
|
58
|
-
now =
|
|
59
|
-
sessions = Sessions.load(client: @client,
|
|
58
|
+
now = @clock.call
|
|
59
|
+
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
|
|
60
60
|
doomed = @reaper.due(sessions, now)
|
|
61
61
|
@store.hide(doomed)
|
|
62
62
|
@queue << [:sessions, sessions]
|
|
@@ -67,7 +67,7 @@ module ClaudeInbox
|
|
|
67
67
|
@store.release(doomed - reaped)
|
|
68
68
|
end
|
|
69
69
|
@queue << [:sessions, sessions] if reaped != doomed
|
|
70
|
-
|
|
70
|
+
@queue << [:notice, @reaper.report(reaped)] if reaped.any?
|
|
71
71
|
fresh, moved = @pull_requests.refresh(sessions)
|
|
72
72
|
@queue << [:sessions, fresh] if moved
|
|
73
73
|
rescue => e
|
|
@@ -89,10 +89,5 @@ module ClaudeInbox
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def paused? = @lock.synchronize { @paused }
|
|
92
|
-
|
|
93
|
-
def notice_reaped(keys)
|
|
94
|
-
word = (keys.size == 1) ? "session" : "sessions"
|
|
95
|
-
@queue << [:notice, "reaped #{keys.size} #{word} idle over #{Store::REAP_AFTER / 86_400}d — see #{@reaper.log_path}"]
|
|
96
|
-
end
|
|
97
92
|
end
|
|
98
93
|
end
|
|
@@ -85,11 +85,6 @@ module ClaudeInbox
|
|
|
85
85
|
[fresh, changed]
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
# Best known state for a url without asking gh.
|
|
89
|
-
def known(url)
|
|
90
|
-
@mutex.synchronize { @known[url] ||= seed(url) }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
88
|
# Best known state for a url, refreshed through gh when due.
|
|
94
89
|
def status(url)
|
|
95
90
|
@mutex.synchronize do
|
|
@@ -119,6 +114,11 @@ module ClaudeInbox
|
|
|
119
114
|
|
|
120
115
|
private
|
|
121
116
|
|
|
117
|
+
# Best known state for a url without asking gh.
|
|
118
|
+
def known(url)
|
|
119
|
+
@mutex.synchronize { @known[url] ||= seed(url) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
122
|
def due?(url)
|
|
123
123
|
@gh && @clock.call.to_i - @checked_at.fetch(url, 0) >= REFRESH_AFTER
|
|
124
124
|
end
|