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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +442 -0
  4. data/exe/claude-inbox +33 -0
  5. data/lib/claude_inbox/agents_client.rb +224 -0
  6. data/lib/claude_inbox/app.rb +533 -0
  7. data/lib/claude_inbox/debug.rb +16 -0
  8. data/lib/claude_inbox/dialog.rb +105 -0
  9. data/lib/claude_inbox/images.rb +58 -0
  10. data/lib/claude_inbox/job_state.rb +89 -0
  11. data/lib/claude_inbox/keymap.rb +71 -0
  12. data/lib/claude_inbox/logs.rb +66 -0
  13. data/lib/claude_inbox/mouse.rb +34 -0
  14. data/lib/claude_inbox/new_session_form.rb +363 -0
  15. data/lib/claude_inbox/palette.rb +40 -0
  16. data/lib/claude_inbox/paste.rb +42 -0
  17. data/lib/claude_inbox/peek.rb +81 -0
  18. data/lib/claude_inbox/poller.rb +98 -0
  19. data/lib/claude_inbox/pull_requests.rb +155 -0
  20. data/lib/claude_inbox/rate_limits.rb +48 -0
  21. data/lib/claude_inbox/reaper.rb +103 -0
  22. data/lib/claude_inbox/records.rb +28 -0
  23. data/lib/claude_inbox/renderer.rb +447 -0
  24. data/lib/claude_inbox/session.rb +100 -0
  25. data/lib/claude_inbox/sessions.rb +18 -0
  26. data/lib/claude_inbox/settings.rb +39 -0
  27. data/lib/claude_inbox/slash_commands.rb +116 -0
  28. data/lib/claude_inbox/store/entry.rb +144 -0
  29. data/lib/claude_inbox/store/row.rb +101 -0
  30. data/lib/claude_inbox/store/sections.rb +47 -0
  31. data/lib/claude_inbox/store/selection.rb +16 -0
  32. data/lib/claude_inbox/store.rb +156 -0
  33. data/lib/claude_inbox/subprocess.rb +53 -0
  34. data/lib/claude_inbox/terminal.rb +101 -0
  35. data/lib/claude_inbox/text.rb +98 -0
  36. data/lib/claude_inbox/text_buffer.rb +216 -0
  37. data/lib/claude_inbox/theme.rb +35 -0
  38. data/lib/claude_inbox/vt_screen.rb +138 -0
  39. data/lib/claude_inbox.rb +14 -0
  40. 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
@@ -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"