claude-inbox 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/claude-inbox +4 -0
- data/lib/claude_inbox/app/navigating.rb +1 -7
- data/lib/claude_inbox/app.rb +25 -11
- data/lib/claude_inbox/dialog.rb +22 -7
- data/lib/claude_inbox/headless.rb +4 -1
- data/lib/claude_inbox/mouse.rb +12 -11
- data/lib/claude_inbox/new_session_form.rb +9 -16
- data/lib/claude_inbox/pull_requests.rb +1 -1
- data/lib/claude_inbox/renderer.rb +15 -11
- data/lib/claude_inbox/slash_commands.rb +3 -10
- data/lib/claude_inbox/store/row.rb +4 -4
- data/lib/claude_inbox/subprocess.rb +2 -4
- data/lib/claude_inbox/terminal.rb +21 -13
- data/lib/claude_inbox/text.rb +32 -3
- data/lib/claude_inbox/text_buffer.rb +4 -2
- data/lib/claude_inbox/theme.rb +10 -4
- data/lib/claude_inbox/updater.rb +5 -3
- data/lib/claude_inbox/vt_screen.rb +2 -1
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +9 -2
- data/mod/tests/pane.test.tsx +2 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c346a215bf94ae3088c6ce5ddff23a2852e0294b77bbaa92a980d6baf4315cb7
|
|
4
|
+
data.tar.gz: 92e53f999cc3731ab9bf96c20e96048cd47a2261cb9640ed26bdafdc5032bbd6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb9dc45979798d3e8c73534f06c1e6dc73a6bdfb5019c6df3f87ae7e3e3adb6cf0d67f370e61f29729f3a10a07a5728a58507dd1f961929843c2798e52f5cd18
|
|
7
|
+
data.tar.gz: 59558583519adb57a59ab15c9bad0d78bff87b8db105210c8bdd63370eaa40d3105a68bed312ec0972bfd6fe91bca53866c39decd734db64b09059d8071d4860
|
data/exe/claude-inbox
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
# Claude's files are UTF-8 whatever the locale says; under LANG=C a read of
|
|
5
|
+
# ~/.claude.json would otherwise come back US-ASCII and fail to parse.
|
|
6
|
+
Encoding.default_external = Encoding::UTF_8
|
|
7
|
+
|
|
4
8
|
require "claude_inbox"
|
|
5
9
|
require "claude_inbox/cli"
|
|
6
10
|
|
|
@@ -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
|
|
@@ -87,7 +87,7 @@ module ClaudeInbox
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def run
|
|
90
|
-
@booted_at =
|
|
90
|
+
@booted_at = monotonic
|
|
91
91
|
install_traps
|
|
92
92
|
@terminal.enter
|
|
93
93
|
@poller.start
|
|
@@ -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)
|
|
@@ -264,7 +276,7 @@ module ClaudeInbox
|
|
|
264
276
|
end
|
|
265
277
|
|
|
266
278
|
def render
|
|
267
|
-
t0 =
|
|
279
|
+
t0 = monotonic
|
|
268
280
|
now = Time.now
|
|
269
281
|
all = @store.sections(now)
|
|
270
282
|
@snapshot.write(all, now)
|
|
@@ -274,16 +286,16 @@ 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.
|
|
286
|
-
dt =
|
|
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?)
|
|
298
|
+
dt = monotonic - t0
|
|
287
299
|
Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
|
|
288
300
|
end
|
|
289
301
|
|
|
@@ -309,11 +321,13 @@ module ClaudeInbox
|
|
|
309
321
|
# already says how to retry.
|
|
310
322
|
def loading_for
|
|
311
323
|
return nil if @last_poll || @error || !@booted_at
|
|
312
|
-
|
|
324
|
+
monotonic - @booted_at
|
|
313
325
|
end
|
|
314
326
|
|
|
315
327
|
def notice(msg)
|
|
316
328
|
@notice = [msg, Time.now + 4]
|
|
317
329
|
end
|
|
330
|
+
|
|
331
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
318
332
|
end
|
|
319
333
|
end
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -28,10 +28,15 @@ module ClaudeInbox
|
|
|
28
28
|
.split("\n")
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
private
|
|
32
|
+
|
|
31
33
|
# How wide the box wants to be; the frame caps it at the terminal.
|
|
32
34
|
def fit = self.class::WIDTH
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
def bordered(rows, inner)
|
|
37
|
+
top = Text.truncate(title, inner)
|
|
38
|
+
["┌#{top}#{"─" * (inner - Text.width(top))}┐", *rows.map { |row| "│#{row}│" }, "└#{"─" * inner}┘"]
|
|
39
|
+
end
|
|
35
40
|
|
|
36
41
|
def key(name) = @theme.cyan_bold(name)
|
|
37
42
|
|
|
@@ -83,9 +88,17 @@ module ClaudeInbox
|
|
|
83
88
|
|
|
84
89
|
def title = " #{kind.to_s.capitalize} pull request "
|
|
85
90
|
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
def
|
|
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
|
|
89
102
|
|
|
90
103
|
def lines(width, _caret)
|
|
91
104
|
(@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
|
|
@@ -102,6 +115,10 @@ module ClaudeInbox
|
|
|
102
115
|
|
|
103
116
|
private
|
|
104
117
|
|
|
118
|
+
# Titles vary, so the box grows to the longest line plus some air,
|
|
119
|
+
# and never shrinks below a plain dialog.
|
|
120
|
+
def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
|
|
121
|
+
|
|
105
122
|
# The list's badge, then the title. A bare #190 is ambiguous only
|
|
106
123
|
# once the list spans repos.
|
|
107
124
|
def label(pr)
|
|
@@ -170,9 +187,7 @@ module ClaudeInbox
|
|
|
170
187
|
# pushes the right border out on every line that has one.
|
|
171
188
|
def frame(width, _caret = nil)
|
|
172
189
|
inner = [width - 4, WIDTH].min - 2
|
|
173
|
-
|
|
174
|
-
*lines(inner, nil).map { |l| "│#{Text.pad(Text.truncate(l, inner), inner)}│" },
|
|
175
|
-
"└#{"─" * inner}┘"]
|
|
190
|
+
bordered(lines(inner, nil).map { |l| Text.pad(Text.truncate(l, inner), inner) }, inner)
|
|
176
191
|
end
|
|
177
192
|
|
|
178
193
|
def lines(_width, _caret)
|
|
@@ -33,8 +33,11 @@ module ClaudeInbox
|
|
|
33
33
|
def run
|
|
34
34
|
return false unless @lock.take(role: "headless")
|
|
35
35
|
@poller.start
|
|
36
|
-
Thread.new { watch_for_updates } if @updater
|
|
36
|
+
updates = Thread.new { watch_for_updates } if @updater
|
|
37
37
|
loop { @queue.pop }
|
|
38
|
+
ensure
|
|
39
|
+
@poller.stop
|
|
40
|
+
updates&.kill
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
private
|
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
|
|
@@ -12,12 +12,11 @@ require_relative "text_buffer"
|
|
|
12
12
|
require_relative "theme"
|
|
13
13
|
|
|
14
14
|
module ClaudeInbox
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# App wraps them in a box.
|
|
15
|
+
# The `n` form a new session is composed in. Pure: it says what happened
|
|
16
|
+
# and the App acts on it.
|
|
18
17
|
class NewSessionForm
|
|
19
18
|
# `value` is a TextBuffer on :text and :multiline fields and the picked
|
|
20
|
-
# string on :choice ones
|
|
19
|
+
# string on :choice ones.
|
|
21
20
|
Field = Struct.new(:key, :label, :kind, :value, :choices)
|
|
22
21
|
|
|
23
22
|
# A directory the Directory field offers, shown by its shortest
|
|
@@ -71,11 +70,9 @@ module ClaudeInbox
|
|
|
71
70
|
|
|
72
71
|
# => :cancel | :start | :start_and_attach | :changed
|
|
73
72
|
def press(name, raw)
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# it; there is no way to cancel a spawn already under way, so every
|
|
78
|
-
# key is swallowed until the App reports back.
|
|
73
|
+
# A start leaves the form up until the spawn has worked, so a failure
|
|
74
|
+
# can hand the prompt back. A spawn under way can't be cancelled, so
|
|
75
|
+
# every key is swallowed until the App reports back.
|
|
79
76
|
return :changed if @busy
|
|
80
77
|
return confirm_discard_press(name, raw) if @confirm_discard
|
|
81
78
|
@error = nil
|
|
@@ -149,8 +146,7 @@ module ClaudeInbox
|
|
|
149
146
|
@defaults = Settings.defaults(cwd, home: @home)
|
|
150
147
|
end
|
|
151
148
|
|
|
152
|
-
#
|
|
153
|
-
# every choice visible. Exactly `height` lines.
|
|
149
|
+
# The whole body, exactly `height` lines.
|
|
154
150
|
def screen(width, height)
|
|
155
151
|
inner_w = width - 4
|
|
156
152
|
fixed = 3 + 2 + 1 + (@fields.size - 1) + 1
|
|
@@ -189,11 +185,8 @@ module ClaudeInbox
|
|
|
189
185
|
@theme.hints(keys)
|
|
190
186
|
end
|
|
191
187
|
|
|
192
|
-
# The App's `claude --bg` call failed after
|
|
193
|
-
#
|
|
194
|
-
# a permissions error. The composed prompt is still here, so the form
|
|
195
|
-
# reopens on it with the reason shown instead of the App just logging
|
|
196
|
-
# the failure and losing the draft.
|
|
188
|
+
# The App's `claude --bg` call failed after a start. The form shows why,
|
|
189
|
+
# over the prompt it kept.
|
|
197
190
|
def submission_failed(message)
|
|
198
191
|
@busy = false
|
|
199
192
|
@error = message
|
|
@@ -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"))
|
|
@@ -7,13 +7,17 @@ require_relative "theme"
|
|
|
7
7
|
require_relative "store"
|
|
8
8
|
|
|
9
9
|
module ClaudeInbox
|
|
10
|
-
# sections
|
|
11
|
-
#
|
|
12
|
-
# Pure: no terminal, no IO, no clock beyond the `now` it is handed.
|
|
10
|
+
# Turns the sections and a View into a Frame, every line exactly `width`
|
|
11
|
+
# columns. Pure: no terminal, no IO, no clock beyond the `now` it is handed.
|
|
13
12
|
class Renderer
|
|
14
13
|
Frame = Struct.new(:lines, :items, :top) do
|
|
15
14
|
# The same frame for a terminal drawn without color.
|
|
16
|
-
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
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
CHROME_ROWS = 2 # header + footer
|
|
@@ -141,7 +145,7 @@ module ClaudeInbox
|
|
|
141
145
|
bar = "█" * filled + "░" * (10 - filled)
|
|
142
146
|
left = w.resets_at.to_i - now.to_i if resets && w.resets_at
|
|
143
147
|
figure = left&.positive? ? "#{w.percent}% · #{Text.age(left)} left" : "#{w.percent}%"
|
|
144
|
-
"#{@p.dim(w.label)} #{@theme.
|
|
148
|
+
"#{@p.dim(w.label)} #{@theme.tint(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
|
|
145
149
|
end.join(" ")
|
|
146
150
|
end
|
|
147
151
|
|
|
@@ -312,7 +316,7 @@ module ClaudeInbox
|
|
|
312
316
|
chrome = 1 + 1 + 1 + 1 + 1 + 2 + Text.width(meta) + 2 + 1
|
|
313
317
|
# Project is only cut once the label has given up all its space too,
|
|
314
318
|
# so the row can never exceed `width` and fall into Text.pad's blind
|
|
315
|
-
# tail-chop
|
|
319
|
+
# tail-chop.
|
|
316
320
|
project_text = Text.truncate(s.project, [width - chrome, 0].max)
|
|
317
321
|
project = quiet ? @p.dim(project_text) : @theme.cyan(project_text)
|
|
318
322
|
|
|
@@ -397,7 +401,7 @@ module ClaudeInbox
|
|
|
397
401
|
def pr_badge(s, section)
|
|
398
402
|
pr = s.pr
|
|
399
403
|
return nil unless pr
|
|
400
|
-
badge = (section == :settled)
|
|
404
|
+
badge = @theme.pr_badge(pr, quiet: section == :settled)
|
|
401
405
|
more = (s.prs.size > 1) ? @p.dim(" · +#{s.prs.size - 1}") : ""
|
|
402
406
|
badge + more
|
|
403
407
|
end
|
|
@@ -416,8 +420,8 @@ module ClaudeInbox
|
|
|
416
420
|
end
|
|
417
421
|
|
|
418
422
|
# "working" while the agent is thinking, "idle" once it has stopped and
|
|
419
|
-
# only the work it kicked off is still open
|
|
420
|
-
#
|
|
423
|
+
# only the work it kicked off is still open. Either way the work is
|
|
424
|
+
# named, since "working · 2 agents" answers "working on what?".
|
|
421
425
|
def working_badge(s)
|
|
422
426
|
return @theme.yellow("waiting#{": #{s.waiting_for}" if s.waiting_for}") if s.status == "waiting"
|
|
423
427
|
label = s.job_state&.in_flight_label
|
|
@@ -431,12 +435,12 @@ module ClaudeInbox
|
|
|
431
435
|
|
|
432
436
|
# ----- modal --------------------------------------------------------------
|
|
433
437
|
|
|
434
|
-
# Centre a block of lines over the frame.
|
|
435
438
|
def overlay(lines, block, width)
|
|
436
439
|
block_w = block.map { |l| Text.width(l) }.max || 0
|
|
437
440
|
left = [(width - block_w) / 2, 0].max
|
|
438
441
|
top = [(lines.size - block.size) / 2, 0].max
|
|
439
|
-
|
|
442
|
+
# Nothing behind a pop-up is clickable.
|
|
443
|
+
out = lines.map { |l| l.gsub(Text::LINK, "") }
|
|
440
444
|
block.each_with_index do |bl, i|
|
|
441
445
|
y = top + i
|
|
442
446
|
next if y >= out.size
|
|
@@ -4,13 +4,9 @@ require_relative "settings"
|
|
|
4
4
|
|
|
5
5
|
module ClaudeInbox
|
|
6
6
|
# The slash commands `claude` would offer at its own prompt, read from the
|
|
7
|
-
# same places it reads them
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# `plugin:name`), and the skills claude.ai syncs down
|
|
11
|
-
# (`anthropic-skills:name`). Built-ins live inside the CLI and are not
|
|
12
|
-
# listed; typing one still works, since the prompt goes to `claude --bg`
|
|
13
|
-
# verbatim.
|
|
7
|
+
# same places it reads them (docs/cli-quirks.md). Built-ins live inside
|
|
8
|
+
# the CLI and are not listed; typing one still works, since the prompt
|
|
9
|
+
# goes to `claude --bg` verbatim.
|
|
14
10
|
#
|
|
15
11
|
# Pure: filesystem in, plain values out. Nothing here runs `claude`.
|
|
16
12
|
module SlashCommands
|
|
@@ -61,7 +57,6 @@ module ClaudeInbox
|
|
|
61
57
|
end
|
|
62
58
|
end
|
|
63
59
|
|
|
64
|
-
# "name", or "plugin:name" when it comes from a plugin.
|
|
65
60
|
def qualify(prefix, name) = [prefix, name].compact.join(":")
|
|
66
61
|
|
|
67
62
|
# `installed_plugins.json` maps "name@marketplace" to where the plugin
|
|
@@ -77,8 +72,6 @@ module ClaudeInbox
|
|
|
77
72
|
end
|
|
78
73
|
end
|
|
79
74
|
|
|
80
|
-
# claude.ai's synced skills sit in one bucket per account under
|
|
81
|
-
# ~/.claude/skills/synced and answer to the anthropic-skills prefix.
|
|
82
75
|
def synced(home)
|
|
83
76
|
Dir.glob(File.join(home, ".claude", "skills", "synced", "*", "")).flat_map do |bucket|
|
|
84
77
|
skills(bucket, prefix: SYNCED_PLUGIN)
|
|
@@ -54,10 +54,6 @@ module ClaudeInbox
|
|
|
54
54
|
# Active rather than Settled, until the state changes again.
|
|
55
55
|
def acknowledged? = unchanged_since?(entry&.acknowledged_at)
|
|
56
56
|
|
|
57
|
-
# Finished or needing you, so it carries a read or unread mark. Only a
|
|
58
|
-
# session you can attach to counts, since nothing else ever clears it.
|
|
59
|
-
def markable? = session.actionable? && (session.finished? || session.needs_you?)
|
|
60
|
-
|
|
61
57
|
# Finished or needing you since you last attached.
|
|
62
58
|
def unread?
|
|
63
59
|
return false unless markable?
|
|
@@ -122,6 +118,10 @@ module ClaudeInbox
|
|
|
122
118
|
|
|
123
119
|
private
|
|
124
120
|
|
|
121
|
+
# Finished or needing you, so it carries a read or unread mark. Only a
|
|
122
|
+
# session you can attach to counts, since nothing else ever clears it.
|
|
123
|
+
def markable? = session.actionable? && (session.finished? || session.needs_you?)
|
|
124
|
+
|
|
125
125
|
def unchanged_since?(at) = at && entry.state_since.to_i <= at.to_i
|
|
126
126
|
end
|
|
127
127
|
end
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ClaudeInbox
|
|
4
|
-
# Runs a helper command with
|
|
5
|
-
# it
|
|
6
|
-
# "active process" of the tty in the tab title; without this the title
|
|
7
|
-
# flips to "claude" or "ps" on every poll.
|
|
4
|
+
# Runs a helper command with its output captured, in a session of its own
|
|
5
|
+
# so it never reaches the tab title (docs/cli-quirks.md).
|
|
8
6
|
module Subprocess
|
|
9
7
|
Result = Struct.new(:out, :err, :status) do
|
|
10
8
|
def success? = status.success?
|
|
@@ -6,10 +6,8 @@ require "tty-screen"
|
|
|
6
6
|
require_relative "painter"
|
|
7
7
|
|
|
8
8
|
module ClaudeInbox
|
|
9
|
-
# The screen the inbox draws on
|
|
10
|
-
#
|
|
11
|
-
# and the Painter that diffs frames onto it. `release` hands the whole
|
|
12
|
-
# thing to a child that wants the tty and takes it back afterwards.
|
|
9
|
+
# The screen the inbox draws on, lent with `release` to a child that
|
|
10
|
+
# wants the tty.
|
|
13
11
|
class Terminal
|
|
14
12
|
ALT_ON = "\e[?1049h"
|
|
15
13
|
ALT_OFF = "\e[?1049l"
|
|
@@ -19,13 +17,14 @@ module ClaudeInbox
|
|
|
19
17
|
# know the mode ignore it and keep scrolling their own history.
|
|
20
18
|
WHEEL_KEYS_ON = "\e[?1007h"
|
|
21
19
|
WHEEL_KEYS_OFF = "\e[?1007l"
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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\\"
|
|
29
28
|
# Bracketed paste: what is pasted arrives fenced off from what is typed
|
|
30
29
|
# (Paste), and a pasted image, which has no text, arrives as an empty
|
|
31
30
|
# fence rather than not at all.
|
|
@@ -38,6 +37,7 @@ module ClaudeInbox
|
|
|
38
37
|
@painter = Painter.new(out)
|
|
39
38
|
@restored = true
|
|
40
39
|
@size = nil
|
|
40
|
+
@over_link = false
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# The tty's mode is kept as found, not assumed: `cooked!` sets a stock
|
|
@@ -54,12 +54,13 @@ module ClaudeInbox
|
|
|
54
54
|
resized
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# Safe to call twice, and from at_exit
|
|
57
|
+
# Safe to call twice, and from at_exit.
|
|
58
58
|
def restore
|
|
59
59
|
return if @restored
|
|
60
60
|
@restored = true
|
|
61
|
+
@over_link = false
|
|
61
62
|
@input.console_mode = @mode if @mode && @input.tty?
|
|
62
|
-
@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
|
|
63
64
|
@out.flush
|
|
64
65
|
rescue
|
|
65
66
|
nil
|
|
@@ -98,6 +99,13 @@ module ClaudeInbox
|
|
|
98
99
|
|
|
99
100
|
def paint(lines) = @painter.paint(lines)
|
|
100
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
|
+
|
|
101
109
|
private
|
|
102
110
|
|
|
103
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
|
|
|
@@ -35,6 +35,8 @@ module ClaudeInbox
|
|
|
35
35
|
|
|
36
36
|
def initialize(text = "")
|
|
37
37
|
@widths = Hash.new { |h, s| h[s] = Text.width(s) }
|
|
38
|
+
@images = 0
|
|
39
|
+
@pastes = 0
|
|
38
40
|
replace(text)
|
|
39
41
|
end
|
|
40
42
|
|
|
@@ -77,11 +79,11 @@ module ClaudeInbox
|
|
|
77
79
|
|
|
78
80
|
# Chips are numbered per kind and never reused, so a second image is
|
|
79
81
|
# `[Image #2]` even after the first was deleted, as Claude Code does.
|
|
80
|
-
def attach(path) = place(Image.new(@images
|
|
82
|
+
def attach(path) = place(Image.new(@images += 1, path))
|
|
81
83
|
|
|
82
84
|
def paste(text)
|
|
83
85
|
return insert(text) if text.size <= FOLD_CHARS && text.count("\n") < FOLD_LINES
|
|
84
|
-
place(Pasted.new(@pastes
|
|
86
|
+
place(Pasted.new(@pastes += 1, text))
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
# Handles one keypress: readline's editing keys, plus printable text.
|
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/updater.rb
CHANGED
|
@@ -133,7 +133,7 @@ module ClaudeInbox
|
|
|
133
133
|
# clone follows the fork.
|
|
134
134
|
def fetch_upstream
|
|
135
135
|
remote = git("config", "branch.main.remote") or raise Error, "main tracks no remote"
|
|
136
|
-
fetched =
|
|
136
|
+
fetched = run_git("fetch", "--quiet", remote)
|
|
137
137
|
raise Error, "git fetch #{remote} failed: #{fetched.err.strip}" unless fetched.success?
|
|
138
138
|
git("rev-parse", "main@{upstream}") or raise Error, "main tracks no remote"
|
|
139
139
|
end
|
|
@@ -142,12 +142,14 @@ module ClaudeInbox
|
|
|
142
142
|
branch = git("symbolic-ref", "--short", "HEAD")
|
|
143
143
|
return Applied.new(success: false, output: "not on main (on #{branch})") unless branch == "main"
|
|
144
144
|
return Applied.new(success: false, output: "the working tree is dirty") unless git("status", "--porcelain") == ""
|
|
145
|
-
merged =
|
|
145
|
+
merged = run_git("merge", "--ff-only", "--quiet", "main@{upstream}")
|
|
146
146
|
Applied.from(merged)
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
+
def run_git(*args) = @run.call("git", "-C", @root, *args)
|
|
150
|
+
|
|
149
151
|
def git(*args)
|
|
150
|
-
result =
|
|
152
|
+
result = run_git(*args)
|
|
151
153
|
result.out.strip if result.success?
|
|
152
154
|
end
|
|
153
155
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "strscan"
|
|
4
4
|
require "unicode/display_width"
|
|
5
|
+
require_relative "text"
|
|
5
6
|
|
|
6
7
|
module ClaudeInbox
|
|
7
8
|
# A deliberately small cursor-addressed screen model that turns captured
|
|
@@ -22,7 +23,7 @@ module ClaudeInbox
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def feed(str)
|
|
25
|
-
ss = StringScanner.new(
|
|
26
|
+
ss = StringScanner.new(Text.utf8(str))
|
|
26
27
|
until ss.eos?
|
|
27
28
|
if ss.scan(CSI)
|
|
28
29
|
csi(ss[1], ss[2])
|
data/lib/claude_inbox.rb
CHANGED
data/mod/hooks/register.tsx
CHANGED
|
@@ -335,7 +335,7 @@ export const register: Register = (on, options) => {
|
|
|
335
335
|
})
|
|
336
336
|
|
|
337
337
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
338
|
-
const { Box, Text, Button } = $.ui.resolve(e)
|
|
338
|
+
const { Box, Text, Button, Link } = $.ui.resolve(e)
|
|
339
339
|
const list = await read($, rows)
|
|
340
340
|
const viewing = await read($, current)
|
|
341
341
|
const folded = await read($, folds)
|
|
@@ -497,9 +497,16 @@ export const register: Register = (on, options) => {
|
|
|
497
497
|
{/* The label gives up its cells to the PR chip, which would otherwise wrap under it. */}
|
|
498
498
|
{one.pr ? (
|
|
499
499
|
<Box flexShrink={0}>
|
|
500
|
+
<Box key={`pr:${itemKey(item)}`} marginLeft={1} hover={{ backgroundColor: 'subtle' }}>
|
|
501
|
+
<Link href={one.pr.url}>
|
|
502
|
+
<Text underline color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
|
|
503
|
+
{one.pr.short}
|
|
504
|
+
</Text>
|
|
505
|
+
</Link>
|
|
506
|
+
</Box>
|
|
500
507
|
<Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
|
|
501
508
|
{' '}
|
|
502
|
-
{one.pr.
|
|
509
|
+
{one.pr.state}
|
|
503
510
|
</Text>
|
|
504
511
|
</Box>
|
|
505
512
|
) : 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: 'Link' }))?.props.href).toBe('https://github.com/o/r/pull/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' })
|
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.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Byron
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: optparse
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.5'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.5'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: tty-cursor
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|