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,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "tty-cursor"
|
|
5
|
+
require "tty-screen"
|
|
6
|
+
require_relative "renderer"
|
|
7
|
+
|
|
8
|
+
module ClaudeInbox
|
|
9
|
+
# The screen the inbox draws on: the alt screen and the mouse and wheel
|
|
10
|
+
# modes that come and go with it, raw mode on the input, the cached size,
|
|
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.
|
|
13
|
+
class Terminal
|
|
14
|
+
ALT_ON = "\e[?1049h"
|
|
15
|
+
ALT_OFF = "\e[?1049l"
|
|
16
|
+
# Alternate scroll mode: while the alt screen is up the terminal turns
|
|
17
|
+
# wheel ticks into cursor keys, so a scroll moves the selection instead of
|
|
18
|
+
# dragging the scrollback we are covering into view. Terminals that don't
|
|
19
|
+
# know the mode ignore it and keep scrolling their own history.
|
|
20
|
+
WHEEL_KEYS_ON = "\e[?1007h"
|
|
21
|
+
WHEEL_KEYS_OFF = "\e[?1007l"
|
|
22
|
+
# Real mouse reporting: button events plus the SGR encoding, so clicks
|
|
23
|
+
# and wheel ticks arrive as escape sequences we parse ourselves (Mouse)
|
|
24
|
+
# instead of the terminal only ever translating the wheel to arrow
|
|
25
|
+
# keys. Terminals that don't understand either mode just ignore it and
|
|
26
|
+
# fall back to WHEEL_KEYS_ON's translation, or their own scrollback.
|
|
27
|
+
MOUSE_ON = "\e[?1000h\e[?1006h"
|
|
28
|
+
MOUSE_OFF = "\e[?1006l\e[?1000l"
|
|
29
|
+
# Bracketed paste: what is pasted arrives fenced off from what is typed
|
|
30
|
+
# (Paste), and a pasted image, which has no text, arrives as an empty
|
|
31
|
+
# fence rather than not at all.
|
|
32
|
+
PASTE_ON = "\e[?2004h"
|
|
33
|
+
PASTE_OFF = "\e[?2004l"
|
|
34
|
+
|
|
35
|
+
def initialize(out, input)
|
|
36
|
+
@out = out
|
|
37
|
+
@input = input
|
|
38
|
+
@painter = Painter.new(out)
|
|
39
|
+
@restored = true
|
|
40
|
+
@size = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def enter
|
|
44
|
+
@out.print ALT_ON, WHEEL_KEYS_ON, MOUSE_ON, PASTE_ON, TTY::Cursor.hide, TTY::Cursor.clear_screen
|
|
45
|
+
@out.flush
|
|
46
|
+
@input.raw! if @input.respond_to?(:raw!) && @input.tty?
|
|
47
|
+
@restored = false
|
|
48
|
+
resized
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Safe to call twice, and from at_exit: the second call is a no-op.
|
|
52
|
+
def restore
|
|
53
|
+
return if @restored
|
|
54
|
+
@restored = true
|
|
55
|
+
@input.cooked! if @input.respond_to?(:cooked!) && @input.tty?
|
|
56
|
+
@out.print TTY::Cursor.show, PASTE_OFF, MOUSE_OFF, WHEEL_KEYS_OFF, ALT_OFF
|
|
57
|
+
@out.flush
|
|
58
|
+
rescue
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Gives the tty to the block, cleared and in cooked mode, and comes back
|
|
63
|
+
# to a fresh alt screen whatever the block did.
|
|
64
|
+
def release
|
|
65
|
+
restore
|
|
66
|
+
@out.print TTY::Cursor.clear_screen
|
|
67
|
+
@out.flush
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
enter
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# [cols, rows]. Cached: querying the terminal can fall back to spawning
|
|
74
|
+
# `tput`, which is far too slow to do on every frame. Refreshed by
|
|
75
|
+
# `resized`, which WINCH and re-entry both call.
|
|
76
|
+
def size
|
|
77
|
+
@size ||= measure
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resized
|
|
81
|
+
@size = nil
|
|
82
|
+
@painter.invalidate
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def paint(lines) = @painter.paint(lines)
|
|
86
|
+
|
|
87
|
+
# Repaint every row on the next frame.
|
|
88
|
+
def invalidate = @painter.invalidate
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def measure
|
|
93
|
+
rows, cols = begin
|
|
94
|
+
(@out.respond_to?(:winsize) && @out.tty?) ? @out.winsize : TTY::Screen.size
|
|
95
|
+
rescue
|
|
96
|
+
TTY::Screen.size
|
|
97
|
+
end
|
|
98
|
+
[[cols, 40].max, [rows, 8].max]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# Width-aware string helpers. Never use String#[] on user-facing text:
|
|
7
|
+
# session names carry emoji and glyphs that break byte/char slicing.
|
|
8
|
+
module Text
|
|
9
|
+
ANSI = /\e\[[0-9;?]*[A-Za-z]/
|
|
10
|
+
ELLIPSIS = "…"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def strip_ansi(s) = s.gsub(ANSI, "")
|
|
15
|
+
|
|
16
|
+
def width(s) = Unicode::DisplayWidth.of(strip_ansi(s))
|
|
17
|
+
|
|
18
|
+
# Truncate plain (uncolored) text to `w` columns, appending an ellipsis
|
|
19
|
+
# when anything was cut. Handles wide glyphs by stepping grapheme by grapheme.
|
|
20
|
+
def truncate(s, w)
|
|
21
|
+
return "" if w <= 0
|
|
22
|
+
return s if width(s) <= w
|
|
23
|
+
take(s, w - width(ELLIPSIS)) << ELLIPSIS
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# First `n` columns of plain text, no ellipsis.
|
|
27
|
+
def take(s, n)
|
|
28
|
+
out = +""
|
|
29
|
+
used = 0
|
|
30
|
+
s.each_grapheme_cluster do |g|
|
|
31
|
+
gw = Unicode::DisplayWidth.of(g)
|
|
32
|
+
break if used + gw > n
|
|
33
|
+
out << g
|
|
34
|
+
used += gw
|
|
35
|
+
end
|
|
36
|
+
out
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Plain text with its first `n` columns removed.
|
|
40
|
+
def drop(s, n)
|
|
41
|
+
used = 0
|
|
42
|
+
out = +""
|
|
43
|
+
s.each_grapheme_cluster do |g|
|
|
44
|
+
if used >= n
|
|
45
|
+
out << g
|
|
46
|
+
else
|
|
47
|
+
used += Unicode::DisplayWidth.of(g)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
out
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Right-pad (ANSI-aware) to exactly `w` columns. Truncates if too long.
|
|
54
|
+
def pad(s, w)
|
|
55
|
+
cur = width(s)
|
|
56
|
+
if cur > w
|
|
57
|
+
s = truncate(strip_ansi(s), w)
|
|
58
|
+
cur = width(s)
|
|
59
|
+
end
|
|
60
|
+
s + (" " * (w - cur))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Greedy word wrap on display width. Words wider than `w` are split.
|
|
64
|
+
def wrap(s, w) = segments(s, w).map(&:rstrip)
|
|
65
|
+
|
|
66
|
+
# Word wrap that keeps every character, so joining the segments back
|
|
67
|
+
# together returns the original string. An editor needs that: `wrap`
|
|
68
|
+
# drops the space you just typed, and with it the cursor's place.
|
|
69
|
+
def segments(s, w)
|
|
70
|
+
return [s] if w <= 0 || width(s) <= w
|
|
71
|
+
lines = []
|
|
72
|
+
line = +""
|
|
73
|
+
s.split(/(?<= )/).each do |word|
|
|
74
|
+
if width(line) + width(word.rstrip) > w && !line.empty?
|
|
75
|
+
lines << line
|
|
76
|
+
line = +""
|
|
77
|
+
end
|
|
78
|
+
while width(word) > w
|
|
79
|
+
lines << take(word, w)
|
|
80
|
+
word = word[take(word, w).size..]
|
|
81
|
+
end
|
|
82
|
+
line << word
|
|
83
|
+
end
|
|
84
|
+
lines << line unless line.empty?
|
|
85
|
+
lines
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# "45s", "12m", "3h", "2d"
|
|
89
|
+
def age(seconds)
|
|
90
|
+
s = seconds.to_i
|
|
91
|
+
return "0s" if s <= 0
|
|
92
|
+
return "#{s}s" if s < 60
|
|
93
|
+
return "#{s / 60}m" if s < 3600
|
|
94
|
+
return "#{s / 3600}h" if s < 86_400
|
|
95
|
+
"#{s / 86_400}d"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# One editable string with a cursor in it. Every edit and all the cursor
|
|
7
|
+
# arithmetic live here, so callers never index into the text themselves:
|
|
8
|
+
# they hand over keypresses and ask for something to draw.
|
|
9
|
+
#
|
|
10
|
+
# Positions count cells, not bytes or characters. A cell is a grapheme
|
|
11
|
+
# cluster — prompts and session names carry emoji, and character indexes
|
|
12
|
+
# cut them in half — or a Chip, an attached image that shows as one
|
|
13
|
+
# `[Image #1]` token and moves and deletes as one unit, the way Claude
|
|
14
|
+
# Code's own prompt treats a pasted image.
|
|
15
|
+
#
|
|
16
|
+
# b = TextBuffer.new("ab")
|
|
17
|
+
# b.press(:left, "\e[D")
|
|
18
|
+
# b.press("X", "X")
|
|
19
|
+
# b.to_s # => "aXb"
|
|
20
|
+
class TextBuffer
|
|
21
|
+
Chip = Struct.new(:n, :path) do
|
|
22
|
+
def to_s = "[Image ##{n}]"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(text = "")
|
|
26
|
+
@g = text.grapheme_clusters
|
|
27
|
+
@cursor = @g.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_s = @g.join
|
|
31
|
+
|
|
32
|
+
def empty? = @g.empty?
|
|
33
|
+
|
|
34
|
+
# Grapheme offset of the cursor. Rendering goes through #row / #view;
|
|
35
|
+
# this is here for callers that need to reason about position (tests).
|
|
36
|
+
attr_reader :cursor
|
|
37
|
+
|
|
38
|
+
def chips = @g.grep(Chip)
|
|
39
|
+
|
|
40
|
+
def expand = @g.map { |c| c.is_a?(Chip) ? yield(c) : c }.join
|
|
41
|
+
|
|
42
|
+
# Swaps the whole text out and parks the cursor at the end — what
|
|
43
|
+
# completion wants after it extends a path.
|
|
44
|
+
def replace(text)
|
|
45
|
+
@g = text.grapheme_clusters
|
|
46
|
+
@cursor = @g.size
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Everything before the cursor, as one string.
|
|
51
|
+
def head = @g[0...@cursor].join
|
|
52
|
+
|
|
53
|
+
# How completion drops a picked command in over the half-typed one.
|
|
54
|
+
def replace_before(count, text)
|
|
55
|
+
delete(@cursor - count, count)
|
|
56
|
+
insert(text)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def insert(text)
|
|
60
|
+
g = text.grapheme_clusters
|
|
61
|
+
@g.insert(@cursor, *g)
|
|
62
|
+
@cursor += g.size
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Numbered after the chips already in the text, so a second image is
|
|
66
|
+
# `[Image #2]` even after the first was deleted, as Claude Code does.
|
|
67
|
+
def attach(path)
|
|
68
|
+
chip = Chip.new(@next_chip = (@next_chip || 0) + 1, path)
|
|
69
|
+
@g.insert(@cursor, chip)
|
|
70
|
+
@cursor += 1
|
|
71
|
+
chip
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Handles one keypress — readline's editing keys, plus printable text —
|
|
75
|
+
# or returns false when the key is not ours and the caller should deal
|
|
76
|
+
# with it (Tab, Enter, Escape, anything else unprintable).
|
|
77
|
+
def press(name, raw)
|
|
78
|
+
case name
|
|
79
|
+
when :left then @cursor = [@cursor - 1, 0].max
|
|
80
|
+
when :right then @cursor = [@cursor + 1, @g.size].min
|
|
81
|
+
when :home, :ctrl_a then @cursor = 0
|
|
82
|
+
when :end, :ctrl_e then @cursor = @g.size
|
|
83
|
+
when :backspace, :ctrl_h then delete(@cursor - 1, 1)
|
|
84
|
+
when :delete then delete(@cursor, 1)
|
|
85
|
+
when :ctrl_u then delete(0, @cursor)
|
|
86
|
+
when :ctrl_k then delete(@cursor, @g.size - @cursor)
|
|
87
|
+
when :ctrl_w then delete_word
|
|
88
|
+
else
|
|
89
|
+
return false unless raw.is_a?(String) && raw.match?(/\A[[:print:]]+\z/)
|
|
90
|
+
insert(raw)
|
|
91
|
+
end
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The text as a single row of at most `width` columns, scrolled right so
|
|
96
|
+
# that the cursor stays in view on a value longer than the box. `cursor`
|
|
97
|
+
# paints the one cell under it; pass nil for an unfocused field, which
|
|
98
|
+
# gets plain truncated text instead.
|
|
99
|
+
def row(width, cursor: nil)
|
|
100
|
+
return Text.truncate(to_s, width) unless cursor
|
|
101
|
+
first = 0
|
|
102
|
+
first += 1 while width_of(@g[first...@cursor]) > width - 1
|
|
103
|
+
cells = []
|
|
104
|
+
@g[first..].each do |c|
|
|
105
|
+
break if width_of(cells) + width_of([c]) > width
|
|
106
|
+
cells << c
|
|
107
|
+
end
|
|
108
|
+
paint(cells, @cursor - first, cursor)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The visible slice of a multi-line editor: word-wrapped to `width`, at
|
|
112
|
+
# most `height` rows, plus how many rows are hidden above them. Shows the
|
|
113
|
+
# end of the text, which is where typing happens; walk the cursor up out
|
|
114
|
+
# of that window and the window follows it instead. `chip` paints each
|
|
115
|
+
# attached image's token; without it they read as plain text.
|
|
116
|
+
def view(width, height, cursor: nil, chip: nil)
|
|
117
|
+
rows = wrapped(width)
|
|
118
|
+
at = cursor_row(rows)
|
|
119
|
+
first = [rows.size - height, 0].max
|
|
120
|
+
first = at if at < first
|
|
121
|
+
slice = rows[first, height]
|
|
122
|
+
lines = slice.map { |cells, _| paint(cells, nil, nil, chip) }
|
|
123
|
+
if cursor
|
|
124
|
+
cells, start = slice[at - first]
|
|
125
|
+
lines[at - first] = paint(cells, @cursor - start, cursor, chip)
|
|
126
|
+
end
|
|
127
|
+
[lines, first]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def width_of(cells) = cells.sum { |c| Text.width(c.to_s) }
|
|
133
|
+
|
|
134
|
+
def paint(cells, offset, cursor, chip = nil)
|
|
135
|
+
out = cells.each_with_index.map do |c, i|
|
|
136
|
+
s = c.to_s
|
|
137
|
+
s = chip.call(s) if chip && c.is_a?(Chip)
|
|
138
|
+
(i == offset) ? cursor.call(s) : s
|
|
139
|
+
end
|
|
140
|
+
out << cursor.call(" ") if offset && offset >= cells.size
|
|
141
|
+
out.join
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# One entry per display row: [cells, offset of its first cell]. A
|
|
145
|
+
# cursor at the end of a row that is already full has no cell of its
|
|
146
|
+
# own, so it gets a row of its own — what a terminal does when text
|
|
147
|
+
# reaches the right margin.
|
|
148
|
+
def wrapped(width)
|
|
149
|
+
rows = logical_lines.flat_map do |line, start|
|
|
150
|
+
at = start
|
|
151
|
+
(line.empty? ? [[]] : segments(line, width)).map do |seg|
|
|
152
|
+
row = [seg, at]
|
|
153
|
+
at += seg.size
|
|
154
|
+
row
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
rows << [[], @g.size] if @cursor == @g.size && width_of(rows.last.first) >= width
|
|
158
|
+
rows
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Text.segments on cells: a chip is one cell however wide its label, so
|
|
162
|
+
# the wrap has to measure cells rather than a joined string.
|
|
163
|
+
def segments(cells, width)
|
|
164
|
+
return [cells] if width_of(cells) <= width
|
|
165
|
+
words = cells.slice_when { |c, _| c == " " }.to_a
|
|
166
|
+
lines = []
|
|
167
|
+
line = []
|
|
168
|
+
words.each do |word|
|
|
169
|
+
if !line.empty? && width_of(line) + width_of(word.reverse.drop_while { |c| c == " " }) > width
|
|
170
|
+
lines << line
|
|
171
|
+
line = []
|
|
172
|
+
end
|
|
173
|
+
while width_of(word) > width
|
|
174
|
+
cut = word.size - 1
|
|
175
|
+
cut -= 1 while cut > 0 && width_of(word[0...cut]) > width
|
|
176
|
+
lines << word[0...cut]
|
|
177
|
+
word = word[cut..]
|
|
178
|
+
end
|
|
179
|
+
line += word
|
|
180
|
+
end
|
|
181
|
+
lines << line unless line.empty?
|
|
182
|
+
lines
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The newline itself belongs to the line it ends, so a cursor sitting on
|
|
186
|
+
# it lands past the end of that line rather than at the start of the next.
|
|
187
|
+
def logical_lines
|
|
188
|
+
out = []
|
|
189
|
+
start = 0
|
|
190
|
+
line = []
|
|
191
|
+
@g.each_with_index do |g, i|
|
|
192
|
+
next line << g unless g == "\n"
|
|
193
|
+
out << [line, start]
|
|
194
|
+
line = []
|
|
195
|
+
start = i + 1
|
|
196
|
+
end
|
|
197
|
+
out << [line, start]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def cursor_row(rows) = rows.rindex { |_, start| start <= @cursor } || 0
|
|
201
|
+
|
|
202
|
+
def delete(at, length)
|
|
203
|
+
return if at < 0 || length <= 0
|
|
204
|
+
@g.slice!(at, length)
|
|
205
|
+
@cursor = at
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Back over any spaces, then over the word itself.
|
|
209
|
+
def delete_word
|
|
210
|
+
at = @cursor
|
|
211
|
+
at -= 1 while at > 0 && @g[at - 1] == " "
|
|
212
|
+
at -= 1 while at > 0 && @g[at - 1] != " "
|
|
213
|
+
delete(at, @cursor - at)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
# Chrome hues tuned to Atom One Dark rather than the terminal profile's
|
|
5
|
+
# own ANSI reds and greens. 256-color indices, since those render the
|
|
6
|
+
# same everywhere a terminal claims 256-color support.
|
|
7
|
+
class Theme
|
|
8
|
+
HUES = {red: 203, green: 108, yellow: 179, blue: 75, purple: 176, cyan: 73}.freeze
|
|
9
|
+
BG = 236
|
|
10
|
+
|
|
11
|
+
def initialize(enabled: true)
|
|
12
|
+
@enabled = enabled
|
|
13
|
+
HUES.each_key do |hue|
|
|
14
|
+
define_singleton_method(hue) { |text| color(text, hue) }
|
|
15
|
+
define_singleton_method(:"#{hue}_bold") { |text| color(text, hue, bold: true) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A filled pill: editor-background text on the hue, for a selected row
|
|
20
|
+
# or choice — the inverse of `color`, which puts the hue on the text.
|
|
21
|
+
def pill(text, hue)
|
|
22
|
+
return text unless @enabled
|
|
23
|
+
"\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def color(text, hue, bold: false)
|
|
29
|
+
return text unless @enabled
|
|
30
|
+
codes = ["38;5;#{HUES.fetch(hue)}"]
|
|
31
|
+
codes << "1" if bold
|
|
32
|
+
"\e[#{codes.join(";")}m#{text}\e[0m"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require "unicode/display_width"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# A deliberately small cursor-addressed screen model. `claude logs` emits a
|
|
8
|
+
# replay of the session's terminal output — cursor moves, erase-line, SGR —
|
|
9
|
+
# not plain text, and words are often separated by cursor motion instead of
|
|
10
|
+
# spaces. Stripping escapes therefore yields garbage; feeding them through a
|
|
11
|
+
# grid does not. This is not a VT emulator: it handles the handful of
|
|
12
|
+
# sequences the replay actually uses and ignores the rest.
|
|
13
|
+
class VtScreen
|
|
14
|
+
CSI = /\e\[([0-9;?]*)([A-Za-z@`])/
|
|
15
|
+
OSC = /\e\][^\a\e]*(?:\a|\e\\)?/
|
|
16
|
+
ESC_OTHER = /\e[()#][A-Za-z0-9]|\e[A-Za-z0-9=>78]/
|
|
17
|
+
|
|
18
|
+
attr_reader :rows, :cols
|
|
19
|
+
|
|
20
|
+
def initialize(rows: 300, cols: 300)
|
|
21
|
+
@rows = rows
|
|
22
|
+
@cols = cols
|
|
23
|
+
@grid = Array.new(rows) { Array.new(cols) { " " } }
|
|
24
|
+
@row = 0
|
|
25
|
+
@col = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def feed(str)
|
|
29
|
+
ss = StringScanner.new(str.dup.force_encoding("UTF-8").scrub)
|
|
30
|
+
until ss.eos?
|
|
31
|
+
if ss.scan(CSI)
|
|
32
|
+
csi(ss[1], ss[2])
|
|
33
|
+
elsif ss.scan(OSC) || ss.scan(ESC_OTHER)
|
|
34
|
+
next
|
|
35
|
+
else
|
|
36
|
+
ch = ss.getch
|
|
37
|
+
control(ch) || put(ch)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Text rows with trailing whitespace removed; leading/trailing blank rows
|
|
44
|
+
# dropped and runs of blank rows collapsed to one.
|
|
45
|
+
def lines
|
|
46
|
+
out = @grid.map { |r| r.join.rstrip }
|
|
47
|
+
out.shift while out.first&.empty?
|
|
48
|
+
out.pop while out.last&.empty?
|
|
49
|
+
out.chunk_while { |a, b| a.empty? && b.empty? }.map(&:first)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def control(ch)
|
|
55
|
+
case ch
|
|
56
|
+
when "\r" then @col = 0
|
|
57
|
+
when "\n" then newline
|
|
58
|
+
when "\b" then @col = [@col - 1, 0].max
|
|
59
|
+
when "\t" then @col = [((@col / 8) + 1) * 8, @cols - 1].min
|
|
60
|
+
when "\a", "\0", "\e" then nil
|
|
61
|
+
else return false
|
|
62
|
+
end
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def newline
|
|
67
|
+
if @row >= @rows - 1
|
|
68
|
+
@grid.shift
|
|
69
|
+
@grid << Array.new(@cols) { " " }
|
|
70
|
+
else
|
|
71
|
+
@row += 1
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def put(ch)
|
|
76
|
+
o = ch.ord
|
|
77
|
+
return if o < 32
|
|
78
|
+
w = (o < 127) ? 1 : Unicode::DisplayWidth.of(ch)
|
|
79
|
+
return if w <= 0
|
|
80
|
+
if @col + w > @cols
|
|
81
|
+
@col = 0
|
|
82
|
+
newline
|
|
83
|
+
end
|
|
84
|
+
@grid[@row][@col] = ch
|
|
85
|
+
@grid[@row][@col + 1] = "" if w == 2 && @col + 1 < @cols
|
|
86
|
+
@col += w
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def csi(params, final)
|
|
90
|
+
return if params.start_with?("?")
|
|
91
|
+
nums = params.split(";").map { |x| x.empty? ? nil : x.to_i }
|
|
92
|
+
n = nums[0] || 1
|
|
93
|
+
case final
|
|
94
|
+
when "H", "f"
|
|
95
|
+
@row = ((nums[0] || 1) - 1).clamp(0, @rows - 1)
|
|
96
|
+
@col = ((nums[1] || 1) - 1).clamp(0, @cols - 1)
|
|
97
|
+
when "A" then @row = [@row - n, 0].max
|
|
98
|
+
when "B" then @row = [@row + n, @rows - 1].min
|
|
99
|
+
when "C" then @col = [@col + n, @cols - 1].min
|
|
100
|
+
when "D" then @col = [@col - n, 0].max
|
|
101
|
+
when "G", "`" then @col = (n - 1).clamp(0, @cols - 1)
|
|
102
|
+
when "d" then @row = (n - 1).clamp(0, @rows - 1)
|
|
103
|
+
when "E"
|
|
104
|
+
@row = [@row + n, @rows - 1].min
|
|
105
|
+
@col = 0
|
|
106
|
+
when "F"
|
|
107
|
+
@row = [@row - n, 0].max
|
|
108
|
+
@col = 0
|
|
109
|
+
when "J" then erase_display(nums[0] || 0)
|
|
110
|
+
when "K" then erase_line(nums[0] || 0)
|
|
111
|
+
when "@" then n.times { @grid[@row].insert(@col, " ") && @grid[@row].pop }
|
|
112
|
+
when "P" then n.times { @grid[@row].delete_at(@col) && @grid[@row].push(" ") }
|
|
113
|
+
when "X" then n.times { |k| @grid[@row][@col + k] = " " if @col + k < @cols }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def erase_display(mode)
|
|
118
|
+
case mode
|
|
119
|
+
when 0
|
|
120
|
+
erase_line(0)
|
|
121
|
+
((@row + 1)...@rows).each { |r| @grid[r].fill(" ") }
|
|
122
|
+
when 1
|
|
123
|
+
erase_line(1)
|
|
124
|
+
(0...@row).each { |r| @grid[r].fill(" ") }
|
|
125
|
+
else
|
|
126
|
+
@grid.each { |r| r.fill(" ") }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def erase_line(mode)
|
|
131
|
+
case mode
|
|
132
|
+
when 0 then @grid[@row].fill(" ", @col)
|
|
133
|
+
when 1 then @grid[@row].fill(" ", 0, @col + 1)
|
|
134
|
+
else @grid[@row].fill(" ")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/claude_inbox.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
require_relative "claude_inbox/palette"
|
|
8
|
+
require_relative "claude_inbox/session"
|
|
9
|
+
require_relative "claude_inbox/job_state"
|
|
10
|
+
require_relative "claude_inbox/pull_requests"
|
|
11
|
+
require_relative "claude_inbox/agents_client"
|
|
12
|
+
require_relative "claude_inbox/sessions"
|
|
13
|
+
require_relative "claude_inbox/store"
|
|
14
|
+
require_relative "claude_inbox/reaper"
|