claude-inbox 0.13.1 → 0.14.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 +1 -7
- data/lib/claude_inbox/app.rb +19 -7
- data/lib/claude_inbox/dialog.rb +23 -6
- data/lib/claude_inbox/mouse.rb +12 -11
- data/lib/claude_inbox/new_session_form.rb +1 -1
- data/lib/claude_inbox/pull_requests.rb +1 -1
- data/lib/claude_inbox/renderer.rb +9 -3
- data/lib/claude_inbox/terminal.rb +18 -5
- data/lib/claude_inbox/text.rb +32 -3
- data/lib/claude_inbox/theme.rb +10 -4
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +38 -9
- data/mod/tests/pane.test.tsx +27 -2
- 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: 6f7809d0909d4391440edb2d01d0452b976b28ed59832d21a05518a11ef164b1
|
|
4
|
+
data.tar.gz: 106434e33d35e297d312b441bfa1ff333a16a7faddd54443c65ba006a6dcf95e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf8d43ffd7d434977f4bcb18acb1d06817854e92973cad5e5e8d7d8efd8e611e82dc1820499f65c50c99f0b12a5b6d3d7d3a18235c1ad693c0efa2325be348a6
|
|
7
|
+
data.tar.gz: c0560e7bd6444fbf325618d8b2eb7172d74b0d63d331058cf9aca5afbcf88da1f0fd5a4d31c1576e808a4b022cf3933247bc61c5ba229f4543b171805e2aa3df
|
|
@@ -7,12 +7,6 @@ module ClaudeInbox
|
|
|
7
7
|
module Navigating
|
|
8
8
|
private
|
|
9
9
|
|
|
10
|
-
# Remembers where the frame put the rows, so a click can find one.
|
|
11
|
-
def placed(frame)
|
|
12
|
-
@row_items = frame.items
|
|
13
|
-
@top = frame.top
|
|
14
|
-
end
|
|
15
|
-
|
|
16
10
|
# The row to land on once a poll lists it, as after a spawn.
|
|
17
11
|
def select_later(id)
|
|
18
12
|
@pending_select = id
|
|
@@ -100,7 +94,7 @@ module ClaudeInbox
|
|
|
100
94
|
# no item of its own; a click there resolves to the row above it.
|
|
101
95
|
def row_item_at(row)
|
|
102
96
|
idx = row - 1
|
|
103
|
-
@
|
|
97
|
+
@frame.items[idx] || (@frame.items[idx - 1] if idx > 0 && @frame.items[idx - 1]&.row?)
|
|
104
98
|
end
|
|
105
99
|
end
|
|
106
100
|
end
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -71,8 +71,8 @@ module ClaudeInbox
|
|
|
71
71
|
reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
|
|
72
72
|
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
|
|
73
73
|
@selected = nil
|
|
74
|
-
@
|
|
75
|
-
@
|
|
74
|
+
@frame = Renderer::Frame.new([], [], 0)
|
|
75
|
+
@pointer_at = nil
|
|
76
76
|
@expanded = Hash.new(false)
|
|
77
77
|
@keymap = Keymap.new
|
|
78
78
|
@tick = 0
|
|
@@ -190,6 +190,9 @@ module ClaudeInbox
|
|
|
190
190
|
# the normal action table (see handle_key); mouse input defers to the
|
|
191
191
|
# same rule rather than reaching past whatever has focus.
|
|
192
192
|
def handle_mouse(event)
|
|
193
|
+
@pointer_at = [event.row, event.col]
|
|
194
|
+
link = link_under_pointer
|
|
195
|
+
return click_link(link) if event.kind == :click && link
|
|
193
196
|
return if @modal || filter_editing?
|
|
194
197
|
case event.kind
|
|
195
198
|
when :click then click_row(event.row)
|
|
@@ -198,6 +201,15 @@ module ClaudeInbox
|
|
|
198
201
|
end
|
|
199
202
|
end
|
|
200
203
|
|
|
204
|
+
def click_link(link)
|
|
205
|
+
return open_in_browser(link.url, link.text) unless @modal
|
|
206
|
+
pr_picked if @modal.pick_by_url(link.url)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def link_under_pointer
|
|
210
|
+
@frame.link_at(*@pointer_at) if @pointer_at && !filter_editing?
|
|
211
|
+
end
|
|
212
|
+
|
|
201
213
|
def handle_modal_key(name, key)
|
|
202
214
|
return handle_form_key(name, key) if @modal.is_a?(NewSessionForm)
|
|
203
215
|
case @modal.press(name, key)
|
|
@@ -274,15 +286,15 @@ module ClaudeInbox
|
|
|
274
286
|
ensure_selection(sections)
|
|
275
287
|
@tick += 1
|
|
276
288
|
view = Renderer::View.new(
|
|
277
|
-
width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
|
|
289
|
+
width: width, height: height, now: now, selected: @selected&.key, top: @frame.top, expanded: @expanded,
|
|
278
290
|
modal: modal_lines(width), screen: screen_lines(width, body_h),
|
|
279
291
|
status: status_text(now), usage: @rate_limits.windows(now), filter: filter, filter_editing: filter_editing?,
|
|
280
292
|
tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
|
|
281
293
|
)
|
|
282
|
-
frame = @renderer.frame(sections, view)
|
|
283
|
-
frame = frame.plain unless @color # --no-color: decided here and nowhere else
|
|
284
|
-
|
|
285
|
-
@terminal.
|
|
294
|
+
@frame = @renderer.frame(sections, view)
|
|
295
|
+
@frame = @frame.plain unless @color # --no-color: decided here and nowhere else
|
|
296
|
+
@terminal.paint(@frame.lines)
|
|
297
|
+
@terminal.pointer(!link_under_pointer.nil?)
|
|
286
298
|
dt = monotonic - t0
|
|
287
299
|
Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
|
|
288
300
|
end
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -33,6 +33,11 @@ module ClaudeInbox
|
|
|
33
33
|
# How wide the box wants to be; the frame caps it at the terminal.
|
|
34
34
|
def fit = self.class::WIDTH
|
|
35
35
|
|
|
36
|
+
def bordered(rows, inner)
|
|
37
|
+
top = Text.truncate(title, inner)
|
|
38
|
+
["┌#{top}#{"─" * (inner - Text.width(top))}┐", *rows.map { |row| "│#{row}│" }, "└#{"─" * inner}┘"]
|
|
39
|
+
end
|
|
40
|
+
|
|
36
41
|
def key(name) = @theme.cyan_bold(name)
|
|
37
42
|
|
|
38
43
|
# A key and what it does.
|
|
@@ -83,6 +88,18 @@ module ClaudeInbox
|
|
|
83
88
|
|
|
84
89
|
def title = " #{kind.to_s.capitalize} pull request "
|
|
85
90
|
|
|
91
|
+
# Drawn by hand: TTY::Box counts a link's hidden characters toward the
|
|
92
|
+
# line's width, so it would clip every line that has a link.
|
|
93
|
+
def frame(width, _caret = nil)
|
|
94
|
+
inner = [width - 4, fit].min - 2
|
|
95
|
+
bordered(lines(inner - 2, nil).map { |l| " #{Text.pad(l, inner - 2)} " }, inner)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def pick_by_url(url)
|
|
99
|
+
@choice = @prs.find { |pr| pr.url == url }
|
|
100
|
+
@choice && :pick
|
|
101
|
+
end
|
|
102
|
+
|
|
86
103
|
def lines(width, _caret)
|
|
87
104
|
(@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
|
|
88
105
|
["", " #{hint(KEYS.fetch(kind), "#{kind} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
|
|
@@ -127,13 +144,13 @@ module ClaudeInbox
|
|
|
127
144
|
|
|
128
145
|
def lines(width, _caret)
|
|
129
146
|
case kind
|
|
130
|
-
when :stop then [ask("Stop", width),
|
|
147
|
+
when :stop then [ask("Stop", width), *answers("stop it", "cancel")]
|
|
131
148
|
when :delete
|
|
132
149
|
[ask("Delete", width), " Its worktree and conversation", " go with it.",
|
|
133
|
-
|
|
150
|
+
*answers("delete it", "keep it")]
|
|
134
151
|
when :adopt
|
|
135
152
|
[" Pull this session into the daemon?", " Its phone session ends and a",
|
|
136
|
-
" new one takes its place.",
|
|
153
|
+
" new one takes its place.", *answers("adopt it", "cancel")]
|
|
137
154
|
end
|
|
138
155
|
end
|
|
139
156
|
|
|
@@ -147,6 +164,8 @@ module ClaudeInbox
|
|
|
147
164
|
|
|
148
165
|
# One line, so a long name is cut rather than wrapped mid-word.
|
|
149
166
|
def ask(verb, width) = " #{verb} #{Text.truncate(@label.to_s, width - Text.width(verb) - 4)}?"
|
|
167
|
+
|
|
168
|
+
def answers(yes, no) = ["", " " + hint("y", yes), " " + hint("esc", no)]
|
|
150
169
|
end
|
|
151
170
|
|
|
152
171
|
# The first launch without the Claude Code pane asks once whether to add
|
|
@@ -170,9 +189,7 @@ module ClaudeInbox
|
|
|
170
189
|
# pushes the right border out on every line that has one.
|
|
171
190
|
def frame(width, _caret = nil)
|
|
172
191
|
inner = [width - 4, WIDTH].min - 2
|
|
173
|
-
|
|
174
|
-
*lines(inner, nil).map { |l| "│#{Text.pad(Text.truncate(l, inner), inner)}│" },
|
|
175
|
-
"└#{"─" * inner}┘"]
|
|
192
|
+
bordered(lines(inner, nil).map { |l| Text.pad(Text.truncate(l, inner), inner) }, inner)
|
|
176
193
|
end
|
|
177
194
|
|
|
178
195
|
def lines(_width, _caret)
|
data/lib/claude_inbox/mouse.rb
CHANGED
|
@@ -2,31 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
module ClaudeInbox
|
|
4
4
|
# Parses SGR mouse reports ("\e[<Cb;Cx;Cy(M|m)", emitted once App turns on
|
|
5
|
-
# \e[?
|
|
6
|
-
# IO. Row
|
|
7
|
-
# into what they just painted.
|
|
5
|
+
# \e[?1003h\e[?1006h) into clicks, pointer moves and wheel ticks. Pure: no
|
|
6
|
+
# terminal, no IO. Row and column are the terminal's own 1-based ones, so
|
|
7
|
+
# callers can index straight into what they just painted.
|
|
8
8
|
module Mouse
|
|
9
9
|
SEQUENCE = /\e\[<(\d+);(\d+);(\d+)([Mm])/
|
|
10
10
|
|
|
11
|
-
Event = Struct.new(:kind, :row)
|
|
11
|
+
Event = Struct.new(:kind, :row, :col)
|
|
12
12
|
|
|
13
13
|
# A raw keypress can glue several reports together the same way a fast
|
|
14
14
|
# "esc gg" glues onto one read (see Input#split_keys), so this scans
|
|
15
15
|
# rather than matching once.
|
|
16
16
|
def self.events(raw)
|
|
17
|
-
raw.scan(SEQUENCE).filter_map do |cb,
|
|
17
|
+
raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
|
|
18
18
|
kind = kind_for(cb.to_i, type)
|
|
19
|
-
Event.new(kind, row.to_i) if kind
|
|
19
|
+
Event.new(kind, row.to_i, col.to_i) if kind
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
# Bit 6 (0x40) marks a wheel tick, direction in bit 0. Otherwise
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# for the terminal's own handling.
|
|
23
|
+
# Bit 6 (0x40) marks a wheel tick, direction in bit 0. Otherwise bits
|
|
24
|
+
# 0-1 give the button (0 = left, 3 = none) and bit 5 (0x20) marks motion,
|
|
25
|
+
# so 0x23 masks both at once. Only a left click, released of any drag,
|
|
26
|
+
# counts, and motion with no button held is the pointer moving; middle
|
|
27
|
+
# and right buttons and drags are left for the terminal's own handling.
|
|
28
28
|
def self.kind_for(cb, type)
|
|
29
29
|
return (cb.even? ? :scroll_up : :scroll_down) if cb & 0x40 != 0
|
|
30
|
+
return :move if cb & 0x23 == 0x23
|
|
30
31
|
:click if type == "M" && cb & 0x23 == 0
|
|
31
32
|
end
|
|
32
33
|
private_class_method :kind_for
|
|
@@ -333,7 +333,7 @@ module ClaudeInbox
|
|
|
333
333
|
|
|
334
334
|
def prompt_box(f, w, h)
|
|
335
335
|
on = f.equal?(focused)
|
|
336
|
-
edge =
|
|
336
|
+
edge = ->(s) { @theme.tint((:cyan if on), s) }
|
|
337
337
|
rows, hidden = f.value.view(w - 4, h, cursor: (caret if on), chip: ->(s) { @theme.cyan_bold(s) })
|
|
338
338
|
rows = [@p.dim("What should this session do?")] if f.value.empty? && !on
|
|
339
339
|
rows += [""] * (h - rows.size)
|
|
@@ -169,7 +169,7 @@ module ClaudeInbox
|
|
|
169
169
|
end
|
|
170
170
|
|
|
171
171
|
def seed(url)
|
|
172
|
-
number = url[%r{/pull/(\d+)}, 1]&.to_i
|
|
172
|
+
number = url[%r{/(?:pull|-/merge_requests)/(\d+)}, 1]&.to_i
|
|
173
173
|
cached = resolved[url] || claude_cache[url]
|
|
174
174
|
PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
|
|
175
175
|
resolved_at: cached&.dig("resolved_at"), title: cached&.dig("title"))
|
|
@@ -12,7 +12,12 @@ module ClaudeInbox
|
|
|
12
12
|
class Renderer
|
|
13
13
|
Frame = Struct.new(:lines, :items, :top) do
|
|
14
14
|
# The same frame for a terminal drawn without color.
|
|
15
|
-
def plain = self.class.new(lines.map { |l|
|
|
15
|
+
def plain = self.class.new(lines.map { |l| l.gsub(Text::CSI, "") }, items, top)
|
|
16
|
+
|
|
17
|
+
def link_at(row, col)
|
|
18
|
+
@links ||= lines.map { |l| Text.links(l) }
|
|
19
|
+
@links.fetch(row - 1, []).find { |link| link.columns.cover?(col - 1) }
|
|
20
|
+
end
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
CHROME_ROWS = 2 # header + footer
|
|
@@ -396,7 +401,7 @@ module ClaudeInbox
|
|
|
396
401
|
def pr_badge(s, section)
|
|
397
402
|
pr = s.pr
|
|
398
403
|
return nil unless pr
|
|
399
|
-
badge = (section == :settled)
|
|
404
|
+
badge = @theme.pr_badge(pr, quiet: section == :settled)
|
|
400
405
|
more = (s.prs.size > 1) ? @p.dim(" · +#{s.prs.size - 1}") : ""
|
|
401
406
|
badge + more
|
|
402
407
|
end
|
|
@@ -434,7 +439,8 @@ module ClaudeInbox
|
|
|
434
439
|
block_w = block.map { |l| Text.width(l) }.max || 0
|
|
435
440
|
left = [(width - block_w) / 2, 0].max
|
|
436
441
|
top = [(lines.size - block.size) / 2, 0].max
|
|
437
|
-
|
|
442
|
+
# Nothing behind a pop-up is clickable.
|
|
443
|
+
out = lines.map { |l| l.gsub(Text::LINK, "") }
|
|
438
444
|
block.each_with_index do |bl, i|
|
|
439
445
|
y = top + i
|
|
440
446
|
next if y >= out.size
|
|
@@ -17,10 +17,14 @@ module ClaudeInbox
|
|
|
17
17
|
# know the mode ignore it and keep scrolling their own history.
|
|
18
18
|
WHEEL_KEYS_ON = "\e[?1007h"
|
|
19
19
|
WHEEL_KEYS_OFF = "\e[?1007l"
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
# Any-motion events in the SGR encoding, which Mouse parses: the pointer
|
|
21
|
+
# moving too, so a link can take a hand pointer. A terminal that knows
|
|
22
|
+
# neither mode falls back to WHEEL_KEYS_ON, or its own scrollback.
|
|
23
|
+
MOUSE_ON = "\e[?1003h\e[?1006h"
|
|
24
|
+
MOUSE_OFF = "\e[?1006l\e[?1003l"
|
|
25
|
+
# OSC 22 sets the pointer's shape. Terminals without it ignore it.
|
|
26
|
+
POINTER = "\e]22;pointer\e\\"
|
|
27
|
+
POINTER_OFF = "\e]22;default\e\\"
|
|
24
28
|
# Bracketed paste: what is pasted arrives fenced off from what is typed
|
|
25
29
|
# (Paste), and a pasted image, which has no text, arrives as an empty
|
|
26
30
|
# fence rather than not at all.
|
|
@@ -33,6 +37,7 @@ module ClaudeInbox
|
|
|
33
37
|
@painter = Painter.new(out)
|
|
34
38
|
@restored = true
|
|
35
39
|
@size = nil
|
|
40
|
+
@over_link = false
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
# The tty's mode is kept as found, not assumed: `cooked!` sets a stock
|
|
@@ -53,8 +58,9 @@ module ClaudeInbox
|
|
|
53
58
|
def restore
|
|
54
59
|
return if @restored
|
|
55
60
|
@restored = true
|
|
61
|
+
@over_link = false
|
|
56
62
|
@input.console_mode = @mode if @mode && @input.tty?
|
|
57
|
-
@out.print TTY::Cursor.show, PASTE_OFF, MOUSE_OFF, WHEEL_KEYS_OFF, ALT_OFF
|
|
63
|
+
@out.print TTY::Cursor.show, POINTER_OFF, PASTE_OFF, MOUSE_OFF, WHEEL_KEYS_OFF, ALT_OFF
|
|
58
64
|
@out.flush
|
|
59
65
|
rescue
|
|
60
66
|
nil
|
|
@@ -93,6 +99,13 @@ module ClaudeInbox
|
|
|
93
99
|
|
|
94
100
|
def paint(lines) = @painter.paint(lines)
|
|
95
101
|
|
|
102
|
+
def pointer(over_link)
|
|
103
|
+
return if over_link == @over_link
|
|
104
|
+
@over_link = over_link
|
|
105
|
+
@out.print(over_link ? POINTER : POINTER_OFF)
|
|
106
|
+
@out.flush
|
|
107
|
+
end
|
|
108
|
+
|
|
96
109
|
private
|
|
97
110
|
|
|
98
111
|
def measure
|
data/lib/claude_inbox/text.rb
CHANGED
|
@@ -6,15 +6,42 @@ module ClaudeInbox
|
|
|
6
6
|
# Width-aware string helpers. Never use String#[] on user-facing text:
|
|
7
7
|
# session names carry emoji and glyphs that break byte/char slicing.
|
|
8
8
|
module Text
|
|
9
|
-
|
|
9
|
+
# An OSC 8 hyperlink's opening or, with an empty url, its closing.
|
|
10
|
+
LINK = /\e\]8;[^;\e]*;[^\e]*\e\\/
|
|
11
|
+
CSI = /\e\[[0-9;?]*[A-Za-z]/
|
|
12
|
+
ANSI = /#{CSI}|#{LINK}/
|
|
10
13
|
# A grapheme, or an escape that takes no column.
|
|
11
14
|
GLYPH = /#{ANSI}|\X/
|
|
12
15
|
ELLIPSIS = "…"
|
|
16
|
+
LINK_END = "\e]8;;\e\\"
|
|
13
17
|
|
|
14
18
|
module_function
|
|
15
19
|
|
|
16
20
|
def strip_ansi(s) = s.gsub(ANSI, "")
|
|
17
21
|
|
|
22
|
+
# The url comes from a transcript, so a control character in it is dropped.
|
|
23
|
+
def link(s, url) = "\e]8;;#{url.delete("\x00-\x1f\x7f")}\e\\#{s}#{LINK_END}"
|
|
24
|
+
|
|
25
|
+
Link = Data.define(:columns, :url, :text)
|
|
26
|
+
|
|
27
|
+
def links(s)
|
|
28
|
+
found = []
|
|
29
|
+
col = 0
|
|
30
|
+
start = url = text = nil
|
|
31
|
+
s.scan(GLYPH) do |g|
|
|
32
|
+
if g.start_with?("\e]8;")
|
|
33
|
+
found << Link.new(start...col, url, text) if url
|
|
34
|
+
url = g[/\A\e\]8;[^;\e]*;([^\e]+)/, 1]
|
|
35
|
+
start = col
|
|
36
|
+
text = +""
|
|
37
|
+
elsif !g.start_with?("\e")
|
|
38
|
+
text << g if url
|
|
39
|
+
col += Unicode::DisplayWidth.of(g)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
found
|
|
43
|
+
end
|
|
44
|
+
|
|
18
45
|
# Bytes that aren't UTF-8 become U+FFFD.
|
|
19
46
|
def utf8(s) = String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
|
|
20
47
|
|
|
@@ -32,8 +59,8 @@ module ClaudeInbox
|
|
|
32
59
|
take(s, w - width(ELLIPSIS)) << ELLIPSIS
|
|
33
60
|
end
|
|
34
61
|
|
|
35
|
-
# First `n` columns, no ellipsis. Colors survive the cut, and
|
|
36
|
-
# closes whichever one
|
|
62
|
+
# First `n` columns, no ellipsis. Colors and links survive the cut, and
|
|
63
|
+
# the cut closes whichever one it left open.
|
|
37
64
|
def take(s, n)
|
|
38
65
|
out = +""
|
|
39
66
|
used = 0
|
|
@@ -43,6 +70,8 @@ module ClaudeInbox
|
|
|
43
70
|
out << g
|
|
44
71
|
used += gw
|
|
45
72
|
end
|
|
73
|
+
last_link = out.scan(LINK).last
|
|
74
|
+
out << LINK_END if last_link && last_link != LINK_END
|
|
46
75
|
(out.include?("\e") && !out.end_with?("\e[0m")) ? out << "\e[0m" : out
|
|
47
76
|
end
|
|
48
77
|
|
data/lib/claude_inbox/theme.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "text"
|
|
4
|
+
|
|
3
5
|
module ClaudeInbox
|
|
4
6
|
# Chrome hues tuned to Atom One Dark rather than the terminal profile's
|
|
5
7
|
# own ANSI reds and greens. 256-color indices, since those render the
|
|
@@ -28,12 +30,16 @@ module ClaudeInbox
|
|
|
28
30
|
|
|
29
31
|
# "#885 open", or "#885 open · ✗ checks" when something is wrong.
|
|
30
32
|
# `name` replaces the bare number where that would be ambiguous.
|
|
31
|
-
def pr_badge(pr, name = pr.short)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
def pr_badge(pr, name = pr.short, quiet: false)
|
|
34
|
+
hue = PR_HUES[pr.state] unless quiet
|
|
35
|
+
linked = Text.link(underline(tint(hue, name)), pr.url)
|
|
36
|
+
return linked unless PR_HUES.key?(pr.state)
|
|
37
|
+
badge = linked + tint(hue, " #{pr.state.downcase}")
|
|
38
|
+
(pr.trouble && !quiet) ? badge + dim(" · ") + tint(PR_TROUBLE_HUES[pr.health], pr.trouble) : badge
|
|
35
39
|
end
|
|
36
40
|
|
|
41
|
+
def underline(text) = "\e[4m#{text}\e[24m"
|
|
42
|
+
|
|
37
43
|
# A filled pill: editor-background text on the hue, for a selected row
|
|
38
44
|
# or choice — the inverse of `color`, which puts the hue on the text.
|
|
39
45
|
def pill(text, hue) = "\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
|
data/lib/claude_inbox.rb
CHANGED
data/mod/hooks/register.tsx
CHANGED
|
@@ -95,6 +95,11 @@ function itemKey(item: Item): string {
|
|
|
95
95
|
return item.kind === 'fold' ? `fold:${item.section}` : `row:${item.row.id ?? item.row.label}`
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// The Button drawn for a row's pull request, a click on which opens it.
|
|
99
|
+
function prKey(item: Item): string | undefined {
|
|
100
|
+
return item.kind === 'row' && item.row.pr ? `pr:${itemKey(item)}` : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
98
103
|
// The visible box drawn for an item, what the pane scrolls to.
|
|
99
104
|
function viewKey(item: Item): string {
|
|
100
105
|
return `view:${itemKey(item)}`
|
|
@@ -313,14 +318,18 @@ export const register: Register = (on, options) => {
|
|
|
313
318
|
// the cursor's item, the arrows move it to the next, and the cursor follows.
|
|
314
319
|
on('ui.focus', { requestId: PANE }, async ($, e, next) => {
|
|
315
320
|
const flat = await listed($)
|
|
321
|
+
const was = onList(flat, await read($, cursor))
|
|
316
322
|
if (e.element === undefined) {
|
|
317
|
-
const item = flat[
|
|
323
|
+
const item = flat[was]
|
|
318
324
|
return item ? next({ ...e, element: itemKey(item) }) : next(e)
|
|
319
325
|
}
|
|
320
|
-
|
|
326
|
+
// A PR chip's Button sits in the ring between its row and the next: the ring arrives on it going down
|
|
327
|
+
// from its own row, so it passes on to the next; going up, or by a click, it lands on its own row.
|
|
328
|
+
const chipOf = flat.findIndex(item => prKey(item) === e.element)
|
|
329
|
+
const at = chipOf < 0 ? flat.findIndex(item => itemKey(item) === e.element) : chipOf === was ? Math.min(chipOf + 1, flat.length - 1) : chipOf
|
|
321
330
|
if (at < 0) return {}
|
|
322
331
|
await update($, cursor, () => at)
|
|
323
|
-
return next(e)
|
|
332
|
+
return next({ ...e, element: itemKey(flat[at]!) })
|
|
324
333
|
})
|
|
325
334
|
|
|
326
335
|
// An arrow in a focused pane scrolls its body a row while the tree is taller than the
|
|
@@ -384,6 +393,12 @@ export const register: Register = (on, options) => {
|
|
|
384
393
|
if (!item.row.id || !item.row.actionable) return $.ui.toast('this session cannot be attached')
|
|
385
394
|
void requestSwitch($, host, item.row.id)
|
|
386
395
|
}
|
|
396
|
+
// The click's focus may have walked the ring past the row, so the cursor is put back on it.
|
|
397
|
+
const openPr = (item: Item) => () => {
|
|
398
|
+
if (item.kind !== 'row' || !item.row.pr) return
|
|
399
|
+
const url = item.row.pr.url
|
|
400
|
+
void moveTo($, flat, flat.indexOf(item)).then(() => openInBrowser($, url))
|
|
401
|
+
}
|
|
387
402
|
|
|
388
403
|
// Only the keys a chord allows are mounted while it waits, so any other key is a no-op.
|
|
389
404
|
const keys =
|
|
@@ -475,10 +490,13 @@ export const register: Register = (on, options) => {
|
|
|
475
490
|
return (
|
|
476
491
|
<Box key={viewKey(item)} flexDirection="column">
|
|
477
492
|
<Box>
|
|
478
|
-
|
|
479
|
-
<
|
|
480
|
-
{
|
|
481
|
-
|
|
493
|
+
{/* Only the label gives way to a long row: a squeezed glyph wraps under itself. */}
|
|
494
|
+
<Box flexShrink={0}>
|
|
495
|
+
<Text color={isHere ? 'yellow' : 'blue'}>{isHere ? '› ' : dot ? '● ' : ' '}</Text>
|
|
496
|
+
<Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
|
|
497
|
+
{GLYPH[one.state] ?? '·'}{' '}
|
|
498
|
+
</Text>
|
|
499
|
+
</Box>
|
|
482
500
|
{/* A Button cannot truncate, so its Box clips a long label to the row. A Button takes no
|
|
483
501
|
color either, so a session's /color is the Box's background rather than the label's ink. */}
|
|
484
502
|
<Box key={`label:${itemKey(item)}`} height={1} flexShrink={1} overflow="hidden" backgroundColor={tint}>
|
|
@@ -494,12 +512,23 @@ export const register: Register = (on, options) => {
|
|
|
494
512
|
{one.isUnread && !isQuiet ? <Text bold>{text}</Text> : text}
|
|
495
513
|
</Button>
|
|
496
514
|
</Box>
|
|
497
|
-
{/*
|
|
515
|
+
{/* Takes what a short label leaves, so the PR and age sit at the right edge on every row;
|
|
516
|
+
the label's own box would stretch a /color tint across it. */}
|
|
517
|
+
<Box flexGrow={1} />
|
|
518
|
+
{/* The label gives up its cells to the PR chip, which would otherwise wrap under it. The chip is
|
|
519
|
+
a Button rather than a Link: a terminal without OSC 8 cannot click a Link, and prints its URL. */}
|
|
498
520
|
{one.pr ? (
|
|
499
521
|
<Box flexShrink={0}>
|
|
522
|
+
<Box marginLeft={1}>
|
|
523
|
+
<Button key={prKey(item)} plain label={one.pr.short} onPress={openPr(item)}>
|
|
524
|
+
<Text underline color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
|
|
525
|
+
{one.pr.short}
|
|
526
|
+
</Text>
|
|
527
|
+
</Button>
|
|
528
|
+
</Box>
|
|
500
529
|
<Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
|
|
501
530
|
{' '}
|
|
502
|
-
{one.pr.
|
|
531
|
+
{one.pr.state}
|
|
503
532
|
</Text>
|
|
504
533
|
</Box>
|
|
505
534
|
) : null}
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -129,7 +129,8 @@ test("rows sit under the gem's sections, Settled folded until za opens it", asyn
|
|
|
129
129
|
expect((await ui.findAll({ type: 'Text', text: '● ' })).filter(dot => dot.props.color === 'blue')).toHaveLength(1)
|
|
130
130
|
expect((await ui.find({ type: 'Text', text: 'finished unseen' }))?.props.bold).toBe(true)
|
|
131
131
|
expect(await ui.find({ type: 'Text', text: 'still going' })).toBeUndefined()
|
|
132
|
-
expect(await ui.find({ type: '
|
|
132
|
+
expect((await ui.find({ type: 'Button', key: 'pr:row:b2' }))?.props.label).toBe('#7')
|
|
133
|
+
expect((await ui.find({ type: 'Text', text: '#7' }))?.props.underline).toBe(true)
|
|
133
134
|
expect(await ui.find({ text: /1 folded/ })).toBeDefined()
|
|
134
135
|
expect(await ui.find({ text: /put away/ })).toBeUndefined()
|
|
135
136
|
await ui.press({ key: 'g' })
|
|
@@ -249,7 +250,7 @@ test('a switch hands the keys back to the prompt before it asks, so a return lan
|
|
|
249
250
|
await ui.unmount()
|
|
250
251
|
})
|
|
251
252
|
|
|
252
|
-
test('o and
|
|
253
|
+
test('o, w and a click on the PR hand the keys back to the prompt before they open the browser', async ($, on) => {
|
|
253
254
|
mock.env(on, { HOME: '/Users/me' })
|
|
254
255
|
mock.clock(on, { now: 1_700_000_000_000 })
|
|
255
256
|
const remote = { ...ROWS[1], remote: 'https://claude.ai/code/session_b2' }
|
|
@@ -270,6 +271,7 @@ test('o and w hand the keys back to the prompt before they open the browser', as
|
|
|
270
271
|
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
271
272
|
await ui.press({ key: 'o' })
|
|
272
273
|
await ui.press({ key: 'w' })
|
|
274
|
+
await ui.press({ key: 'pr:row:b2' })
|
|
273
275
|
expect(calls).toEqual([
|
|
274
276
|
'close inbox',
|
|
275
277
|
'open inbox',
|
|
@@ -277,6 +279,9 @@ test('o and w hand the keys back to the prompt before they open the browser', as
|
|
|
277
279
|
'close inbox',
|
|
278
280
|
'open inbox',
|
|
279
281
|
'open https://claude.ai/code/session_b2',
|
|
282
|
+
'close inbox',
|
|
283
|
+
'open inbox',
|
|
284
|
+
'open https://github.com/o/r/pull/7',
|
|
280
285
|
])
|
|
281
286
|
await ui.unmount()
|
|
282
287
|
})
|
|
@@ -359,3 +364,23 @@ test('with openOnStart off only /inbox opens it', { options: { openOnStart: fals
|
|
|
359
364
|
await polled
|
|
360
365
|
expect(opened).toEqual([])
|
|
361
366
|
})
|
|
367
|
+
|
|
368
|
+
test("the ring steps over a row's PR chip: down to the next row, up onto its own", async ($, on) => {
|
|
369
|
+
mock.env(on, { HOME: '/Users/me' })
|
|
370
|
+
mock.clock(on, { now: 1_700_000_000_000 })
|
|
371
|
+
on('state.get', ($, e, next) => (e.key === 'rows' ? seeded(ROWS) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
|
|
372
|
+
const landed: (string | undefined)[] = []
|
|
373
|
+
on('ui.focus', ($, e) => {
|
|
374
|
+
landed.push(e.element)
|
|
375
|
+
return {}
|
|
376
|
+
})
|
|
377
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
378
|
+
// The harness has no ring to walk, so the test raises ui.focus as the engine does for an arrow.
|
|
379
|
+
const arrowOnto = (element: string) =>
|
|
380
|
+
$.ui.focus({ component: 'Pane', requestId: 'inbox', plugin: 'inbox-pane', element, origin: { kind: 'person' } } as never)
|
|
381
|
+
await arrowOnto('row:b2')
|
|
382
|
+
await arrowOnto('pr:row:b2')
|
|
383
|
+
await arrowOnto('pr:row:b2')
|
|
384
|
+
expect(landed).toEqual(['row:b2', 'row:uuid-term', 'row:b2'])
|
|
385
|
+
await ui.unmount()
|
|
386
|
+
})
|