claude-inbox 0.1.0 → 0.2.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 +149 -358
- data/exe/claude-inbox +0 -1
- data/lib/claude_inbox/agents_client.rb +72 -14
- data/lib/claude_inbox/app.rb +76 -40
- data/lib/claude_inbox/dialog.rb +7 -3
- data/lib/claude_inbox/job_state.rb +11 -3
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/logs.rb +5 -2
- data/lib/claude_inbox/new_session_form.rb +41 -19
- data/lib/claude_inbox/palette.rb +0 -2
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +5 -11
- data/lib/claude_inbox/rate_limits.rb +19 -7
- data/lib/claude_inbox/reaper.rb +13 -16
- data/lib/claude_inbox/renderer.rb +91 -69
- data/lib/claude_inbox/session.rb +17 -3
- data/lib/claude_inbox/sessions.rb +2 -2
- 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 -2
- data/lib/claude_inbox/subprocess.rb +3 -2
- data/lib/claude_inbox/text.rb +6 -10
- data/lib/claude_inbox/text_buffer.rb +3 -2
- data/lib/claude_inbox/theme.rb +5 -4
- data/lib/claude_inbox/vt_screen.rb +0 -2
- data/lib/claude_inbox.rb +1 -1
- metadata +2 -2
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "reaper"
|
|
4
4
|
require_relative "sessions"
|
|
5
|
-
require_relative "store"
|
|
6
5
|
|
|
7
6
|
module ClaudeInbox
|
|
8
7
|
# Asks `claude agents` for the list off the main thread, every INTERVAL
|
|
@@ -15,11 +14,11 @@ module ClaudeInbox
|
|
|
15
14
|
class Poller
|
|
16
15
|
INTERVAL = 4
|
|
17
16
|
|
|
18
|
-
def initialize(client:, store:, pull_requests:,
|
|
17
|
+
def initialize(client:, store:, pull_requests:, reaper:, queue:, interval: INTERVAL, clock: -> { Time.now })
|
|
19
18
|
@client = client
|
|
19
|
+
@clock = clock
|
|
20
20
|
@store = store
|
|
21
21
|
@pull_requests = pull_requests
|
|
22
|
-
@jobs_dir = jobs_dir
|
|
23
22
|
@reaper = reaper
|
|
24
23
|
@queue = queue
|
|
25
24
|
@interval = interval
|
|
@@ -55,8 +54,8 @@ module ClaudeInbox
|
|
|
55
54
|
# dropped it. The gh refresh comes last and publishes again only if a PR
|
|
56
55
|
# state moved.
|
|
57
56
|
def once
|
|
58
|
-
now =
|
|
59
|
-
sessions = Sessions.load(client: @client,
|
|
57
|
+
now = @clock.call
|
|
58
|
+
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
|
|
60
59
|
doomed = @reaper.due(sessions, now)
|
|
61
60
|
@store.hide(doomed)
|
|
62
61
|
@queue << [:sessions, sessions]
|
|
@@ -67,7 +66,7 @@ module ClaudeInbox
|
|
|
67
66
|
@store.release(doomed - reaped)
|
|
68
67
|
end
|
|
69
68
|
@queue << [:sessions, sessions] if reaped != doomed
|
|
70
|
-
|
|
69
|
+
@queue << [:notice, @reaper.report(reaped)] if reaped.any?
|
|
71
70
|
fresh, moved = @pull_requests.refresh(sessions)
|
|
72
71
|
@queue << [:sessions, fresh] if moved
|
|
73
72
|
rescue => e
|
|
@@ -89,10 +88,5 @@ module ClaudeInbox
|
|
|
89
88
|
end
|
|
90
89
|
|
|
91
90
|
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
91
|
end
|
|
98
92
|
end
|
|
@@ -5,7 +5,7 @@ require "json"
|
|
|
5
5
|
module ClaudeInbox
|
|
6
6
|
# The subscription's 5-hour and 7-day usage, read from
|
|
7
7
|
# ~/.claude/rate_limits.json. Nothing writes that file but the user's own
|
|
8
|
-
# status line script (README, "
|
|
8
|
+
# status line script (README, "Setup"): `claude agents --json` says nothing
|
|
9
9
|
# about limits and there is no CLI command for them, but every session hands
|
|
10
10
|
# its status line a `rate_limits` object on each turn, so a one-line tee
|
|
11
11
|
# there is the only source that costs no API calls. No file, or a file
|
|
@@ -13,6 +13,14 @@ module ClaudeInbox
|
|
|
13
13
|
class RateLimits
|
|
14
14
|
DEFAULT_PATH = File.join(Dir.home, ".claude", "rate_limits.json")
|
|
15
15
|
STALE_AFTER = 15 * 60
|
|
16
|
+
# Named as Claude's own /usage screen names them, not by length, so the
|
|
17
|
+
# label never reads like the countdown beside it.
|
|
18
|
+
LABELS = {"five_hour" => "session", "seven_day" => "week"}.freeze
|
|
19
|
+
|
|
20
|
+
# `resets_at` is when the window rolls over, or nil if the file omits it.
|
|
21
|
+
Window = Data.define(:label, :percent, :resets_at) do
|
|
22
|
+
def initialize(label:, percent:, resets_at: nil) = super
|
|
23
|
+
end
|
|
16
24
|
|
|
17
25
|
def initialize(path: DEFAULT_PATH)
|
|
18
26
|
@path = path
|
|
@@ -20,9 +28,11 @@ module ClaudeInbox
|
|
|
20
28
|
@data = nil
|
|
21
29
|
end
|
|
22
30
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
31
|
+
# The windows the file reports, each with a whole percent and its reset
|
|
32
|
+
# time, or nil.
|
|
33
|
+
# Parses only when the file has changed, since render asks several times
|
|
34
|
+
# a second.
|
|
35
|
+
def windows(now = Time.now)
|
|
26
36
|
mtime = File.mtime(@path)
|
|
27
37
|
return nil if now - mtime > STALE_AFTER
|
|
28
38
|
@data = parse if mtime != @mtime
|
|
@@ -36,11 +46,13 @@ module ClaudeInbox
|
|
|
36
46
|
|
|
37
47
|
def parse
|
|
38
48
|
hash = JSON.parse(File.read(@path))
|
|
39
|
-
|
|
49
|
+
windows = LABELS.filter_map do |key, label|
|
|
40
50
|
pct = hash.dig(key, "used_percentage")
|
|
41
|
-
|
|
51
|
+
next unless pct.is_a?(Numeric)
|
|
52
|
+
resets_at = hash.dig(key, "resets_at")
|
|
53
|
+
Window.new(label, pct.round.clamp(0, 100), (Time.at(resets_at) if resets_at.is_a?(Numeric)))
|
|
42
54
|
end
|
|
43
|
-
|
|
55
|
+
windows unless windows.empty?
|
|
44
56
|
rescue JSON::ParserError, TypeError
|
|
45
57
|
nil
|
|
46
58
|
end
|
data/lib/claude_inbox/reaper.rb
CHANGED
|
@@ -9,8 +9,8 @@ module ClaudeInbox
|
|
|
9
9
|
#
|
|
10
10
|
# The one thing in the inbox that destroys anything without being asked
|
|
11
11
|
# first, so the whole of it lives here rather than spread through the poll
|
|
12
|
-
# loop
|
|
13
|
-
#
|
|
12
|
+
# loop, along with the append-only log that is the last record a session
|
|
13
|
+
# ever existed once `claude rm` has taken its transcript.
|
|
14
14
|
#
|
|
15
15
|
# Unpushed work is safe by construction. `claude rm` refuses a worktree
|
|
16
16
|
# holding commits that aren't pushed and reports a --discard-unpushed token
|
|
@@ -20,8 +20,6 @@ module ClaudeInbox
|
|
|
20
20
|
RETRY_AFTER = 24 * 3600
|
|
21
21
|
DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
|
|
22
22
|
|
|
23
|
-
attr_reader :log_path
|
|
24
|
-
|
|
25
23
|
def initialize(client, store, log_path: DEFAULT_LOG, enabled: self.class.enabled?)
|
|
26
24
|
@client = client
|
|
27
25
|
@store = store
|
|
@@ -43,27 +41,26 @@ module ClaudeInbox
|
|
|
43
41
|
# Raises if the log cannot be opened, before anything is deleted. No
|
|
44
42
|
# audit trail, no reaping.
|
|
45
43
|
def sweep(sessions, now)
|
|
46
|
-
return [] unless @enabled
|
|
47
44
|
now_i = now.to_i
|
|
48
|
-
|
|
49
|
-
return [] if
|
|
50
|
-
with_log { |log|
|
|
45
|
+
rows = due_rows(sessions, now_i)
|
|
46
|
+
return [] if rows.empty?
|
|
47
|
+
with_log { |log| rows.filter_map { |row| reap(row, log, now_i) } }
|
|
51
48
|
end
|
|
52
49
|
|
|
53
50
|
# Keys `sweep` would go after right now, without touching any of them,
|
|
54
51
|
# so the poller can hand the list over minus these before `claude rm`.
|
|
55
|
-
def due(sessions, now)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
def due(sessions, now) = due_rows(sessions, now.to_i).map(&:key)
|
|
53
|
+
|
|
54
|
+
def report(keys)
|
|
55
|
+
word = (keys.size == 1) ? "session" : "sessions"
|
|
56
|
+
"reaped #{keys.size} #{word} idle over #{Store::REAP_AFTER / 86_400}d — see #{@log_path}"
|
|
59
57
|
end
|
|
60
58
|
|
|
61
59
|
private
|
|
62
60
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
backing_off?(row, now_i) ? nil : row
|
|
61
|
+
def due_rows(sessions, now_i)
|
|
62
|
+
return [] unless @enabled
|
|
63
|
+
sessions.map { |s| @store.row(s) }.select { |row| row.reapable?(now_i) && !backing_off?(row, now_i) }
|
|
67
64
|
end
|
|
68
65
|
|
|
69
66
|
def backing_off?(row, now_i)
|
|
@@ -20,6 +20,13 @@ module ClaudeInbox
|
|
|
20
20
|
|
|
21
21
|
Frame = Struct.new(:lines, :items, :top, :list_width)
|
|
22
22
|
|
|
23
|
+
# What App hands Renderer for one frame. `peek` is a Peek::View.
|
|
24
|
+
View = Data.define(:width, :height, :now, :selected, :top, :expanded, :peek, :modal, :screen,
|
|
25
|
+
:status, :usage, :filter, :filter_editing, :tick, :loading) do
|
|
26
|
+
def initialize(width:, height:, now:, selected: nil, top: 0, expanded: {}, peek: nil, modal: nil, screen: nil,
|
|
27
|
+
status: nil, usage: nil, filter: nil, filter_editing: false, tick: 0, loading: nil) = super
|
|
28
|
+
end
|
|
29
|
+
|
|
23
30
|
SECTION_TITLES = {
|
|
24
31
|
pinned: "PINNED",
|
|
25
32
|
needs_you: "NEEDS YOU",
|
|
@@ -28,6 +35,8 @@ module ClaudeInbox
|
|
|
28
35
|
settled: "SETTLED"
|
|
29
36
|
}.freeze
|
|
30
37
|
|
|
38
|
+
SECTION_HUES = {pinned: :cyan, needs_you: :red, active: :yellow, snoozed: :purple}.freeze
|
|
39
|
+
|
|
31
40
|
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
32
41
|
|
|
33
42
|
# The first poll usually lands inside a second. Past LOADING_QUIET the
|
|
@@ -62,41 +71,34 @@ module ClaudeInbox
|
|
|
62
71
|
|
|
63
72
|
def caret = ->(cell) { @p.inverse(cell) }
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# tick (Integer, drives the spinner),
|
|
70
|
-
# loading (Float seconds waited for the first poll, nil once it has landed),
|
|
71
|
-
# screen ({lines:, footer:} takes over everything below the header)
|
|
72
|
-
def frame(sections, width:, height:, now:, **opts)
|
|
73
|
-
return full_screen(sections, width, height, now, opts) if opts[:screen]
|
|
74
|
-
selected = opts[:selected]
|
|
75
|
-
list_w = width_for_list(width, opts[:peek])
|
|
74
|
+
def frame(sections, view)
|
|
75
|
+
return full_screen(sections, view) if view.screen
|
|
76
|
+
width, height, now, selected = view.width, view.height, view.now, view.selected
|
|
77
|
+
list_w = width_for_list(width, view.peek)
|
|
76
78
|
view_h = height - 2 # header + footer
|
|
77
79
|
body, items =
|
|
78
|
-
if
|
|
79
|
-
[loading_state(list_w, view_h,
|
|
80
|
+
if view.loading
|
|
81
|
+
[loading_state(list_w, view_h, view.loading, view.tick), []]
|
|
80
82
|
else
|
|
81
|
-
body_lines(sections, list_w,
|
|
83
|
+
body_lines(sections, list_w, view, now)
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
top = clamp_top(
|
|
86
|
+
top = clamp_top(view.top, body.size, view_h, items, selected)
|
|
85
87
|
visible = body[top, view_h] || []
|
|
86
88
|
visible_items = items[top, view_h] || []
|
|
87
89
|
visible += [""] * (view_h - visible.size)
|
|
88
90
|
visible_items += [nil] * (view_h - visible_items.size)
|
|
89
91
|
|
|
90
|
-
if
|
|
92
|
+
if view.peek
|
|
91
93
|
peek_w = width - list_w - 1
|
|
92
|
-
peek_lines = peek_pane(
|
|
94
|
+
peek_lines = peek_pane(view.peek, peek_w, view_h)
|
|
93
95
|
visible = visible.each_with_index.map do |l, i|
|
|
94
96
|
Text.pad(l, list_w) + @p.dim("│") + Text.pad(peek_lines[i] || "", peek_w)
|
|
95
97
|
end
|
|
96
98
|
end
|
|
97
99
|
|
|
98
|
-
lines = [header(sections, width,
|
|
99
|
-
lines = overlay(lines,
|
|
100
|
+
lines = [header(sections, width, view)] + visible.map { |l| Text.pad(l, width) } + [footer(width, view)]
|
|
101
|
+
lines = overlay(lines, view.modal, width) if view.modal
|
|
100
102
|
Frame.new(lines, [nil] + visible_items + [nil], top, list_w)
|
|
101
103
|
end
|
|
102
104
|
|
|
@@ -114,53 +116,79 @@ module ClaudeInbox
|
|
|
114
116
|
top.clamp(0, [size - view_h, 0].max)
|
|
115
117
|
end
|
|
116
118
|
|
|
117
|
-
def full_screen(sections,
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
def full_screen(sections, view)
|
|
120
|
+
width = view.width
|
|
121
|
+
view_h = view.height - 2
|
|
122
|
+
body = view.screen[:lines].first(view_h)
|
|
120
123
|
body += [""] * (view_h - body.size)
|
|
121
|
-
lines = [header(sections, width,
|
|
122
|
-
Frame.new(lines, [nil] * (view_h + 2),
|
|
124
|
+
lines = [header(sections, width, view)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + view.screen[:footer], width)]
|
|
125
|
+
Frame.new(lines, [nil] * (view_h + 2), view.top, width)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
128
|
# ----- chrome -------------------------------------------------------------
|
|
126
129
|
|
|
127
|
-
def header(sections, width,
|
|
130
|
+
def header(sections, width, view)
|
|
128
131
|
brand = " " + @theme.cyan_bold("▌ claude-inbox")
|
|
129
|
-
right = [
|
|
132
|
+
right = [view.status && @p.dim(view.status), view.usage && usage_meters(view.usage, view.now)].compact.join(@p.dim(" · "))
|
|
130
133
|
right += " " unless right.empty?
|
|
131
134
|
room = width - Text.width(brand) - Text.width(right) - 3
|
|
132
|
-
chips =
|
|
135
|
+
chips = view.loading ? "" : header_chips(sections, compact: false)
|
|
133
136
|
chips = header_chips(sections, compact: true) if Text.width(chips) > room
|
|
134
137
|
chips = "" if Text.width(chips) > room
|
|
135
138
|
Text.pad(brand + " " + chips, width - Text.width(right)) + right
|
|
136
139
|
end
|
|
137
140
|
|
|
141
|
+
# "session ██░░░░░░░░ 24% · 3h left" per window, drawn and colored the way
|
|
142
|
+
# the context bar in the user's status line is, so the two read alike.
|
|
143
|
+
def usage_meters(windows, now)
|
|
144
|
+
windows.map do |w|
|
|
145
|
+
filled = w.percent / 10
|
|
146
|
+
bar = "█" * filled + "░" * (10 - filled)
|
|
147
|
+
left = w.resets_at && w.resets_at.to_i - now.to_i
|
|
148
|
+
figure = left&.positive? ? "#{w.percent}% · #{Text.age(left)} left" : "#{w.percent}%"
|
|
149
|
+
"#{@p.dim(w.label)} #{@theme.public_send(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
|
|
150
|
+
end.join(" ")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def usage_hue(percent)
|
|
154
|
+
if percent >= 90 then :red
|
|
155
|
+
elsif percent >= 70 then :yellow
|
|
156
|
+
else :green
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
138
160
|
def header_chips(sections, compact:)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
chips = [
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
pinned = sections.pinned.size
|
|
162
|
+
needing = sections.needs_you.size
|
|
163
|
+
working = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
|
|
164
|
+
idle = sections.active.count { |r| r.session.waiting_on_work? }
|
|
165
|
+
terminals = sections.all.count { |r| r.session.terminal? }
|
|
166
|
+
remotes = sections.all.count { |r| r.session.remote? }
|
|
167
|
+
snoozed = sections.snoozed.size
|
|
168
|
+
settled = sections.settled.size
|
|
169
|
+
chips = [
|
|
170
|
+
header_chip(pinned, "★", "pinned", compact) { |s| @theme.cyan_bold(s) },
|
|
171
|
+
header_chip(needing, "●", "need#{"s" if needing == 1} you", compact) { |s| @theme.red_bold(s) },
|
|
172
|
+
header_chip(working, "✻", "working", compact) { |s| @theme.yellow(s) },
|
|
173
|
+
header_chip(idle, "◌", "idle", compact) { |s| @theme.yellow(s) },
|
|
174
|
+
header_chip(terminals, "○", "terminal#{"s" if terminals > 1}", compact) { |s| @p.dim(s) },
|
|
175
|
+
header_chip(remotes, "⇅", "remote", compact) { |s| @theme.blue(s) },
|
|
176
|
+
header_chip(snoozed, "z", "snoozed", compact) { |s| @theme.purple(s) },
|
|
177
|
+
header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
|
|
178
|
+
].compact
|
|
156
179
|
chips << @p.dim("nothing running") if sections.all.empty?
|
|
157
180
|
chips.join(compact ? " " : @p.dim(" · "))
|
|
158
181
|
end
|
|
159
182
|
|
|
160
|
-
def
|
|
183
|
+
def header_chip(count, glyph, words, compact)
|
|
184
|
+
return unless count > 0
|
|
185
|
+
yield(compact ? "#{glyph} #{count}" : "#{glyph} #{count} #{words}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def footer(width, view)
|
|
161
189
|
text =
|
|
162
|
-
if
|
|
163
|
-
elsif
|
|
190
|
+
if view.filter_editing then " " + @theme.cyan_bold("/") + line(view.filter, width - 2)
|
|
191
|
+
elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
|
|
164
192
|
else " " + KEYS.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
165
193
|
end
|
|
166
194
|
Text.pad(text, width)
|
|
@@ -172,40 +200,34 @@ module ClaudeInbox
|
|
|
172
200
|
title = " #{SECTION_TITLES[name]} "
|
|
173
201
|
count_s = " #{count} "
|
|
174
202
|
fill = [width - 3 - Text.width(title) - Text.width(count_s), 0].max
|
|
175
|
-
|
|
176
|
-
Text.pad(" " + color.call("▎") + color.call(@p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
|
|
203
|
+
Text.pad(" " + section_color(name, "▎") + section_color(name, @p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
|
|
177
204
|
end
|
|
178
205
|
|
|
179
|
-
def section_color(name)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
when :needs_you then ->(s) { @theme.red(s) }
|
|
183
|
-
when :active then ->(s) { @theme.yellow(s) }
|
|
184
|
-
when :snoozed then ->(s) { @theme.purple(s) }
|
|
185
|
-
else ->(s) { @p.dim(s) }
|
|
186
|
-
end
|
|
206
|
+
def section_color(name, s)
|
|
207
|
+
hue = SECTION_HUES[name]
|
|
208
|
+
hue ? @theme.public_send(hue, s) : @p.dim(s)
|
|
187
209
|
end
|
|
188
210
|
|
|
189
211
|
# ----- body ---------------------------------------------------------------
|
|
190
212
|
|
|
191
|
-
def body_lines(sections, width,
|
|
213
|
+
def body_lines(sections, width, view, now)
|
|
192
214
|
lines = []
|
|
193
215
|
items = []
|
|
194
216
|
if sections.all.empty?
|
|
195
217
|
return [empty_state(width), []]
|
|
196
218
|
end
|
|
197
|
-
|
|
219
|
+
selected = view.selected
|
|
198
220
|
sections.each_section do |name, rows|
|
|
199
221
|
next if rows.empty?
|
|
200
222
|
lines << "" << section_title(name, rows.size, width)
|
|
201
223
|
items << nil << nil
|
|
202
|
-
if Store.folded?(name, expanded)
|
|
224
|
+
if Store.folded?(name, view.expanded)
|
|
203
225
|
lines << fold_toggle_line(name, rows.size, selected, width)
|
|
204
226
|
items << Item.new(:fold_toggle, nil, name)
|
|
205
227
|
next
|
|
206
228
|
end
|
|
207
229
|
rows.each do |row|
|
|
208
|
-
row_lines(row, name, selected, width, now,
|
|
230
|
+
row_lines(row, name, selected, width, now, view.tick).each_with_index do |l, i|
|
|
209
231
|
lines << l
|
|
210
232
|
items << ((i.zero? && row.selectable?) ? Item.new(:row, row, name) : nil)
|
|
211
233
|
end
|
|
@@ -336,7 +358,8 @@ module ClaudeInbox
|
|
|
336
358
|
state_badge(s) + @p.dim(" · #{where} · #{age}")
|
|
337
359
|
else
|
|
338
360
|
age = row.state_since ? @p.dim(" · " + Text.age(now.to_i - row.state_since.to_i)) : ""
|
|
339
|
-
|
|
361
|
+
remote = s.remote_control? ? @theme.blue(" ⇅") : ""
|
|
362
|
+
state_badge(s) + remote + age
|
|
340
363
|
end
|
|
341
364
|
end
|
|
342
365
|
pr = pr_badge(s, section)
|
|
@@ -387,12 +410,12 @@ module ClaudeInbox
|
|
|
387
410
|
|
|
388
411
|
# ----- peek ---------------------------------------------------------------
|
|
389
412
|
|
|
390
|
-
def peek_pane(
|
|
391
|
-
bar = Text.pad(" " + (title || ""), width)
|
|
413
|
+
def peek_pane(peek, width, height)
|
|
414
|
+
bar = Text.pad(" " + (peek.title || ""), width)
|
|
392
415
|
out = [@p.inverse(bar)]
|
|
393
|
-
out << Text.pad(" " + @p.dim(subtitle.to_s), width) if subtitle
|
|
416
|
+
out << Text.pad(" " + @p.dim(peek.subtitle.to_s), width) if peek.subtitle
|
|
394
417
|
body_h = height - out.size
|
|
395
|
-
wrapped = lines.flat_map { |l| Text.wrap(l, width - 1) }
|
|
418
|
+
wrapped = peek.lines.flat_map { |l| Text.wrap(l, width - 1) }
|
|
396
419
|
wrapped.last(body_h).each { |l| out << Text.pad(" " + l, width) }
|
|
397
420
|
out
|
|
398
421
|
end
|
|
@@ -427,11 +450,10 @@ module ClaudeInbox
|
|
|
427
450
|
@prev = []
|
|
428
451
|
end
|
|
429
452
|
|
|
430
|
-
def paint(lines
|
|
453
|
+
def paint(lines)
|
|
431
454
|
buf = +""
|
|
432
|
-
buf << @cursor.clear_screen if force
|
|
433
455
|
lines.each_with_index do |line, i|
|
|
434
|
-
next if
|
|
456
|
+
next if @prev[i] == line
|
|
435
457
|
buf << @cursor.move_to(0, i) << line
|
|
436
458
|
end
|
|
437
459
|
if @prev.size > lines.size
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -10,14 +10,14 @@ module ClaudeInbox
|
|
|
10
10
|
# move under it.
|
|
11
11
|
Session = Data.define(
|
|
12
12
|
:id, :cwd, :kind, :started_at, :session_id, :name,
|
|
13
|
-
:state, :pid, :status, :waiting_for, :origin, :prs, :job_state
|
|
13
|
+
:state, :pid, :status, :waiting_for, :origin, :prs, :job_state, :bridge_id
|
|
14
14
|
) do
|
|
15
15
|
# Every member is optional so the parser and the specs name only what they
|
|
16
16
|
# have; prs is [] rather than nil so nobody asks whether PullRequests has run.
|
|
17
17
|
def initialize(id: nil, cwd: nil, kind: nil, started_at: nil, session_id: nil, name: nil,
|
|
18
|
-
state: nil, pid: nil, status: nil, waiting_for: nil, origin: nil, prs: nil, job_state: nil)
|
|
18
|
+
state: nil, pid: nil, status: nil, waiting_for: nil, origin: nil, prs: nil, job_state: nil, bridge_id: nil)
|
|
19
19
|
super(id: id, cwd: cwd, kind: kind, started_at: started_at, session_id: session_id, name: name,
|
|
20
|
-
state: state, pid: pid, status: status, waiting_for: waiting_for, origin: origin, prs: prs || [], job_state: job_state)
|
|
20
|
+
state: state, pid: pid, status: status, waiting_for: waiting_for, origin: origin, prs: prs || [], job_state: job_state, bridge_id: bridge_id)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def self.from_hash(h)
|
|
@@ -59,6 +59,17 @@ module ClaudeInbox
|
|
|
59
59
|
|
|
60
60
|
def terminal? = interactive? && !remote? && !unattended?
|
|
61
61
|
|
|
62
|
+
# Where claude.ai/code shows this session. Every background session
|
|
63
|
+
# registers a bridge and records it in the job file; a worker a `claude
|
|
64
|
+
# remote-control` server spawned carries it on its command line.
|
|
65
|
+
def remote_url
|
|
66
|
+
bridge = bridge_id || job_state&.bridge_id
|
|
67
|
+
bridge && "https://claude.ai/code/session_#{bridge.delete_prefix("cse_")}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Driven from claude.ai/code as well as from here, not only followed there.
|
|
71
|
+
def remote_control? = remote? || job_state&.remote_control? == true
|
|
72
|
+
|
|
62
73
|
# Selection handle: short id for background sessions, the UUID otherwise.
|
|
63
74
|
def key = id || session_id
|
|
64
75
|
|
|
@@ -69,6 +80,9 @@ module ClaudeInbox
|
|
|
69
80
|
# From `/color`; interactive sessions have no job file, so never one.
|
|
70
81
|
def color = job_state&.color
|
|
71
82
|
|
|
83
|
+
# The prompt this session was started with.
|
|
84
|
+
def intent = job_state&.intent
|
|
85
|
+
|
|
72
86
|
# The session's own one-line account of where it is, the same line
|
|
73
87
|
# `claude agents` prints under a row: what it needs while blocked, what
|
|
74
88
|
# it produced once done, otherwise its status line. Nil without a job
|
|
@@ -10,8 +10,8 @@ module ClaudeInbox
|
|
|
10
10
|
module Sessions
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
def load(client:,
|
|
14
|
-
sessions = JobState.enrich(client.list, jobs_dir: jobs_dir)
|
|
13
|
+
def load(client:, pull_requests:, overrides:)
|
|
14
|
+
sessions = JobState.enrich(client.list, jobs_dir: client.jobs_dir)
|
|
15
15
|
pull_requests.enrich(sessions, overrides)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -26,8 +26,8 @@ module ClaudeInbox
|
|
|
26
26
|
def list(cwd:, home: Dir.home)
|
|
27
27
|
found = {}
|
|
28
28
|
add = ->(cmd) { found[cmd.name] ||= cmd }
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
from_dir(File.join(cwd, ".claude"), "project").each(&add)
|
|
30
|
+
from_dir(File.join(home, ".claude"), "user").each(&add)
|
|
31
31
|
plugins(home).each(&add)
|
|
32
32
|
synced(home).each(&add)
|
|
33
33
|
found.values.sort_by(&:name)
|
|
@@ -39,8 +39,9 @@ module ClaudeInbox
|
|
|
39
39
|
starts + rest.select { |c| c.name.downcase.include?(q) }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def
|
|
43
|
-
skills(File.join(dir, "skills"), source
|
|
42
|
+
def from_dir(dir, source, prefix: nil)
|
|
43
|
+
skills(File.join(dir, "skills"), source, prefix:) +
|
|
44
|
+
commands(File.join(dir, "commands"), source, prefix:)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def skills(dir, source, prefix: nil)
|
|
@@ -70,8 +71,7 @@ module ClaudeInbox
|
|
|
70
71
|
(installed["plugins"] || {}).flat_map do |key, entries|
|
|
71
72
|
plugin = key.split("@").first
|
|
72
73
|
Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
|
|
73
|
-
|
|
74
|
-
commands(File.join(root, "commands"), "plugin", prefix: plugin)
|
|
74
|
+
from_dir(root, "plugin", prefix: plugin)
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
end
|
|
@@ -27,9 +27,11 @@ module ClaudeInbox
|
|
|
27
27
|
|
|
28
28
|
def reap_failed_at = entry&.reap_failed_at
|
|
29
29
|
|
|
30
|
+
# Names get shortened and forgotten, so the filter also reads what the
|
|
31
|
+
# session was asked to do and what it says it is doing now.
|
|
30
32
|
def matches?(query)
|
|
31
33
|
q = query.downcase
|
|
32
|
-
label.
|
|
34
|
+
[label, session.cwd, session.intent, session.summary].any? { |field| field.to_s.downcase.include?(q) }
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# A session already blocked when you snoozed it stays snoozed; one that
|
|
@@ -89,10 +91,8 @@ module ClaudeInbox
|
|
|
89
91
|
if session.terminal? then session.needs_you? ? :needs_you : :active
|
|
90
92
|
elsif pinned? then :pinned
|
|
91
93
|
elsif snoozed?(now) then :snoozed
|
|
92
|
-
elsif !revived? && hand_settled? then :settled
|
|
93
94
|
elsif !revived? && settled? then :settled # a resolved PR outranks Needs You
|
|
94
95
|
elsif session.needs_you? then acknowledged? ? :active : :needs_you
|
|
95
|
-
elsif session.finished? then :active
|
|
96
96
|
else :active
|
|
97
97
|
end
|
|
98
98
|
end
|
data/lib/claude_inbox/store.rb
CHANGED
|
@@ -35,10 +35,15 @@ module ClaudeInbox
|
|
|
35
35
|
out
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def self.row_for(session, entries)
|
|
39
|
+
hash = session.key && entries[session.key]
|
|
40
|
+
Row.new(session: session, entry: hash && Entry.new(hash))
|
|
41
|
+
end
|
|
42
|
+
|
|
38
43
|
def self.sectionize(sessions, entries, now)
|
|
39
44
|
sec = Sections.new(pinned: [], needs_you: [], active: [], snoozed: [], settled: [])
|
|
40
45
|
sessions.each do |s|
|
|
41
|
-
row =
|
|
46
|
+
row = row_for(s, entries)
|
|
42
47
|
sec[row.section(now)] << row
|
|
43
48
|
end
|
|
44
49
|
sec.pinned.sort_by! { |r| -(r.pinned_at || 0) }
|
|
@@ -108,7 +113,11 @@ module ClaudeInbox
|
|
|
108
113
|
@mutex.synchronize { @entries.transform_values { |e| Entry.new(e).pr }.select { |_, url| url } }
|
|
109
114
|
end
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
# A copy, not the live entry: the reaper decides a deletion on this row
|
|
117
|
+
# while the UI thread may be editing that entry in place.
|
|
118
|
+
def row(session)
|
|
119
|
+
@mutex.synchronize { self.class.row_for(session, {session.key => @entries[session.key]&.dup}) }
|
|
120
|
+
end
|
|
112
121
|
|
|
113
122
|
# The raw hash, for the specs; nothing in lib/ reads it.
|
|
114
123
|
def entry(id) = @mutex.synchronize { @entries[id]&.dup }
|
|
@@ -37,8 +37,9 @@ module ClaudeInbox
|
|
|
37
37
|
ready, = IO.select(readers)
|
|
38
38
|
ready.each do |io|
|
|
39
39
|
chunk = io.read_nonblock(65_536, exception: false)
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
case chunk
|
|
41
|
+
when :wait_readable then next
|
|
42
|
+
when nil
|
|
42
43
|
readers.delete(io)
|
|
43
44
|
io.close
|
|
44
45
|
else
|
data/lib/claude_inbox/text.rb
CHANGED
|
@@ -61,13 +61,8 @@ module ClaudeInbox
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Greedy word wrap on display width. Words wider than `w` are split.
|
|
64
|
-
def wrap(s, w)
|
|
65
|
-
|
|
66
|
-
# Word wrap that keeps every character, so joining the segments back
|
|
67
|
-
# together returns the original string. An editor needs that: `wrap`
|
|
68
|
-
# drops the space you just typed, and with it the cursor's place.
|
|
69
|
-
def segments(s, w)
|
|
70
|
-
return [s] if w <= 0 || width(s) <= w
|
|
64
|
+
def wrap(s, w)
|
|
65
|
+
return [s.rstrip] if w <= 0 || width(s) <= w
|
|
71
66
|
lines = []
|
|
72
67
|
line = +""
|
|
73
68
|
s.split(/(?<= )/).each do |word|
|
|
@@ -76,13 +71,14 @@ module ClaudeInbox
|
|
|
76
71
|
line = +""
|
|
77
72
|
end
|
|
78
73
|
while width(word) > w
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
head = take(word, w)
|
|
75
|
+
lines << head
|
|
76
|
+
word = word[head.size..]
|
|
81
77
|
end
|
|
82
78
|
line << word
|
|
83
79
|
end
|
|
84
80
|
lines << line unless line.empty?
|
|
85
|
-
lines
|
|
81
|
+
lines.map(&:rstrip)
|
|
86
82
|
end
|
|
87
83
|
|
|
88
84
|
# "45s", "12m", "3h", "2d"
|
|
@@ -158,8 +158,9 @@ module ClaudeInbox
|
|
|
158
158
|
rows
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
# Text.
|
|
162
|
-
#
|
|
161
|
+
# Text.wrap on cells, keeping the trailing spaces the cursor may sit on:
|
|
162
|
+
# a chip is one cell however wide its label, so the wrap has to measure
|
|
163
|
+
# cells rather than a joined string.
|
|
163
164
|
def segments(cells, width)
|
|
164
165
|
return [cells] if width_of(cells) <= width
|
|
165
166
|
words = cells.slice_when { |c, _| c == " " }.to_a
|