claude-inbox 0.10.0 → 0.11.1
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/navigating.rb +9 -0
- data/lib/claude_inbox/app/spawning.rb +1 -1
- data/lib/claude_inbox/app/triaging.rb +11 -3
- data/lib/claude_inbox/app.rb +2 -1
- data/lib/claude_inbox/dialog.rb +22 -11
- data/lib/claude_inbox/headless.rb +18 -3
- data/lib/claude_inbox/job_state.rb +7 -5
- data/lib/claude_inbox/new_session_form.rb +4 -4
- data/lib/claude_inbox/notifier.rb +1 -0
- data/lib/claude_inbox/palette.rb +0 -5
- data/lib/claude_inbox/pull_requests.rb +14 -8
- data/lib/claude_inbox/remote/pairing_dialog.rb +2 -1
- data/lib/claude_inbox/renderer.rb +27 -41
- data/lib/claude_inbox/snooze_time.rb +1 -0
- data/lib/claude_inbox/stack_links.rb +49 -0
- data/lib/claude_inbox/store/sections.rb +1 -2
- data/lib/claude_inbox/text.rb +12 -13
- data/lib/claude_inbox/theme.rb +20 -7
- data/lib/claude_inbox/writer_lock.rb +11 -2
- data/lib/claude_inbox.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4be74e826e16c816d3589869198467177926961e11d885d7c811e6218b67fd2f
|
|
4
|
+
data.tar.gz: b09b5adfa01af2b64b00479faba7ce3c3312a8aec82916e1c0a003e7eec8f4ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93865715d77fe8092727f66c32da8e6cdbbac67068c2ead4af429f35d488d034834c188dc74905e42b0c4ecdea8470a3770d870251c8f229a532f3006cc463b0
|
|
7
|
+
data.tar.gz: 5334bb968b0fc31caea4249abe7b838266a70e9513c2693baa68432bc20f6c8eef3f9ff2491a14af15caa168550a27fffc0d20a2879c953904029a526206f4e1
|
|
@@ -47,6 +47,15 @@ module ClaudeInbox
|
|
|
47
47
|
select(stops[(idx + delta).clamp(0, stops.size - 1)])
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Before a row leaves the list: the stop below it, or above it at the
|
|
51
|
+
# bottom, so working down a section doesn't throw the cursor to the top.
|
|
52
|
+
def select_neighbour
|
|
53
|
+
stops = filtered.selections(@expanded)
|
|
54
|
+
idx = stops.index(@selected)
|
|
55
|
+
rest = stops - [@selected]
|
|
56
|
+
select(rest[idx.clamp(..rest.size - 1)]) if idx && rest.any?
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
# Tab / Shift-Tab: first selectable row of the next / previous section.
|
|
51
60
|
def jump_section(dir)
|
|
52
61
|
sections = filtered
|
|
@@ -9,7 +9,7 @@ module ClaudeInbox
|
|
|
9
9
|
def open_new_session
|
|
10
10
|
cwd = selected_session&.cwd || Dir.pwd
|
|
11
11
|
dirs = SessionRequest.directories(@store.sessions, @trust.call)
|
|
12
|
-
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd),
|
|
12
|
+
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), dirs: dirs)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
|
|
@@ -33,7 +33,9 @@ module ClaudeInbox
|
|
|
33
33
|
|
|
34
34
|
def settle_selected
|
|
35
35
|
return unless require_storable
|
|
36
|
-
@
|
|
36
|
+
key = @selected.key
|
|
37
|
+
select_neighbour
|
|
38
|
+
@store.settle(key)
|
|
37
39
|
notice("settled — u brings it back")
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -44,10 +46,16 @@ module ClaudeInbox
|
|
|
44
46
|
|
|
45
47
|
def snooze_chosen
|
|
46
48
|
return @modal = Dialog::Prompt.new(:snooze, @modal.id, "") if @modal.choice == :custom
|
|
47
|
-
|
|
49
|
+
snooze_modal_row(@modal.choice)
|
|
48
50
|
@modal = nil
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
# A poll can move the cursor while the menu is up, so only step off a row still under it.
|
|
54
|
+
def snooze_modal_row(choice)
|
|
55
|
+
select_neighbour if @selected == Store::Selection.row(@modal.id)
|
|
56
|
+
@store.snooze(@modal.id, choice)
|
|
57
|
+
end
|
|
58
|
+
|
|
51
59
|
def open_alias_editor
|
|
52
60
|
return unless require_storable && require_writable("rename")
|
|
53
61
|
current = @store.alias_for(@selected.key) || ""
|
|
@@ -67,7 +75,7 @@ module ClaudeInbox
|
|
|
67
75
|
elsif @modal.kind == :snooze
|
|
68
76
|
wake_at = SnoozeTime.parse(value, Time.now)
|
|
69
77
|
return notice("try 45m, 2h, 3d, 17:00, 5pm, fri, tomorrow or tonight") unless wake_at
|
|
70
|
-
|
|
78
|
+
snooze_modal_row(wake_at)
|
|
71
79
|
elsif value.empty? || PullRequests.valid_url?(value)
|
|
72
80
|
@store.set_pr(@modal.id, value)
|
|
73
81
|
@poller.soon
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -64,7 +64,7 @@ module ClaudeInbox
|
|
|
64
64
|
@out = out
|
|
65
65
|
@color = color
|
|
66
66
|
@trust = trust
|
|
67
|
-
@renderer = Renderer.new
|
|
67
|
+
@renderer = Renderer.new
|
|
68
68
|
@input = Input.new(input, out)
|
|
69
69
|
@queue = queue
|
|
70
70
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
@@ -275,6 +275,7 @@ module ClaudeInbox
|
|
|
275
275
|
tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
|
|
276
276
|
)
|
|
277
277
|
frame = @renderer.frame(sections, view)
|
|
278
|
+
frame = frame.plain unless @color # --no-color: decided here and nowhere else
|
|
278
279
|
placed(frame)
|
|
279
280
|
@terminal.paint(frame.lines)
|
|
280
281
|
dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "tty-box"
|
|
4
4
|
require_relative "text"
|
|
5
5
|
require_relative "text_buffer"
|
|
6
|
+
require_relative "theme"
|
|
6
7
|
|
|
7
8
|
module ClaudeInbox
|
|
8
9
|
# A small box over the list that claims every key until it answers. Like
|
|
@@ -18,6 +19,7 @@ module ClaudeInbox
|
|
|
18
19
|
def initialize(kind, id)
|
|
19
20
|
@kind = kind
|
|
20
21
|
@id = id
|
|
22
|
+
@theme = Theme.new
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def frame(width, caret = nil)
|
|
@@ -29,6 +31,13 @@ module ClaudeInbox
|
|
|
29
31
|
# How wide the box wants to be; the frame caps it at the terminal.
|
|
30
32
|
def fit = self.class::WIDTH
|
|
31
33
|
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def key(name) = @theme.cyan_bold(name)
|
|
37
|
+
|
|
38
|
+
# A key and what it does.
|
|
39
|
+
def hint(name, action) = "#{key(name)} #{@theme.dim(action)}"
|
|
40
|
+
|
|
32
41
|
# `s` snooze: one key per choice, and the choice is read from `choice`.
|
|
33
42
|
class Snooze < Dialog
|
|
34
43
|
MENU = [
|
|
@@ -45,11 +54,11 @@ module ClaudeInbox
|
|
|
45
54
|
|
|
46
55
|
def title = " Snooze "
|
|
47
56
|
|
|
48
|
-
def lines(_width, _caret) = MENU.map { |k, label, _| " #{k} #{label}" } + ["", " esc
|
|
57
|
+
def lines(_width, _caret) = MENU.map { |k, label, _| " #{key(k)} #{label}" } + ["", " " + hint("esc", "cancel")]
|
|
49
58
|
|
|
50
59
|
def press(name, key)
|
|
51
60
|
return :cancel if name == :escape || key == "q"
|
|
52
|
-
entry = MENU.
|
|
61
|
+
entry = MENU.assoc(key)
|
|
53
62
|
return nil unless entry
|
|
54
63
|
@choice = entry[2]
|
|
55
64
|
:snooze
|
|
@@ -77,8 +86,8 @@ module ClaudeInbox
|
|
|
77
86
|
def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
|
|
78
87
|
|
|
79
88
|
def lines(width, _caret)
|
|
80
|
-
(@prs.each_with_index.map { |pr, i| " #{i + 1} #{label(pr)}" } +
|
|
81
|
-
["", " #{KEYS.fetch(kind)
|
|
89
|
+
(@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
|
|
90
|
+
["", " #{hint(KEYS.fetch(kind), "#{VERBS.fetch(kind)} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
|
|
82
91
|
.map { |line| Text.truncate(line, width) }
|
|
83
92
|
end
|
|
84
93
|
|
|
@@ -91,10 +100,11 @@ module ClaudeInbox
|
|
|
91
100
|
|
|
92
101
|
private
|
|
93
102
|
|
|
94
|
-
# A bare #190 is ambiguous only
|
|
103
|
+
# The list's badge, then the title. A bare #190 is ambiguous only
|
|
104
|
+
# once the list spans repos.
|
|
95
105
|
def label(pr)
|
|
96
106
|
name = (@prs.map(&:repo).uniq.size > 1) ? pr.qualified : pr.short
|
|
97
|
-
|
|
107
|
+
pr.title ? @theme.pr_badge(pr, name) + @theme.dim(" · ") + pr.title : @theme.pr_badge(pr, name)
|
|
98
108
|
end
|
|
99
109
|
end
|
|
100
110
|
|
|
@@ -108,13 +118,13 @@ module ClaudeInbox
|
|
|
108
118
|
|
|
109
119
|
def lines(_width, _caret)
|
|
110
120
|
case kind
|
|
111
|
-
when :stop then [" Stop session #{id}?", "", " y
|
|
121
|
+
when :stop then [" Stop session #{id}?", "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
|
|
112
122
|
when :delete
|
|
113
123
|
[" Delete session #{id}?", " Its worktree and conversation", " go with it.",
|
|
114
|
-
"", " y
|
|
124
|
+
"", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
|
|
115
125
|
when :adopt
|
|
116
126
|
[" Pull this session into the daemon?", " Its phone session ends and a",
|
|
117
|
-
" new one takes its place.", "", " y
|
|
127
|
+
" new one takes its place.", "", " " + hint("y", "adopt it"), " " + hint("esc", "cancel")]
|
|
118
128
|
end
|
|
119
129
|
end
|
|
120
130
|
|
|
@@ -160,7 +170,7 @@ module ClaudeInbox
|
|
|
160
170
|
"",
|
|
161
171
|
" You can install this later with claude-inbox install",
|
|
162
172
|
"",
|
|
163
|
-
" y
|
|
173
|
+
" #{hint("y", "add it to Claude Code")} #{hint("esc", "not now")}"]
|
|
164
174
|
end
|
|
165
175
|
end
|
|
166
176
|
|
|
@@ -181,7 +191,8 @@ module ClaudeInbox
|
|
|
181
191
|
def title = TITLES.fetch(kind)
|
|
182
192
|
|
|
183
193
|
def lines(width, caret)
|
|
184
|
-
[QUESTIONS.fetch(kind), "", " > " + @buffer.row(width - 4, cursor: caret), "",
|
|
194
|
+
[QUESTIONS.fetch(kind), "", " > " + @buffer.row(width - 4, cursor: caret), "",
|
|
195
|
+
" #{key("⏎")} #{@theme.dim("save")} · #{key("esc")} #{@theme.dim("cancel")}"]
|
|
185
196
|
end
|
|
186
197
|
|
|
187
198
|
def press(name, key)
|
|
@@ -15,11 +15,15 @@ module ClaudeInbox
|
|
|
15
15
|
# reaps, since deleting sessions stays with the inbox you can see, and it
|
|
16
16
|
# exits at once while an inbox holds the writer lock.
|
|
17
17
|
class Headless
|
|
18
|
+
RECHECK_EVERY = 60
|
|
19
|
+
|
|
18
20
|
def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
|
|
19
|
-
lock: WriterLock.new, updater: nil, notifier: Notifier.disabled)
|
|
21
|
+
lock: WriterLock.new, updater: nil, notifier: Notifier.disabled, recheck_every: RECHECK_EVERY)
|
|
20
22
|
@lock = lock
|
|
21
23
|
@snapshot = snapshot
|
|
22
24
|
@updater = updater
|
|
25
|
+
@recheck_every = recheck_every
|
|
26
|
+
@notice = nil
|
|
23
27
|
@queue = Queue.new
|
|
24
28
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
|
|
25
29
|
queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
|
|
@@ -29,7 +33,7 @@ module ClaudeInbox
|
|
|
29
33
|
def run
|
|
30
34
|
return false unless @lock.take(role: "headless")
|
|
31
35
|
@poller.start
|
|
32
|
-
|
|
36
|
+
Thread.new { watch_for_updates } if @updater
|
|
33
37
|
loop { @queue.pop }
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -38,8 +42,19 @@ module ClaudeInbox
|
|
|
38
42
|
# After the poller is off, since the check may wait on the network. The
|
|
39
43
|
# pane cannot swap the process under Claude Code, so the snapshot only
|
|
40
44
|
# says a newer one is out, and a check that cannot tell says nothing.
|
|
45
|
+
# Re-checked because this process outlives an update applied from a
|
|
46
|
+
# terminal; the updater's day-long cache keeps that off the network.
|
|
47
|
+
def watch_for_updates
|
|
48
|
+
loop do
|
|
49
|
+
check_for_update
|
|
50
|
+
sleep @recheck_every
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
41
54
|
def check_for_update
|
|
42
|
-
|
|
55
|
+
notice = @updater.check&.notice
|
|
56
|
+
return if notice == @notice
|
|
57
|
+
@notice = @snapshot.update = notice
|
|
43
58
|
@poller.soon
|
|
44
59
|
rescue Updater::Error
|
|
45
60
|
nil
|
|
@@ -14,10 +14,11 @@ module ClaudeInbox
|
|
|
14
14
|
# The file tells them apart: `tempo` is the agent's own pulse, `fan` names
|
|
15
15
|
# each outstanding piece of work, and `detail` is the session's own status
|
|
16
16
|
# line, with `needs` naming what it is waiting on while blocked and
|
|
17
|
-
# `output.result` what it produced once done. It also carries the PR links
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
17
|
+
# `output.result` what it produced once done. It also carries the PR links
|
|
18
|
+
# the daemon scanned out of the transcript and the transcript's path, which
|
|
19
|
+
# PullRequests reads through here, the color `/color` set on the session,
|
|
20
|
+
# which `claude agents --json` drops, and `intent`, the prompt the session
|
|
21
|
+
# was started from, and `createdAt`, when it was.
|
|
21
22
|
#
|
|
22
23
|
# Never cached. A color changes the moment you type `/color`, so every poll
|
|
23
24
|
# asks the file again.
|
|
@@ -65,7 +66,7 @@ module ClaudeInbox
|
|
|
65
66
|
nil
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
attr_reader :pr_urls, :color, :intent, :bridge_id, :created_at
|
|
69
|
+
attr_reader :pr_urls, :transcript_path, :color, :intent, :bridge_id, :created_at
|
|
69
70
|
|
|
70
71
|
def initialize(hash)
|
|
71
72
|
@detail = hash["detail"]
|
|
@@ -75,6 +76,7 @@ module ClaudeInbox
|
|
|
75
76
|
@kinds = (hash["fan"] || []).filter_map { |f| f["kind"] }
|
|
76
77
|
@tasks = (hash["inFlight"] || {})["tasks"].to_i
|
|
77
78
|
@pr_urls = (hash["children"] || []).filter_map { |c| c["href"] if c["kind"] == "pr" }
|
|
79
|
+
@transcript_path = hash["linkScanPath"]
|
|
78
80
|
@color = hash["color"]
|
|
79
81
|
@intent = hash["intent"]
|
|
80
82
|
@bridge_id = hash["bridgeSessionId"]
|
|
@@ -35,10 +35,10 @@ module ClaudeInbox
|
|
|
35
35
|
# editable field spends these on its own text instead.
|
|
36
36
|
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
37
37
|
|
|
38
|
-
def initialize(cwd:,
|
|
39
|
-
@p = Pastel.new(enabled:
|
|
40
|
-
@theme = Theme.new
|
|
41
|
-
@palette = Palette.new
|
|
38
|
+
def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
39
|
+
@p = Pastel.new(enabled: true)
|
|
40
|
+
@theme = Theme.new
|
|
41
|
+
@palette = Palette.new
|
|
42
42
|
@home = home
|
|
43
43
|
@clipboard = clipboard
|
|
44
44
|
@places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
|
data/lib/claude_inbox/palette.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
5
|
require_relative "records"
|
|
6
|
+
require_relative "stack_links"
|
|
6
7
|
require_relative "subprocess"
|
|
7
8
|
|
|
8
9
|
module ClaudeInbox
|
|
@@ -58,6 +59,8 @@ module ClaudeInbox
|
|
|
58
59
|
# file, which JobState reads onto `job_state`; `claude agents --json` does
|
|
59
60
|
# not expose them.
|
|
60
61
|
# It is a link scan, so a session that merely mentions a PR gets it too.
|
|
62
|
+
# The daemon keeps one PR per session, so StackLinks adds the rest of a
|
|
63
|
+
# stack the session opened.
|
|
61
64
|
# Interactive sessions have no job file; for those the store's `pr` override
|
|
62
65
|
# is the only source.
|
|
63
66
|
#
|
|
@@ -87,6 +90,7 @@ module ClaudeInbox
|
|
|
87
90
|
@known = {} # url => PullRequest
|
|
88
91
|
@checked_at = {} # url => epoch seconds of the last gh call
|
|
89
92
|
@mutex = Mutex.new
|
|
93
|
+
@stacks = StackLinks.new
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
# Each session with its `prs` set from what is already known, asking
|
|
@@ -96,7 +100,7 @@ module ClaudeInbox
|
|
|
96
100
|
# override replaces the scanned list.
|
|
97
101
|
def enrich(sessions, overrides)
|
|
98
102
|
sessions.map do |s|
|
|
99
|
-
urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state
|
|
103
|
+
urls = overrides[s.key] ? [overrides[s.key]] : scanned(s.job_state)
|
|
100
104
|
s.with(prs: urls.map { |u| known(u) })
|
|
101
105
|
end
|
|
102
106
|
end
|
|
@@ -106,13 +110,8 @@ module ClaudeInbox
|
|
|
106
110
|
# the caller knows whether the list is worth publishing again. A merge
|
|
107
111
|
# date alone counts: it is what lets a PR the cache called merged settle.
|
|
108
112
|
def refresh(sessions)
|
|
109
|
-
|
|
110
|
-
fresh
|
|
111
|
-
s.with(prs: s.prs.map { |pr|
|
|
112
|
-
status(pr.url).tap { |now| changed = true if now != pr }
|
|
113
|
-
})
|
|
114
|
-
end
|
|
115
|
-
[fresh, changed]
|
|
113
|
+
fresh = sessions.map { |s| s.with(prs: s.prs.map { |pr| status(pr.url) }) }
|
|
114
|
+
[fresh, fresh.map(&:prs) != sessions.map(&:prs)]
|
|
116
115
|
end
|
|
117
116
|
|
|
118
117
|
# Best known state for a url, refreshed through gh when due.
|
|
@@ -159,8 +158,15 @@ module ClaudeInbox
|
|
|
159
158
|
url.to_s.match?(%r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/pull/\d+/?\z})
|
|
160
159
|
end
|
|
161
160
|
|
|
161
|
+
private_class_method :health
|
|
162
|
+
|
|
162
163
|
private
|
|
163
164
|
|
|
165
|
+
def scanned(job_state)
|
|
166
|
+
return [] unless job_state
|
|
167
|
+
(job_state.pr_urls + @stacks.urls(job_state.transcript_path)).uniq
|
|
168
|
+
end
|
|
169
|
+
|
|
164
170
|
# Best known state for a url without asking gh.
|
|
165
171
|
def known(url)
|
|
166
172
|
@mutex.synchronize { @known[url] ||= seed(url) }
|
|
@@ -86,7 +86,8 @@ module ClaudeInbox
|
|
|
86
86
|
|
|
87
87
|
def key_lines(s)
|
|
88
88
|
return [" paired phones get 401 until they pair again —", " rotate? y/n"] if @asking
|
|
89
|
-
(s.state == :listening) ? [
|
|
89
|
+
keys = (s.state == :listening) ? [%w[c copy], ["r", "new token"], %w[esc close]] : [%w[esc close]]
|
|
90
|
+
[" " + keys.map { |k, action| "#{key(k)} #{@theme.dim(action)}" }.join(" ")]
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
end
|
|
@@ -11,7 +11,10 @@ module ClaudeInbox
|
|
|
11
11
|
# `width` columns wide) plus a parallel Array of selectable items.
|
|
12
12
|
# Pure: no terminal, no IO, no clock beyond the `now` it is handed.
|
|
13
13
|
class Renderer
|
|
14
|
-
Frame = Struct.new(:lines, :items, :top)
|
|
14
|
+
Frame = Struct.new(:lines, :items, :top) do
|
|
15
|
+
# The same frame for a terminal drawn without color.
|
|
16
|
+
def plain = self.class.new(lines.map { |l| Text.strip_ansi(l) }, items, top)
|
|
17
|
+
end
|
|
15
18
|
|
|
16
19
|
CHROME_ROWS = 2 # header + footer
|
|
17
20
|
|
|
@@ -35,14 +38,6 @@ module ClaudeInbox
|
|
|
35
38
|
|
|
36
39
|
SECTION_HUES = {pinned: :cyan, needs_you: :red, active: :yellow, snoozed: :purple}.freeze
|
|
37
40
|
|
|
38
|
-
# GitHub's colors. A draft has no hue, so it dims; a state missing from
|
|
39
|
-
# the table isn't named on the badge at all.
|
|
40
|
-
PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
|
|
41
|
-
|
|
42
|
-
# The hue of PR_TROUBLE after a badge. Red for what puts the row in
|
|
43
|
-
# Needs You; a conflict dims, since it waits on a rebase, not on you.
|
|
44
|
-
PR_TROUBLE_HUES = {"FAILING" => :red, "CHANGES_REQUESTED" => :red}.freeze
|
|
45
|
-
|
|
46
41
|
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
47
42
|
|
|
48
43
|
# The first poll usually lands inside a second. Past LOADING_QUIET the
|
|
@@ -67,10 +62,10 @@ module ClaudeInbox
|
|
|
67
62
|
["za", "fold"], ["/", "filter"], ["q", "quit"]
|
|
68
63
|
].freeze
|
|
69
64
|
|
|
70
|
-
def initialize(
|
|
71
|
-
@p = Pastel.new(enabled:
|
|
72
|
-
@theme = Theme.new
|
|
73
|
-
@palette = Palette.new
|
|
65
|
+
def initialize(home: Dir.home)
|
|
66
|
+
@p = Pastel.new(enabled: true)
|
|
67
|
+
@theme = Theme.new
|
|
68
|
+
@palette = Palette.new
|
|
74
69
|
@home = home
|
|
75
70
|
end
|
|
76
71
|
|
|
@@ -178,15 +173,19 @@ module ClaudeInbox
|
|
|
178
173
|
header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
|
|
179
174
|
]
|
|
180
175
|
chips << @p.dim("nothing running") if sections.all.empty?
|
|
181
|
-
[*chips, listening_chip(listening)].compact.join(compact ? " " : @p.dim(" · "))
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
# Red when the listener was asked for and isn't up.
|
|
185
|
-
# wasn't asked for.
|
|
186
|
-
def listening_chip(listening)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
176
|
+
[*chips, listening_chip(listening, compact)].compact.join(compact ? " " : @p.dim(" · "))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Red, and saying why, when the listener was asked for and isn't up.
|
|
180
|
+
# Nothing when it wasn't asked for.
|
|
181
|
+
def listening_chip(listening, compact)
|
|
182
|
+
return unless listening
|
|
183
|
+
address = "#{"lan" if listening.lan}:#{listening.port}"
|
|
184
|
+
case listening.state
|
|
185
|
+
when :listening then @theme.blue("◉ #{address}")
|
|
186
|
+
when :held then @theme.red(compact ? "◉ #{address} held" : "◉ #{address} held by another inbox")
|
|
187
|
+
when :in_use then @theme.red("◉ #{address} in use")
|
|
188
|
+
when :failed then @theme.red("◉ #{address} failed")
|
|
190
189
|
end
|
|
191
190
|
end
|
|
192
191
|
|
|
@@ -216,20 +215,14 @@ module ClaudeInbox
|
|
|
216
215
|
Text.pad(" " + section_color(name, "▎") + section_color(name, @p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
|
|
217
216
|
end
|
|
218
217
|
|
|
219
|
-
def section_color(name, s) = tint(SECTION_HUES[name], s)
|
|
220
|
-
|
|
221
|
-
def tint(hue, s)
|
|
222
|
-
hue ? @theme.public_send(hue, s) : @p.dim(s)
|
|
223
|
-
end
|
|
218
|
+
def section_color(name, s) = @theme.tint(SECTION_HUES[name], s)
|
|
224
219
|
|
|
225
220
|
# ----- body ---------------------------------------------------------------
|
|
226
221
|
|
|
227
222
|
def body_lines(sections, width, view)
|
|
223
|
+
return [empty_state(width), []] if sections.all.empty?
|
|
228
224
|
lines = []
|
|
229
225
|
items = []
|
|
230
|
-
if sections.all.empty?
|
|
231
|
-
return [empty_state(width), []]
|
|
232
|
-
end
|
|
233
226
|
selected = view.selected
|
|
234
227
|
sections.each_pair do |name, rows|
|
|
235
228
|
next if rows.empty?
|
|
@@ -324,7 +317,7 @@ module ClaudeInbox
|
|
|
324
317
|
|
|
325
318
|
label_w = [width - chrome - Text.width(project_text), 0].max
|
|
326
319
|
label = Text.truncate(row.label, label_w)
|
|
327
|
-
label = style_label(label, row, quiet, sel ||
|
|
320
|
+
label = style_label(label, row, quiet, sel || unread)
|
|
328
321
|
first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
|
|
329
322
|
|
|
330
323
|
return [Text.pad(first, width)] unless live
|
|
@@ -399,20 +392,13 @@ module ClaudeInbox
|
|
|
399
392
|
pr ? base + @p.dim(" · ") + pr : base
|
|
400
393
|
end
|
|
401
394
|
|
|
402
|
-
#
|
|
403
|
-
# headline PR is shown, with a count of the rest.
|
|
395
|
+
# The headline PR, with a count of the rest. A settled row dims it.
|
|
404
396
|
def pr_badge(s, section)
|
|
405
397
|
pr = s.pr
|
|
406
398
|
return nil unless pr
|
|
399
|
+
badge = (section == :settled) ? @p.dim("#{pr.short} #{pr.state&.downcase}".strip) : @theme.pr_badge(pr)
|
|
407
400
|
more = (s.prs.size > 1) ? @p.dim(" · +#{s.prs.size - 1}") : ""
|
|
408
|
-
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
def pr_state_badge(pr, section)
|
|
412
|
-
return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
|
|
413
|
-
return @p.dim(pr.short) unless PR_HUES.key?(pr.state)
|
|
414
|
-
badge = tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
|
|
415
|
-
pr.trouble ? badge + @p.dim(" · ") + tint(PR_TROUBLE_HUES[pr.health], pr.trouble) : badge
|
|
401
|
+
badge + more
|
|
416
402
|
end
|
|
417
403
|
|
|
418
404
|
def state_badge(s)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# The PRs a session opened with `gh stack submit`, read from its
|
|
7
|
+
# transcript. The daemon links a session to one PR only, so a stack of
|
|
8
|
+
# three shows as its first; this finds the rest.
|
|
9
|
+
#
|
|
10
|
+
# Transcripts run to megabytes and every poll asks, so each file is read
|
|
11
|
+
# once and then only for what was appended since.
|
|
12
|
+
class StackLinks
|
|
13
|
+
# gh-stack's line for each PR it opens. Matched against a Bash result's
|
|
14
|
+
# own stdout, at the start of a line, so a session that merely quotes
|
|
15
|
+
# one doesn't count.
|
|
16
|
+
CREATED = %r{^✓ Created PR #\d+ \((https://github\.com/[^/\s]+/[^/\s]+/pull/\d+)\)}
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@seen = {} # path => [bytes read, urls]
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# => the PR urls in creation order, [] for a missing or unreadable file.
|
|
24
|
+
def urls(path)
|
|
25
|
+
return [] unless path
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
offset, urls = @seen.fetch(path, [0, []])
|
|
28
|
+
offset, urls = 0, [] if File.size(path) < offset
|
|
29
|
+
added = File.open(path, "r:UTF-8") { |f| f.seek(offset) && f.read }
|
|
30
|
+
complete = added.include?("\n") ? added[..added.rindex("\n")] : ""
|
|
31
|
+
@seen[path] = [offset + complete.bytesize, (urls + scan(complete)).uniq]
|
|
32
|
+
@seen[path].last
|
|
33
|
+
end
|
|
34
|
+
rescue SystemCallError
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def scan(text)
|
|
41
|
+
text.each_line.select { |l| l.include?("Created PR #") }.flat_map do |line|
|
|
42
|
+
stdout = JSON.parse(line).dig("toolUseResult", "stdout")
|
|
43
|
+
stdout.is_a?(String) ? stdout.scan(CREATED).flatten : []
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -14,8 +14,7 @@ module ClaudeInbox
|
|
|
14
14
|
def section_of(selection)
|
|
15
15
|
return nil if selection.nil?
|
|
16
16
|
return selection.key if selection.fold?
|
|
17
|
-
each_pair { |
|
|
18
|
-
nil
|
|
17
|
+
each_pair.find { |_, rows| rows.any? { |r| r.key == selection.key } }&.first
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
def selections(expanded) = stops(expanded).flat_map(&:last)
|
data/lib/claude_inbox/text.rb
CHANGED
|
@@ -7,6 +7,8 @@ module ClaudeInbox
|
|
|
7
7
|
# session names carry emoji and glyphs that break byte/char slicing.
|
|
8
8
|
module Text
|
|
9
9
|
ANSI = /\e\[[0-9;?]*[A-Za-z]/
|
|
10
|
+
# A grapheme, or an escape that takes no column.
|
|
11
|
+
GLYPH = /#{ANSI}|\X/
|
|
10
12
|
ELLIPSIS = "…"
|
|
11
13
|
|
|
12
14
|
module_function
|
|
@@ -21,25 +23,26 @@ module ClaudeInbox
|
|
|
21
23
|
|
|
22
24
|
def width(s) = Unicode::DisplayWidth.of(strip_ansi(s))
|
|
23
25
|
|
|
24
|
-
# Truncate
|
|
25
|
-
#
|
|
26
|
+
# Truncate to `w` columns, appending an ellipsis when anything was cut.
|
|
27
|
+
# Handles wide glyphs by stepping grapheme by grapheme.
|
|
26
28
|
def truncate(s, w)
|
|
27
29
|
return "" if w <= 0
|
|
28
30
|
return s if width(s) <= w
|
|
29
31
|
take(s, w - width(ELLIPSIS)) << ELLIPSIS
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
# First `n` columns
|
|
34
|
+
# First `n` columns, no ellipsis. Colors survive the cut, and a reset
|
|
35
|
+
# closes whichever one the cut left open.
|
|
33
36
|
def take(s, n)
|
|
34
37
|
out = +""
|
|
35
38
|
used = 0
|
|
36
|
-
s.
|
|
37
|
-
gw = Unicode::DisplayWidth.of(g)
|
|
39
|
+
s.scan(GLYPH) do |g|
|
|
40
|
+
gw = g.start_with?("\e") ? 0 : Unicode::DisplayWidth.of(g)
|
|
38
41
|
break if used + gw > n
|
|
39
42
|
out << g
|
|
40
43
|
used += gw
|
|
41
44
|
end
|
|
42
|
-
out
|
|
45
|
+
(out.include?("\e") && !out.end_with?("\e[0m")) ? out << "\e[0m" : out
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
# Plain text with its first `n` columns removed.
|
|
@@ -56,14 +59,10 @@ module ClaudeInbox
|
|
|
56
59
|
out
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
# Right-pad
|
|
62
|
+
# Right-pad to exactly `w` columns. Truncates if too long.
|
|
60
63
|
def pad(s, w)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
s = truncate(strip_ansi(s), w)
|
|
64
|
-
cur = width(s)
|
|
65
|
-
end
|
|
66
|
-
s + (" " * [w - cur, 0].max)
|
|
64
|
+
s = truncate(s, w)
|
|
65
|
+
s + (" " * [w - width(s), 0].max)
|
|
67
66
|
end
|
|
68
67
|
|
|
69
68
|
# Greedy word wrap on display width. Words wider than `w` are split.
|
data/lib/claude_inbox/theme.rb
CHANGED
|
@@ -8,26 +8,39 @@ module ClaudeInbox
|
|
|
8
8
|
HUES = {red: 203, green: 108, yellow: 179, blue: 75, purple: 176, cyan: 73}.freeze
|
|
9
9
|
BG = 236
|
|
10
10
|
|
|
11
|
+
# GitHub's colors. A draft has no hue, so it dims; a state missing from
|
|
12
|
+
# the table isn't named on the badge at all.
|
|
13
|
+
PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
|
|
14
|
+
|
|
15
|
+
# The hue of PR_TROUBLE after a badge. Red for what puts the row in
|
|
16
|
+
# Needs You; a conflict dims, since it waits on a rebase, not on you.
|
|
17
|
+
PR_TROUBLE_HUES = {"FAILING" => :red, "CHANGES_REQUESTED" => :red}.freeze
|
|
18
|
+
|
|
11
19
|
HUES.each_key do |hue|
|
|
12
20
|
define_method(hue) { |text| color(text, hue) }
|
|
13
21
|
define_method(:"#{hue}_bold") { |text| color(text, hue, bold: true) }
|
|
14
22
|
end
|
|
15
23
|
|
|
16
|
-
def
|
|
17
|
-
|
|
24
|
+
def dim(text) = "\e[2m#{text}\e[0m"
|
|
25
|
+
|
|
26
|
+
# The hue, or dim when there is none.
|
|
27
|
+
def tint(hue, text) = hue ? public_send(hue, text) : dim(text)
|
|
28
|
+
|
|
29
|
+
# "#885 open", or "#885 open · ✗ checks" when something is wrong.
|
|
30
|
+
# `name` replaces the bare number where that would be ambiguous.
|
|
31
|
+
def pr_badge(pr, name = pr.short)
|
|
32
|
+
return dim(name) unless PR_HUES.key?(pr.state)
|
|
33
|
+
badge = tint(PR_HUES[pr.state], "#{name} #{pr.state.downcase}")
|
|
34
|
+
pr.trouble ? badge + dim(" · ") + tint(PR_TROUBLE_HUES[pr.health], pr.trouble) : badge
|
|
18
35
|
end
|
|
19
36
|
|
|
20
37
|
# A filled pill: editor-background text on the hue, for a selected row
|
|
21
38
|
# or choice — the inverse of `color`, which puts the hue on the text.
|
|
22
|
-
def pill(text, hue)
|
|
23
|
-
return text unless @enabled
|
|
24
|
-
"\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
|
|
25
|
-
end
|
|
39
|
+
def pill(text, hue) = "\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
|
|
26
40
|
|
|
27
41
|
private
|
|
28
42
|
|
|
29
43
|
def color(text, hue, bold: false)
|
|
30
|
-
return text unless @enabled
|
|
31
44
|
codes = ["38;5;#{HUES.fetch(hue)}"]
|
|
32
45
|
codes << "1" if bold
|
|
33
46
|
"\e[#{codes.join(";")}m#{text}\e[0m"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "subprocess"
|
|
4
5
|
|
|
5
6
|
module ClaudeInbox
|
|
6
7
|
# One snapshot writer per machine: the inbox you can see, else a headless
|
|
@@ -25,8 +26,8 @@ module ClaudeInbox
|
|
|
25
26
|
file = File.open(@path, File::RDWR | File::CREAT, 0o600)
|
|
26
27
|
return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
27
28
|
holder_role, holder = file.read.split
|
|
28
|
-
return refuse(file) unless role == "inbox" && holder_role == "headless"
|
|
29
|
-
Process.kill("TERM", holder.to_i)
|
|
29
|
+
return refuse(file) unless role == "inbox" && holder_role == "headless" && headless?(holder.to_i)
|
|
30
|
+
Process.kill("TERM", holder.to_i)
|
|
30
31
|
EVICT_TRIES.times do
|
|
31
32
|
return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
32
33
|
sleep 0.1
|
|
@@ -39,6 +40,14 @@ module ClaudeInbox
|
|
|
39
40
|
|
|
40
41
|
private
|
|
41
42
|
|
|
43
|
+
# The file can name a pid that no longer holds the lock (#322), which by
|
|
44
|
+
# now may be some other process; only a headless claude-inbox is ended.
|
|
45
|
+
def headless?(pid)
|
|
46
|
+
return false unless pid.positive? && pid != Process.pid
|
|
47
|
+
command = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
|
|
48
|
+
command.include?("claude-inbox") && command.split.include?("--headless")
|
|
49
|
+
end
|
|
50
|
+
|
|
42
51
|
# After reading the holder the position sits at the end, and truncate does
|
|
43
52
|
# not move it, so the write is rewound first or it lands after NUL padding.
|
|
44
53
|
def hold(file, role)
|
data/lib/claude_inbox.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-inbox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Byron
|
|
@@ -155,6 +155,7 @@ files:
|
|
|
155
155
|
- lib/claude_inbox/slash_commands.rb
|
|
156
156
|
- lib/claude_inbox/snapshot.rb
|
|
157
157
|
- lib/claude_inbox/snooze_time.rb
|
|
158
|
+
- lib/claude_inbox/stack_links.rb
|
|
158
159
|
- lib/claude_inbox/store.rb
|
|
159
160
|
- lib/claude_inbox/store/entry.rb
|
|
160
161
|
- lib/claude_inbox/store/row.rb
|