claude-inbox 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +442 -0
- data/exe/claude-inbox +33 -0
- data/lib/claude_inbox/agents_client.rb +224 -0
- data/lib/claude_inbox/app.rb +533 -0
- data/lib/claude_inbox/debug.rb +16 -0
- data/lib/claude_inbox/dialog.rb +105 -0
- data/lib/claude_inbox/images.rb +58 -0
- data/lib/claude_inbox/job_state.rb +89 -0
- data/lib/claude_inbox/keymap.rb +71 -0
- data/lib/claude_inbox/logs.rb +66 -0
- data/lib/claude_inbox/mouse.rb +34 -0
- data/lib/claude_inbox/new_session_form.rb +363 -0
- data/lib/claude_inbox/palette.rb +40 -0
- data/lib/claude_inbox/paste.rb +42 -0
- data/lib/claude_inbox/peek.rb +81 -0
- data/lib/claude_inbox/poller.rb +98 -0
- data/lib/claude_inbox/pull_requests.rb +155 -0
- data/lib/claude_inbox/rate_limits.rb +48 -0
- data/lib/claude_inbox/reaper.rb +103 -0
- data/lib/claude_inbox/records.rb +28 -0
- data/lib/claude_inbox/renderer.rb +447 -0
- data/lib/claude_inbox/session.rb +100 -0
- data/lib/claude_inbox/sessions.rb +18 -0
- data/lib/claude_inbox/settings.rb +39 -0
- data/lib/claude_inbox/slash_commands.rb +116 -0
- data/lib/claude_inbox/store/entry.rb +144 -0
- data/lib/claude_inbox/store/row.rb +101 -0
- data/lib/claude_inbox/store/sections.rb +47 -0
- data/lib/claude_inbox/store/selection.rb +16 -0
- data/lib/claude_inbox/store.rb +156 -0
- data/lib/claude_inbox/subprocess.rb +53 -0
- data/lib/claude_inbox/terminal.rb +101 -0
- data/lib/claude_inbox/text.rb +98 -0
- data/lib/claude_inbox/text_buffer.rb +216 -0
- data/lib/claude_inbox/theme.rb +35 -0
- data/lib/claude_inbox/vt_screen.rb +138 -0
- data/lib/claude_inbox.rb +14 -0
- metadata +163 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "tty-cursor"
|
|
5
|
+
require_relative "text"
|
|
6
|
+
require_relative "palette"
|
|
7
|
+
require_relative "theme"
|
|
8
|
+
require_relative "store"
|
|
9
|
+
|
|
10
|
+
module ClaudeInbox
|
|
11
|
+
# sections -> Array<String> (one entry per terminal row, each exactly
|
|
12
|
+
# `width` columns wide) plus a parallel Array of selectable items.
|
|
13
|
+
# Pure: no terminal, no IO, no clock beyond the `now` it is handed.
|
|
14
|
+
class Renderer
|
|
15
|
+
Item = Struct.new(:kind, :row, :section) do
|
|
16
|
+
def selection = (kind == :fold_toggle) ? Store::Selection.fold(section) : Store::Selection.row(row.key)
|
|
17
|
+
|
|
18
|
+
def key = selection.key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Frame = Struct.new(:lines, :items, :top, :list_width)
|
|
22
|
+
|
|
23
|
+
SECTION_TITLES = {
|
|
24
|
+
pinned: "PINNED",
|
|
25
|
+
needs_you: "NEEDS YOU",
|
|
26
|
+
active: "ACTIVE",
|
|
27
|
+
snoozed: "SNOOZED",
|
|
28
|
+
settled: "SETTLED"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
32
|
+
|
|
33
|
+
# The first poll usually lands inside a second. Past LOADING_QUIET the
|
|
34
|
+
# wait is long enough to feel like a hang, so it gets some company; past
|
|
35
|
+
# LOADING_HINT_AFTER it is long enough to be one.
|
|
36
|
+
LOADING_QUIET = 1.5
|
|
37
|
+
LOADING_HINT_AFTER = 10
|
|
38
|
+
QUIPS = [
|
|
39
|
+
"asking the daemon nicely…",
|
|
40
|
+
"counting agents…",
|
|
41
|
+
"reticulating splines…",
|
|
42
|
+
"herding sessions…",
|
|
43
|
+
"checking behind the couch…",
|
|
44
|
+
"reading the process tree…",
|
|
45
|
+
"polishing the spinner…",
|
|
46
|
+
"still here…"
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
KEYS = [
|
|
50
|
+
["j/k", "move"], ["⏎", "attach"], ["n", "new"], ["t", "pin"], ["s", "snooze"], ["u", "wake"],
|
|
51
|
+
["a", "alias"], ["o", "PR"], ["x", "settle"], ["p", "peek"], ["⇥", "section"],
|
|
52
|
+
["za", "fold"], ["/", "filter"], ["q", "quit"]
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
def initialize(color: true, min_left: 44, home: Dir.home)
|
|
56
|
+
@p = Pastel.new(enabled: color)
|
|
57
|
+
@theme = Theme.new(enabled: color)
|
|
58
|
+
@palette = Palette.new(enabled: color)
|
|
59
|
+
@min_left = min_left
|
|
60
|
+
@home = home
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def caret = ->(cell) { @p.inverse(cell) }
|
|
64
|
+
|
|
65
|
+
# opts: selected (id | :snoozed | :settled | nil), expanded ({snoozed:, settled:} => bool), top (scroll),
|
|
66
|
+
# peek (Array<String> | nil), peek_title, modal (Array<String> | nil),
|
|
67
|
+
# status (String | nil, a notice or error at the header's right end),
|
|
68
|
+
# usage (String | nil, the rate-limit label after it), now (Time), filter (TextBuffer | nil), filter_editing,
|
|
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])
|
|
76
|
+
view_h = height - 2 # header + footer
|
|
77
|
+
body, items =
|
|
78
|
+
if opts[:loading]
|
|
79
|
+
[loading_state(list_w, view_h, opts[:loading], opts[:tick].to_i), []]
|
|
80
|
+
else
|
|
81
|
+
body_lines(sections, list_w, selected, opts, now)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
top = clamp_top(opts[:top] || 0, body.size, view_h, items, selected)
|
|
85
|
+
visible = body[top, view_h] || []
|
|
86
|
+
visible_items = items[top, view_h] || []
|
|
87
|
+
visible += [""] * (view_h - visible.size)
|
|
88
|
+
visible_items += [nil] * (view_h - visible_items.size)
|
|
89
|
+
|
|
90
|
+
if opts[:peek]
|
|
91
|
+
peek_w = width - list_w - 1
|
|
92
|
+
peek_lines = peek_pane(opts[:peek], opts[:peek_title], opts[:peek_subtitle], peek_w, view_h)
|
|
93
|
+
visible = visible.each_with_index.map do |l, i|
|
|
94
|
+
Text.pad(l, list_w) + @p.dim("│") + Text.pad(peek_lines[i] || "", peek_w)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
lines = [header(sections, width, opts, now)] + visible.map { |l| Text.pad(l, width) } + [footer(width, opts)]
|
|
99
|
+
lines = overlay(lines, opts[:modal], width) if opts[:modal]
|
|
100
|
+
Frame.new(lines, [nil] + visible_items + [nil], top, list_w)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def width_for_list(width, peek)
|
|
106
|
+
return width unless peek
|
|
107
|
+
[(width * 0.4).floor, @min_left].max.clamp(0, width)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def clamp_top(top, size, view_h, items, selected)
|
|
111
|
+
idx = items.index { |item| item && item.key == selected }
|
|
112
|
+
top = idx - 2 if idx && idx - 2 < top # keep the section title in view
|
|
113
|
+
top = idx - view_h + 1 if idx && idx >= top + view_h
|
|
114
|
+
top.clamp(0, [size - view_h, 0].max)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def full_screen(sections, width, height, now, opts)
|
|
118
|
+
view_h = height - 2
|
|
119
|
+
body = opts[:screen][:lines].first(view_h)
|
|
120
|
+
body += [""] * (view_h - body.size)
|
|
121
|
+
lines = [header(sections, width, opts, now)] + body.map { |l| Text.pad(l, width) } + [Text.pad(" " + opts[:screen][:footer], width)]
|
|
122
|
+
Frame.new(lines, [nil] * (view_h + 2), opts[:top] || 0, width)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ----- chrome -------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def header(sections, width, opts, now)
|
|
128
|
+
brand = " " + @theme.cyan_bold("▌ claude-inbox")
|
|
129
|
+
right = [opts[:status], opts[:usage]].compact.map { |s| @p.dim(s) }.join(@p.dim(" · "))
|
|
130
|
+
right += " " unless right.empty?
|
|
131
|
+
room = width - Text.width(brand) - Text.width(right) - 3
|
|
132
|
+
chips = opts[:loading] ? "" : header_chips(sections, compact: false)
|
|
133
|
+
chips = header_chips(sections, compact: true) if Text.width(chips) > room
|
|
134
|
+
chips = "" if Text.width(chips) > room
|
|
135
|
+
Text.pad(brand + " " + chips, width - Text.width(right)) + right
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def header_chips(sections, compact:)
|
|
139
|
+
pn = sections.pinned.size
|
|
140
|
+
n = sections.needs_you.size
|
|
141
|
+
w = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
|
|
142
|
+
q = sections.active.count { |r| r.session.waiting_on_work? }
|
|
143
|
+
i = sections.all.count { |r| r.session.terminal? }
|
|
144
|
+
m = sections.all.count { |r| r.session.remote? }
|
|
145
|
+
z = sections.snoozed.size
|
|
146
|
+
d = sections.settled.size
|
|
147
|
+
chips = []
|
|
148
|
+
chips << @theme.cyan_bold(compact ? "★ #{pn}" : "★ #{pn} pinned") if pn > 0
|
|
149
|
+
chips << @theme.red_bold(compact ? "● #{n}" : "● #{n} need#{"s" if n == 1} you") if n > 0
|
|
150
|
+
chips << @theme.yellow(compact ? "✻ #{w}" : "✻ #{w} working") if w > 0
|
|
151
|
+
chips << @theme.yellow(compact ? "◌ #{q}" : "◌ #{q} idle") if q > 0
|
|
152
|
+
chips << @p.dim(compact ? "○ #{i}" : "○ #{i} terminal#{"s" if i > 1}") if i > 0
|
|
153
|
+
chips << @theme.blue(compact ? "⇅ #{m}" : "⇅ #{m} remote") if m > 0
|
|
154
|
+
chips << @theme.purple(compact ? "z #{z}" : "z #{z} snoozed") if z > 0
|
|
155
|
+
chips << @p.dim(compact ? "◦ #{d}" : "◦ #{d} settled") if d > 0
|
|
156
|
+
chips << @p.dim("nothing running") if sections.all.empty?
|
|
157
|
+
chips.join(compact ? " " : @p.dim(" · "))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def footer(width, opts)
|
|
161
|
+
text =
|
|
162
|
+
if opts[:filter_editing] then " " + @theme.cyan_bold("/") + line(opts[:filter], width - 2)
|
|
163
|
+
elsif opts[:filter] then " " + @theme.cyan_bold("/") + opts[:filter].to_s + @p.dim(" esc clears")
|
|
164
|
+
else " " + KEYS.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
|
|
165
|
+
end
|
|
166
|
+
Text.pad(text, width)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def line(buffer, width) = buffer.row(width, cursor: caret)
|
|
170
|
+
|
|
171
|
+
def section_title(name, count, width)
|
|
172
|
+
title = " #{SECTION_TITLES[name]} "
|
|
173
|
+
count_s = " #{count} "
|
|
174
|
+
fill = [width - 3 - Text.width(title) - Text.width(count_s), 0].max
|
|
175
|
+
color = section_color(name)
|
|
176
|
+
Text.pad(" " + color.call("▎") + color.call(@p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def section_color(name)
|
|
180
|
+
case name
|
|
181
|
+
when :pinned then ->(s) { @theme.cyan(s) }
|
|
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
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ----- body ---------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def body_lines(sections, width, selected, opts, now)
|
|
192
|
+
lines = []
|
|
193
|
+
items = []
|
|
194
|
+
if sections.all.empty?
|
|
195
|
+
return [empty_state(width), []]
|
|
196
|
+
end
|
|
197
|
+
expanded = opts[:expanded] || {}
|
|
198
|
+
sections.each_section do |name, rows|
|
|
199
|
+
next if rows.empty?
|
|
200
|
+
lines << "" << section_title(name, rows.size, width)
|
|
201
|
+
items << nil << nil
|
|
202
|
+
if Store.folded?(name, expanded)
|
|
203
|
+
lines << fold_toggle_line(name, rows.size, selected, width)
|
|
204
|
+
items << Item.new(:fold_toggle, nil, name)
|
|
205
|
+
next
|
|
206
|
+
end
|
|
207
|
+
rows.each do |row|
|
|
208
|
+
row_lines(row, name, selected, width, now, opts[:tick].to_i).each_with_index do |l, i|
|
|
209
|
+
lines << l
|
|
210
|
+
items << ((i.zero? && row.selectable?) ? Item.new(:row, row, name) : nil)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
[lines, items]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Nothing to list yet and no way to know whether that means nothing is
|
|
218
|
+
# running, so neither the empty state nor the chips. A short wait gets
|
|
219
|
+
# a blank body; a long one gets a spinner, a dot pacing its tray and a
|
|
220
|
+
# rotating excuse, with the elapsed time so a hang looks like one.
|
|
221
|
+
def loading_state(width, height, waited, tick)
|
|
222
|
+
return [] if waited < LOADING_QUIET
|
|
223
|
+
block = [
|
|
224
|
+
centered(@theme.cyan_bold(SPINNER[tick % SPINNER.size]), width),
|
|
225
|
+
"",
|
|
226
|
+
centered(tray(tick), width),
|
|
227
|
+
"",
|
|
228
|
+
centered(QUIPS[(tick / 6) % QUIPS.size], width),
|
|
229
|
+
centered(@p.dim("waiting on claude agents · #{waited.floor}s"), width)
|
|
230
|
+
]
|
|
231
|
+
if waited >= LOADING_HINT_AFTER
|
|
232
|
+
block << "" << centered(@p.dim("slow? ") + @theme.cyan("claude daemon status") + @p.dim(" says whether the daemon is up"), width)
|
|
233
|
+
end
|
|
234
|
+
[""] * [(height - block.size) / 2, 0].max + block
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
TRAY_SLOTS = 9
|
|
238
|
+
|
|
239
|
+
# A dot bouncing between the ends of a tray, one slot per tick.
|
|
240
|
+
def tray(tick)
|
|
241
|
+
span = TRAY_SLOTS - 1
|
|
242
|
+
i = tick % (span * 2)
|
|
243
|
+
pos = (i <= span) ? i : span * 2 - i
|
|
244
|
+
cells = Array.new(TRAY_SLOTS) { |j| (j == pos) ? @theme.cyan_bold("●") : @p.dim("·") }
|
|
245
|
+
@p.dim("▌") + " " + cells.join(" ") + " " + @p.dim("▐")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def centered(s, width)
|
|
249
|
+
left = [(width - Text.width(s)) / 2, 0].max
|
|
250
|
+
Text.pad(" " * left + s, width)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def empty_state(width)
|
|
254
|
+
[
|
|
255
|
+
"", "",
|
|
256
|
+
Text.pad(" " + @p.bold("Nothing running."), width),
|
|
257
|
+
Text.pad(" " + @p.dim("Start one from any terminal with ") + @theme.cyan("claude --bg \"task\""), width),
|
|
258
|
+
Text.pad(" " + @p.dim("or press ") + @theme.cyan("R") + @p.dim(" to poll again."), width)
|
|
259
|
+
]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def fold_toggle_line(name, count, selected, width)
|
|
263
|
+
sel = selected == name
|
|
264
|
+
marker = sel ? @theme.cyan_bold("▶") : " "
|
|
265
|
+
text = @p.dim("… #{count} #{SECTION_TITLES[name].downcase}") + (sel ? @p.dim(" ⏎ or zo to expand") : "")
|
|
266
|
+
Text.pad(" #{marker} " + text, width)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def row_lines(row, section, selected, width, now, tick)
|
|
270
|
+
s = row.session
|
|
271
|
+
sel = row.selectable? && selected == row.key
|
|
272
|
+
marker = sel ? @theme.cyan_bold("▶") : " "
|
|
273
|
+
glyph = glyph_for(s, section, tick)
|
|
274
|
+
meta = meta_for(row, section, now)
|
|
275
|
+
|
|
276
|
+
# " " marker " " glyph " " label " " meta " " project " "
|
|
277
|
+
chrome = 1 + 1 + 1 + 1 + 1 + 2 + Text.width(meta) + 2 + 1
|
|
278
|
+
# Project is only cut once the label has given up all its space too,
|
|
279
|
+
# so the row can never exceed `width` and fall into Text.pad's blind
|
|
280
|
+
# tail-chop (which used to land mid-project-name with no ellipsis).
|
|
281
|
+
project_text = Text.truncate(s.project, [width - chrome, 0].max)
|
|
282
|
+
project = (section == :settled) ? @p.dim(project_text) : @theme.cyan(project_text)
|
|
283
|
+
|
|
284
|
+
label_w = [width - chrome - Text.width(project_text), 0].max
|
|
285
|
+
label = Text.truncate(row.label, label_w)
|
|
286
|
+
label = style_label(label, row, section, sel)
|
|
287
|
+
first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
|
|
288
|
+
|
|
289
|
+
return [Text.pad(first, width)] unless %i[pinned needs_you active].include?(section)
|
|
290
|
+
|
|
291
|
+
# The session's own line when it has one; the path is what is left to
|
|
292
|
+
# say about a terminal, which has no job file.
|
|
293
|
+
detail = @p.dim(" ↳ " + Text.truncate(s.summary || short_path(s.cwd), [width - 10, 0].max))
|
|
294
|
+
[Text.pad(first, width), Text.pad(detail, width)]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The label is the one part of a row you own: `/color` tints it, and
|
|
298
|
+
# nothing else on the line. Glyph, badge and PR keep the state's colors,
|
|
299
|
+
# so no color you pick can make a blocked session stop looking blocked.
|
|
300
|
+
# Settled stays dim — the section is meant to be quiet.
|
|
301
|
+
def style_label(label, row, section, sel)
|
|
302
|
+
return @p.dim(label) if section == :settled
|
|
303
|
+
styled =
|
|
304
|
+
if row.alias_name then sel ? @p.bold.italic(label) : @p.italic(label)
|
|
305
|
+
elsif sel then @p.bold(label)
|
|
306
|
+
else label
|
|
307
|
+
end
|
|
308
|
+
@palette.paint(styled, row.session.color)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def glyph_for(s, section, tick)
|
|
312
|
+
return @theme.purple("z") if section == :snoozed
|
|
313
|
+
return @p.dim("◦") if section == :settled
|
|
314
|
+
case s.effective_state
|
|
315
|
+
when "blocked" then @theme.red_bold("●")
|
|
316
|
+
when "failed" then @theme.red_bold("✗")
|
|
317
|
+
when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(SPINNER[tick % SPINNER.size])
|
|
318
|
+
when "done" then @theme.green("✓")
|
|
319
|
+
when "stopped" then @p.dim("■")
|
|
320
|
+
else @p.dim("?")
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def meta_for(row, section, now)
|
|
325
|
+
s = row.session
|
|
326
|
+
base =
|
|
327
|
+
case section
|
|
328
|
+
when :snoozed
|
|
329
|
+
row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
|
|
330
|
+
when :settled
|
|
331
|
+
@p.dim("#{s.state} · #{Text.age(now.to_i - row.state_since.to_i)}")
|
|
332
|
+
else
|
|
333
|
+
if s.interactive?
|
|
334
|
+
where = s.remote? ? "remote" : "your terminal"
|
|
335
|
+
age = row.state_since ? Text.age(now.to_i - row.state_since.to_i) : Text.age(now - s.started_at)
|
|
336
|
+
state_badge(s) + @p.dim(" · #{where} · #{age}")
|
|
337
|
+
else
|
|
338
|
+
age = row.state_since ? @p.dim(" · " + Text.age(now.to_i - row.state_since.to_i)) : ""
|
|
339
|
+
state_badge(s) + age
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
pr = pr_badge(s, section)
|
|
343
|
+
pr ? base + @p.dim(" · ") + pr : base
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# "#885 open" in GitHub's colors: green open, dim draft, purple merged,
|
|
347
|
+
# red closed. Only the first PR is shown; the peek subtitle lists them all.
|
|
348
|
+
def pr_badge(s, section)
|
|
349
|
+
pr = s.pr
|
|
350
|
+
return nil unless pr
|
|
351
|
+
return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
|
|
352
|
+
case pr.state
|
|
353
|
+
when "OPEN" then @theme.green("#{pr.short} open")
|
|
354
|
+
when "DRAFT" then @p.dim("#{pr.short} draft")
|
|
355
|
+
when "MERGED" then @theme.purple("#{pr.short} merged")
|
|
356
|
+
when "CLOSED" then @theme.red("#{pr.short} closed")
|
|
357
|
+
else @p.dim(pr.short)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def state_badge(s)
|
|
362
|
+
case s.effective_state
|
|
363
|
+
when "blocked"
|
|
364
|
+
detail = s.waiting_for ? ": #{s.waiting_for}" : ""
|
|
365
|
+
@theme.red_bold("needs you#{detail}")
|
|
366
|
+
when "failed" then @theme.red_bold("failed")
|
|
367
|
+
when "working" then working_badge(s)
|
|
368
|
+
when "done" then @theme.green("done") + ((s.alive? && !s.interactive?) ? @p.dim(" · #{s.status}") : "")
|
|
369
|
+
when "stopped" then @p.dim("stopped")
|
|
370
|
+
else @p.dim(s.state.to_s)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# "working" while the agent is thinking, "idle" once it has stopped and
|
|
375
|
+
# only the work it kicked off is still open — with that work named either
|
|
376
|
+
# way, since "working · 2 agents" is the answer to "working on what?".
|
|
377
|
+
def working_badge(s)
|
|
378
|
+
return @theme.yellow("waiting#{": #{s.waiting_for}" if s.waiting_for}") if s.status == "waiting"
|
|
379
|
+
label = s.job_state&.in_flight_label
|
|
380
|
+
@theme.yellow(s.waiting_on_work? ? "idle" : "working") + (label ? @p.dim(" · #{label}") : "")
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def short_path(path)
|
|
384
|
+
return "" unless path
|
|
385
|
+
path.start_with?(@home) ? path.sub(@home, "~") : path
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# ----- peek ---------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def peek_pane(lines, title, subtitle, width, height)
|
|
391
|
+
bar = Text.pad(" " + (title || ""), width)
|
|
392
|
+
out = [@p.inverse(bar)]
|
|
393
|
+
out << Text.pad(" " + @p.dim(subtitle.to_s), width) if subtitle
|
|
394
|
+
body_h = height - out.size
|
|
395
|
+
wrapped = lines.flat_map { |l| Text.wrap(l, width - 1) }
|
|
396
|
+
wrapped.last(body_h).each { |l| out << Text.pad(" " + l, width) }
|
|
397
|
+
out
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# ----- modal --------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
# Centre a block of lines over the frame.
|
|
403
|
+
def overlay(lines, block, width)
|
|
404
|
+
block_w = block.map { |l| Text.width(l) }.max || 0
|
|
405
|
+
left = [(width - block_w) / 2, 0].max
|
|
406
|
+
top = [(lines.size - block.size) / 2, 0].max
|
|
407
|
+
out = lines.dup
|
|
408
|
+
block.each_with_index do |bl, i|
|
|
409
|
+
y = top + i
|
|
410
|
+
next if y >= out.size
|
|
411
|
+
base = Text.strip_ansi(out[y])
|
|
412
|
+
prefix = Text.pad(Text.take(base, left), left)
|
|
413
|
+
suffix = Text.drop(base, left + block_w)
|
|
414
|
+
out[y] = Text.pad(@p.dim(prefix) + Text.pad(bl, block_w) + @p.dim(suffix), width)
|
|
415
|
+
end
|
|
416
|
+
out
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Diffs successive frames and writes only changed rows. No erase-to-end-of-
|
|
421
|
+
# line after a row: in the terminal's last column the cursor stays put
|
|
422
|
+
# (pending wrap), so EL would eat the glyph just drawn.
|
|
423
|
+
class Painter
|
|
424
|
+
def initialize(out, cursor: TTY::Cursor)
|
|
425
|
+
@out = out
|
|
426
|
+
@cursor = cursor
|
|
427
|
+
@prev = []
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def paint(lines, force: false)
|
|
431
|
+
buf = +""
|
|
432
|
+
buf << @cursor.clear_screen if force
|
|
433
|
+
lines.each_with_index do |line, i|
|
|
434
|
+
next if !force && @prev[i] == line
|
|
435
|
+
buf << @cursor.move_to(0, i) << line
|
|
436
|
+
end
|
|
437
|
+
if @prev.size > lines.size
|
|
438
|
+
(lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
|
|
439
|
+
end
|
|
440
|
+
@out.print buf unless buf.empty?
|
|
441
|
+
@out.flush
|
|
442
|
+
@prev = lines
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def invalidate = @prev = []
|
|
446
|
+
end
|
|
447
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
# Interactive sessions report only `status`; folded into the background
|
|
5
|
+
# vocabulary, an idle terminal is a finished turn and a waiting one needs you.
|
|
6
|
+
INTERACTIVE_STATE = {"busy" => "working", "waiting" => "blocked", "idle" => "done"}.freeze
|
|
7
|
+
|
|
8
|
+
# One entry from `claude agents --json`. Immutable, and the enrichers hand
|
|
9
|
+
# back copies via `with`, so a list already given to another thread cannot
|
|
10
|
+
# move under it.
|
|
11
|
+
Session = Data.define(
|
|
12
|
+
:id, :cwd, :kind, :started_at, :session_id, :name,
|
|
13
|
+
:state, :pid, :status, :waiting_for, :origin, :prs, :job_state
|
|
14
|
+
) do
|
|
15
|
+
# Every member is optional so the parser and the specs name only what they
|
|
16
|
+
# have; prs is [] rather than nil so nobody asks whether PullRequests has run.
|
|
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)
|
|
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)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.from_hash(h)
|
|
24
|
+
new(
|
|
25
|
+
id: h["id"],
|
|
26
|
+
cwd: h["cwd"],
|
|
27
|
+
kind: h["kind"],
|
|
28
|
+
started_at: h["startedAt"] && Time.at(h["startedAt"] / 1000.0),
|
|
29
|
+
session_id: h["sessionId"],
|
|
30
|
+
name: h["name"],
|
|
31
|
+
state: h["state"],
|
|
32
|
+
pid: h["pid"],
|
|
33
|
+
status: h["status"],
|
|
34
|
+
waiting_for: h["waitingFor"]
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def background? = kind == "background"
|
|
39
|
+
|
|
40
|
+
def interactive? = kind == "interactive"
|
|
41
|
+
|
|
42
|
+
# Only background sessions carry an id, and every action needs one.
|
|
43
|
+
def actionable? = background? && !id.nil?
|
|
44
|
+
|
|
45
|
+
def effective_state = state || INTERACTIVE_STATE[status] || "done"
|
|
46
|
+
|
|
47
|
+
# The JSON does not say where an interactive session is driven from;
|
|
48
|
+
# AgentsClient reads it off the process tree: :terminal, :remote (a
|
|
49
|
+
# claude.ai/code worker), :subagent or :headless (`claude -p` or an SDK
|
|
50
|
+
# run); the last two are dropped before anyone downstream sees them.
|
|
51
|
+
def remote? = origin == :remote
|
|
52
|
+
|
|
53
|
+
def subagent? = origin == :subagent
|
|
54
|
+
|
|
55
|
+
def headless? = origin == :headless
|
|
56
|
+
|
|
57
|
+
# A remote worker is not unattended: a person drives it, just from claude.ai/code.
|
|
58
|
+
def unattended? = subagent? || headless?
|
|
59
|
+
|
|
60
|
+
def terminal? = interactive? && !remote? && !unattended?
|
|
61
|
+
|
|
62
|
+
# Selection handle: short id for background sessions, the UUID otherwise.
|
|
63
|
+
def key = id || session_id
|
|
64
|
+
|
|
65
|
+
# "working" from the daemon means either the agent is thinking or it has
|
|
66
|
+
# stopped and is waiting on work it started. JobState tells them apart.
|
|
67
|
+
def waiting_on_work? = effective_state == "working" && job_state&.waiting_on_work? == true
|
|
68
|
+
|
|
69
|
+
# From `/color`; interactive sessions have no job file, so never one.
|
|
70
|
+
def color = job_state&.color
|
|
71
|
+
|
|
72
|
+
# The session's own one-line account of where it is, the same line
|
|
73
|
+
# `claude agents` prints under a row: what it needs while blocked, what
|
|
74
|
+
# it produced once done, otherwise its status line. Nil without a job
|
|
75
|
+
# file, or before the session has said anything.
|
|
76
|
+
def summary
|
|
77
|
+
return nil unless job_state
|
|
78
|
+
line =
|
|
79
|
+
case effective_state
|
|
80
|
+
when "blocked" then job_state.needs || job_state.detail
|
|
81
|
+
when "done" then job_state.result || job_state.detail
|
|
82
|
+
else job_state.detail
|
|
83
|
+
end
|
|
84
|
+
line = line.to_s.gsub(/\s+/, " ").strip
|
|
85
|
+
line.empty? ? nil : line
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def needs_you? = %w[blocked failed].include?(effective_state)
|
|
89
|
+
|
|
90
|
+
def finished? = %w[done stopped].include?(effective_state)
|
|
91
|
+
|
|
92
|
+
def alive? = !pid.nil?
|
|
93
|
+
|
|
94
|
+
def display_name = name || id || session_id || "(unnamed)"
|
|
95
|
+
|
|
96
|
+
def project = cwd ? File.basename(cwd) : ""
|
|
97
|
+
|
|
98
|
+
def pr = prs.first
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "job_state"
|
|
4
|
+
require_relative "pull_requests"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# Puts the list together. JobState goes before PullRequests because the PR
|
|
8
|
+
# links it wants are read off the job file. Nothing here asks gh: that is
|
|
9
|
+
# `PullRequests#refresh`, which the poller runs once this list has gone up.
|
|
10
|
+
module Sessions
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def load(client:, jobs_dir:, pull_requests:, overrides:)
|
|
14
|
+
sessions = JobState.enrich(client.list, jobs_dir: jobs_dir)
|
|
15
|
+
pull_requests.enrich(sessions, overrides)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# What `claude` will use when a flag is left off: read from the same
|
|
7
|
+
# settings files it reads, most specific wins. Values are strings, or
|
|
8
|
+
# nil when nothing sets them — then the CLI decides at launch.
|
|
9
|
+
module Settings
|
|
10
|
+
Defaults = Struct.new(:model, :effort, :permission_mode)
|
|
11
|
+
|
|
12
|
+
def self.defaults(cwd, home: Dir.home)
|
|
13
|
+
layers = [
|
|
14
|
+
File.join(home, ".claude", "settings.json"),
|
|
15
|
+
File.join(cwd, ".claude", "settings.json"),
|
|
16
|
+
File.join(cwd, ".claude", "settings.local.json")
|
|
17
|
+
].map { |path| read(path) }
|
|
18
|
+
Defaults.new(
|
|
19
|
+
model: pick(layers, "model"),
|
|
20
|
+
effort: pick(layers, "effortLevel"),
|
|
21
|
+
permission_mode: pick(layers, "permissions", "defaultMode")
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.read(path)
|
|
26
|
+
JSON.parse(File.read(path))
|
|
27
|
+
rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
|
|
28
|
+
{}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.pick(layers, *keys)
|
|
32
|
+
layers.reverse_each do |h|
|
|
33
|
+
v = h.dig(*keys)
|
|
34
|
+
return v.to_s if v.is_a?(String) || v.is_a?(Numeric)
|
|
35
|
+
end
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|