claude-inbox 0.14.2 → 0.14.4
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/lib/claude_inbox/app/attaching.rb +5 -3
- data/lib/claude_inbox/dialog.rb +1 -1
- data/lib/claude_inbox/input.rb +12 -4
- data/lib/claude_inbox/mouse.rb +13 -6
- data/lib/claude_inbox/poller.rb +5 -5
- data/lib/claude_inbox/rate_limits.rb +6 -9
- data/lib/claude_inbox/renderer.rb +8 -6
- data/lib/claude_inbox/slash_commands.rb +1 -2
- data/lib/claude_inbox/store/entry.rb +2 -1
- data/lib/claude_inbox/store/row.rb +4 -3
- data/lib/claude_inbox/store.rb +43 -3
- data/lib/claude_inbox/text.rb +1 -0
- data/lib/claude_inbox.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed40b9c11c703427940ad961d9f114e815c8af02090d8dd2b614971a1a578f4b
|
|
4
|
+
data.tar.gz: 21e8cbb5fac27662d14cf77dd40673c36b9f254618fcb029cef73b8cbee56dfb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5a464b6c6d6e97943dc66e66e2f3b6ed42eb4e839e878a54afb8fca8e8b5d46a51176180dd3231e178f92ce1b63cca49c755a1e36731b6740acb1d370143cb7f
|
|
7
|
+
data.tar.gz: 4e959e5f8b45042c7d19b40616865fa62830fca5b788313392eae6e006cbcf780e3549f253c6086913eecbd344f4d8e9e6480f13c75ae25efe6b34825d98468d
|
|
@@ -29,12 +29,14 @@ module ClaudeInbox
|
|
|
29
29
|
# failed attach leaves its reason on screen until a key is pressed.
|
|
30
30
|
def attach(id)
|
|
31
31
|
while id
|
|
32
|
-
@store.acknowledge(id)
|
|
33
32
|
@switch.clear
|
|
34
33
|
from = session_for(id)&.session_id
|
|
35
|
-
@
|
|
36
|
-
@terminal.
|
|
34
|
+
@store.visit(id) do
|
|
35
|
+
@terminal.release do
|
|
36
|
+
@terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
|
|
37
|
+
end
|
|
37
38
|
end
|
|
39
|
+
@poller.soon
|
|
38
40
|
id = @switch.take(@store.actionable_keys)
|
|
39
41
|
end
|
|
40
42
|
end
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -189,7 +189,7 @@ module ClaudeInbox
|
|
|
189
189
|
# Drawn by hand: TTY::Box measures an emoji as one column, which
|
|
190
190
|
# pushes the right border out on every line that has one.
|
|
191
191
|
def frame(width, _caret = nil)
|
|
192
|
-
inner = [width - 4,
|
|
192
|
+
inner = [width - 4, fit].min - 2
|
|
193
193
|
bordered(lines(inner, nil).map { |l| Text.pad(Text.truncate(l, inner), inner) }, inner)
|
|
194
194
|
end
|
|
195
195
|
|
data/lib/claude_inbox/input.rb
CHANGED
|
@@ -13,6 +13,9 @@ module ClaudeInbox
|
|
|
13
13
|
# is taken as Option held with it.
|
|
14
14
|
ALT = /\A\e([a-z])\z/
|
|
15
15
|
|
|
16
|
+
# A nonblocking read can end partway through a mouse report.
|
|
17
|
+
UNFINISHED_REPORT = /\e\[<[\d;]*\z/
|
|
18
|
+
|
|
16
19
|
def self.alt?(name) = name.to_s.start_with?("alt_")
|
|
17
20
|
|
|
18
21
|
# An Option chord as the quick Esc-then-letter it may also have been.
|
|
@@ -22,6 +25,7 @@ module ClaudeInbox
|
|
|
22
25
|
@input = input
|
|
23
26
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
24
27
|
@paste = Paste.new
|
|
28
|
+
@held = +""
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def pasting? = @paste.pasting?
|
|
@@ -33,13 +37,17 @@ module ClaudeInbox
|
|
|
33
37
|
|
|
34
38
|
# `[:paste, text]`, `[:mouse, event]` and `[:key, name, raw]`, in the
|
|
35
39
|
# order they arrived; `name` is the reader's symbol for a key it knows,
|
|
36
|
-
# else the raw string.
|
|
40
|
+
# else the raw string. The head of a report cut off at the end of a read
|
|
41
|
+
# waits for the rest instead of being typed.
|
|
37
42
|
def events(raw)
|
|
38
43
|
@paste.feed(raw).flat_map do |kind, text|
|
|
39
44
|
next [[:paste, text]] if kind == :paste
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
text = @held + text
|
|
46
|
+
@held = text[UNFINISHED_REPORT] || +""
|
|
47
|
+
Mouse.split(text.delete_suffix(@held)).flat_map do |piece|
|
|
48
|
+
next [[:mouse, piece]] if piece.is_a?(Mouse::Event)
|
|
49
|
+
split_keys(piece).map { |k| [:key, key_name(k), k] }
|
|
50
|
+
end
|
|
43
51
|
end
|
|
44
52
|
end
|
|
45
53
|
|
data/lib/claude_inbox/mouse.rb
CHANGED
|
@@ -10,14 +10,21 @@ module ClaudeInbox
|
|
|
10
10
|
|
|
11
11
|
Event = Struct.new(:kind, :row, :col)
|
|
12
12
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
def self.
|
|
17
|
-
|
|
13
|
+
# The read cut at its reports, in order: an Event per report acted on,
|
|
14
|
+
# the keys between as strings. An ignored report still cuts, so the keys
|
|
15
|
+
# either side of it never join into one.
|
|
16
|
+
def self.split(raw)
|
|
17
|
+
out = []
|
|
18
|
+
rest = raw
|
|
19
|
+
while (m = SEQUENCE.match(rest))
|
|
20
|
+
out << m.pre_match unless m.pre_match.empty?
|
|
21
|
+
cb, col, row, type = m.captures
|
|
18
22
|
kind = kind_for(cb.to_i, type)
|
|
19
|
-
Event.new(kind, row.to_i, col.to_i) if kind
|
|
23
|
+
out << Event.new(kind, row.to_i, col.to_i) if kind
|
|
24
|
+
rest = m.post_match
|
|
20
25
|
end
|
|
26
|
+
out << rest unless rest.empty?
|
|
27
|
+
out
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
# Bit 6 (0x40) marks a wheel tick, direction in bit 0. Otherwise bits
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -58,17 +58,17 @@ module ClaudeInbox
|
|
|
58
58
|
sessions = Sessions.load(client: @client, pull_requests: @pull_requests, transcripts: @transcripts, overrides: @store.pr_overrides)
|
|
59
59
|
doomed = @reaper.due(sessions, now)
|
|
60
60
|
@store.hide(doomed)
|
|
61
|
-
publish(sessions)
|
|
61
|
+
publish(sessions, now)
|
|
62
62
|
reaped = []
|
|
63
63
|
begin
|
|
64
64
|
reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
|
|
65
65
|
ensure
|
|
66
66
|
@store.release(doomed - reaped)
|
|
67
67
|
end
|
|
68
|
-
publish(sessions) if reaped != doomed
|
|
68
|
+
publish(sessions, now) if reaped != doomed
|
|
69
69
|
@queue << [:notice, @reaper.report(reaped)] if reaped.any?
|
|
70
70
|
fresh, moved = @pull_requests.refresh(sessions)
|
|
71
|
-
publish(fresh) if moved
|
|
71
|
+
publish(fresh, now) if moved
|
|
72
72
|
rescue => e
|
|
73
73
|
@queue << [:error, e.message]
|
|
74
74
|
end
|
|
@@ -82,10 +82,10 @@ module ClaudeInbox
|
|
|
82
82
|
|
|
83
83
|
# The notifier compares the sections from just before the hand-over, so
|
|
84
84
|
# a row you woke or unpinned yourself since the last poll says nothing.
|
|
85
|
-
def publish(sessions)
|
|
85
|
+
def publish(sessions, fetched_at)
|
|
86
86
|
now = @clock.call
|
|
87
87
|
before = @store.sections(now)
|
|
88
|
-
@store.update(sessions)
|
|
88
|
+
@store.update(sessions, fetched_at:)
|
|
89
89
|
after = @store.sections(now)
|
|
90
90
|
@snapshot.write(after, now)
|
|
91
91
|
@notifier.deliver(before, after) { |msg| @queue << [:notice, msg] }
|
|
@@ -5,11 +5,10 @@ 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, "
|
|
9
|
-
# about limits and there is no CLI command for them, but every
|
|
10
|
-
# its status line a `rate_limits` object on each turn, so a
|
|
11
|
-
# there is the only source that costs no API calls.
|
|
12
|
-
# nobody has touched in STALE_AFTER, reads as nothing to show.
|
|
8
|
+
# status line script (README, "Usage bars"): `claude agents --json` says
|
|
9
|
+
# nothing about limits and there is no CLI command for them, but every
|
|
10
|
+
# session hands its status line a `rate_limits` object on each turn, so a
|
|
11
|
+
# one-line tee there is the only source that costs no API calls.
|
|
13
12
|
class RateLimits
|
|
14
13
|
DEFAULT_PATH = File.join(Dir.home, ".claude", "rate_limits.json")
|
|
15
14
|
STALE_AFTER = 15 * 60
|
|
@@ -26,10 +25,8 @@ module ClaudeInbox
|
|
|
26
25
|
@data = nil
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
# The windows the file reports,
|
|
30
|
-
#
|
|
31
|
-
# Parses only when the file has changed, since render asks several times
|
|
32
|
-
# a second.
|
|
28
|
+
# The windows the file reports, or nil. Parses only when the file has
|
|
29
|
+
# changed, since render asks several times a second.
|
|
33
30
|
def windows(now)
|
|
34
31
|
mtime = File.mtime(@path)
|
|
35
32
|
return nil if now - mtime > STALE_AFTER
|
|
@@ -14,9 +14,12 @@ module ClaudeInbox
|
|
|
14
14
|
# The same frame for a terminal drawn without color.
|
|
15
15
|
def plain = self.class.new(lines.map { |l| l.gsub(Text::CSI, "") }, items, top)
|
|
16
16
|
|
|
17
|
+
# Only the row under the pointer is scanned: every keystroke paints a
|
|
18
|
+
# frame, and scanning the whole screen for links each time cost more
|
|
19
|
+
# than the key repeat interval.
|
|
17
20
|
def link_at(row, col)
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
line = lines[row - 1] if row >= 1
|
|
22
|
+
Text.links(line).find { |link| link.columns.cover?(col - 1) } if line
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -375,22 +378,21 @@ module ClaudeInbox
|
|
|
375
378
|
|
|
376
379
|
def meta_for(row, section, now)
|
|
377
380
|
s = row.session
|
|
381
|
+
age = Text.age(now.to_i - row.spoke_at)
|
|
378
382
|
base =
|
|
379
383
|
case section
|
|
380
384
|
when :snoozed
|
|
381
385
|
wake = row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
|
|
382
386
|
row.pinned? ? wake + @theme.cyan(" ★") : wake
|
|
383
387
|
when :settled
|
|
384
|
-
@p.dim("#{s.state} · #{
|
|
388
|
+
@p.dim("#{s.state} · #{age}")
|
|
385
389
|
else
|
|
386
390
|
if s.interactive?
|
|
387
391
|
where = s.remote? ? "remote" : "your terminal"
|
|
388
|
-
age = Text.age(now.to_i - row.spoke_at)
|
|
389
392
|
state_badge(s) + @p.dim(" · #{where} · #{age}")
|
|
390
393
|
else
|
|
391
|
-
age = @p.dim(" · " + Text.age(now.to_i - row.spoke_at))
|
|
392
394
|
remote = s.remote_control? ? @theme.blue(" ⇅") : ""
|
|
393
|
-
state_badge(s) + remote + age
|
|
395
|
+
state_badge(s) + remote + @p.dim(" · #{age}")
|
|
394
396
|
end
|
|
395
397
|
end
|
|
396
398
|
pr = pr_badge(s, section)
|
|
@@ -102,8 +102,7 @@ module ClaudeInbox
|
|
|
102
102
|
|
|
103
103
|
def unquote(s)
|
|
104
104
|
s = s.sub(/\A[>|][-+]?\z/, "")
|
|
105
|
-
|
|
106
|
-
s = s[1..-2] if quoted
|
|
105
|
+
s = s[1..-2] if s.match?(/\A(["']).*\1\z/)
|
|
107
106
|
s.gsub("''", "'")
|
|
108
107
|
end
|
|
109
108
|
private_class_method :from_dir, :skills, :commands, :qualify, :plugins, :synced, :frontmatter, :unquote
|
|
@@ -60,7 +60,8 @@ module ClaudeInbox
|
|
|
60
60
|
|
|
61
61
|
def acknowledged_at = @h["acknowledged_at"]
|
|
62
62
|
|
|
63
|
-
# When you last
|
|
63
|
+
# When you last saw it: an attach, or a change it made while you were
|
|
64
|
+
# in it. Never cleared, unlike `acknowledged_at`.
|
|
64
65
|
def last_visited_at = @h["last_visited_at"]
|
|
65
66
|
|
|
66
67
|
def revived_at = @h["revived_at"]
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module ClaudeInbox
|
|
4
4
|
class Store
|
|
5
5
|
# A session paired with its Entry (nil until a poll has recorded one), with
|
|
6
|
-
# the triage rules as its methods
|
|
6
|
+
# the triage rules as its methods. A rule that depends on the time takes
|
|
7
|
+
# `now`, so a Row is pure.
|
|
7
8
|
Row = Struct.new(:session, :entry) do
|
|
8
9
|
def key = session.key
|
|
9
10
|
|
|
@@ -118,8 +119,8 @@ module ClaudeInbox
|
|
|
118
119
|
|
|
119
120
|
private
|
|
120
121
|
|
|
121
|
-
#
|
|
122
|
-
#
|
|
122
|
+
# Only a session you can attach to carries a read or unread mark, since
|
|
123
|
+
# nothing else ever clears it.
|
|
123
124
|
def markable? = session.actionable? && (session.finished? || session.needs_you?)
|
|
124
125
|
|
|
125
126
|
def unchanged_since?(at) = at && entry.state_since.to_i <= at.to_i
|
data/lib/claude_inbox/store.rb
CHANGED
|
@@ -78,19 +78,46 @@ module ClaudeInbox
|
|
|
78
78
|
@sessions = []
|
|
79
79
|
@hidden = Set.new
|
|
80
80
|
@forgotten = Set.new
|
|
81
|
+
@visiting = Set.new
|
|
82
|
+
@leaving = {}
|
|
81
83
|
@entries = load
|
|
82
84
|
end
|
|
83
85
|
|
|
84
86
|
# A hidden or forgotten key the daemon has stopped listing is released;
|
|
85
|
-
# one it still lists stays out of sight.
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
# one it still lists stays out of sight. `fetched_at` is when the list
|
|
88
|
+
# was asked for, which can be well before it is handed over.
|
|
89
|
+
def update(sessions, fetched_at: @clock.call)
|
|
90
|
+
seen = @mutex.synchronize do
|
|
88
91
|
keys = sessions.map(&:key)
|
|
89
92
|
@hidden &= keys
|
|
90
93
|
@forgotten &= keys
|
|
91
94
|
@sessions = sessions.reject { |s| @hidden.include?(s.key) || @forgotten.include?(s.key) }
|
|
92
95
|
@entries = self.class.merge_entries(@writable ? @entries : load, @sessions, @clock.call)
|
|
96
|
+
# A list asked for before you left shows only what happened while you
|
|
97
|
+
# were there; the first one asked for after closes the visit.
|
|
98
|
+
left = @leaving.select { |_, left_at| fetched_at >= left_at }.keys
|
|
99
|
+
seen = (@visiting.to_a | @leaving.keys).select { |id| see(id) }
|
|
100
|
+
# A reader reloads state.json each poll, so it holds the visit open
|
|
101
|
+
# until the writer has taken its acknowledge.
|
|
102
|
+
(@writable ? left : left - seen).each { |id| @leaving.delete(id) }
|
|
93
103
|
save
|
|
104
|
+
seen
|
|
105
|
+
end
|
|
106
|
+
seen.each { |id| relay("acknowledge", id) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# You are in the session for the length of the block, so whatever it
|
|
110
|
+
# does meanwhile is seen, not unread. The first list asked for after you
|
|
111
|
+
# leave still counts: a state that changed in your last seconds there is
|
|
112
|
+
# only recorded then.
|
|
113
|
+
def visit(id)
|
|
114
|
+
acknowledge(id)
|
|
115
|
+
@mutex.synchronize { @visiting << id }
|
|
116
|
+
yield
|
|
117
|
+
ensure
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
@visiting.delete(id)
|
|
120
|
+
@leaving[id] = @clock.call
|
|
94
121
|
end
|
|
95
122
|
end
|
|
96
123
|
|
|
@@ -173,9 +200,22 @@ module ClaudeInbox
|
|
|
173
200
|
yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
|
|
174
201
|
save
|
|
175
202
|
end
|
|
203
|
+
relay(action, id, **detail)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def relay(action, id, **detail)
|
|
176
207
|
@relay&.request(action, id, **detail) unless @writable
|
|
177
208
|
end
|
|
178
209
|
|
|
210
|
+
# Acknowledges the state the last poll found, and says whether it had to:
|
|
211
|
+
# `Entry#observe` drops an acknowledgement once the state outruns it.
|
|
212
|
+
def see(id)
|
|
213
|
+
entry = @entries[id] && Entry.new(@entries[id])
|
|
214
|
+
return false if entry.nil? || entry.acknowledged_at
|
|
215
|
+
entry.acknowledge(@clock.call)
|
|
216
|
+
true
|
|
217
|
+
end
|
|
218
|
+
|
|
179
219
|
def load
|
|
180
220
|
Records.read(@path)["sessions"] || {}
|
|
181
221
|
end
|
data/lib/claude_inbox/text.rb
CHANGED
data/lib/claude_inbox.rb
CHANGED