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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeInbox
6
+ # What the daemon writes about a background session in
7
+ # ~/.claude/jobs/<id>/state.json. The only reader of that file.
8
+ #
9
+ # `claude agents --json` reports one word, `state`, and "working" covers two
10
+ # different situations: the agent is thinking, or the agent is idle and
11
+ # something it started earlier — a watch shell, a sub-agent — is still open.
12
+ # The file tells them apart: `tempo` is the agent's own pulse, `fan` names
13
+ # each outstanding piece of work, and `detail` is the session's own status
14
+ # line, with `needs` naming what it is waiting on while blocked and
15
+ # `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
16
+ # transcript, which PullRequests reads through here, and the color `/color`
17
+ # set on the session, which `claude agents --json` drops.
18
+ #
19
+ # Never cached. A color changes the moment you type `/color`, so every poll
20
+ # asks the file again.
21
+ #
22
+ # The file says what a session is *doing*, never whether it is *alive*: the
23
+ # session's own process writes it, so one that dies hard leaves it frozen
24
+ # mid-turn, claiming "working" for ever. Liveness stays with the daemon.
25
+ class JobState
26
+ DEFAULT_DIR = File.join(Dir.home, ".claude", "jobs")
27
+
28
+ # The daemon's names for the things a session waits on, in ours.
29
+ KIND_WORDS = {
30
+ "shell" => "shell",
31
+ "local_bash" => "shell",
32
+ "teammate" => "agent",
33
+ "in_process_teammate" => "agent",
34
+ "monitor" => "monitor"
35
+ }.freeze
36
+
37
+ # Each background session with its job file read onto `job_state`, or nil
38
+ # when there is none: interactive sessions have no job file, and neither
39
+ # does one the daemon has already forgotten. Sessions.load calls this.
40
+ def self.enrich(sessions, jobs_dir: DEFAULT_DIR)
41
+ sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
42
+ end
43
+
44
+ # => JobState, or nil when there is no readable file for this id.
45
+ def self.read(id, jobs_dir: DEFAULT_DIR)
46
+ return nil unless id
47
+ new(JSON.parse(File.read(File.join(jobs_dir, id, "state.json"))))
48
+ rescue JSON::ParserError, SystemCallError
49
+ nil
50
+ end
51
+
52
+ attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :pr_urls, :color
53
+
54
+ def initialize(hash)
55
+ @detail = hash["detail"]
56
+ @needs = hash["needs"]
57
+ @result = (hash["output"] || {})["result"]
58
+ @tempo = hash["tempo"]
59
+ @kinds = (hash["fan"] || []).filter_map { |f| f["kind"] }
60
+ @tasks = (hash["inFlight"] || {})["tasks"].to_i
61
+ @pr_urls = (hash["children"] || []).select { |c| c["kind"] == "pr" && c["href"] }.map { |c| c["href"] }
62
+ @color = hash["color"]
63
+ end
64
+
65
+ # The agent itself is not thinking. On its own this means little — a
66
+ # session whose process died leaves the same reading behind — so it only
67
+ # says something paired with work still in flight.
68
+ def agent_idle? = tempo == "idle"
69
+
70
+ def in_flight? = tasks.positive?
71
+
72
+ # True when the agent has stopped and is only waiting on what it started.
73
+ def waiting_on_work? = agent_idle? && in_flight?
74
+
75
+ # "1 shell", "2 agents · 1 shell". Falls back to a bare count for a state
76
+ # file that counts the open tasks without naming them.
77
+ def in_flight_label
78
+ return nil unless in_flight?
79
+ return count_label if kinds.empty?
80
+ kinds.tally.map { |kind, n| "#{n} #{plural(KIND_WORDS.fetch(kind, kind), n)}" }.join(" · ")
81
+ end
82
+
83
+ private
84
+
85
+ def count_label = "#{tasks} #{plural("task", tasks)}"
86
+
87
+ def plural(word, n) = (n == 1) ? word : "#{word}s"
88
+ end
89
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ # Pure key -> action resolver with vim-style chords. The App feeds it one
5
+ # keypress at a time (the tty-reader key name plus the raw string) and gets
6
+ # back an action symbol, or nil while a chord is pending.
7
+ #
8
+ # km = Keymap.new
9
+ # km.press(:down, "j") # => :down
10
+ # km.press("g", "g") # => nil (pending)
11
+ # km.press("g", "g") # => :top
12
+ class Keymap
13
+ CHORD_TIMEOUT = 1.0
14
+
15
+ BINDINGS = {
16
+ # motion
17
+ "j" => :down, :down => :down,
18
+ "k" => :up, :up => :up,
19
+ "G" => :bottom,
20
+ :ctrl_d => :half_page_down, :ctrl_u => :half_page_up,
21
+ :ctrl_f => :page_down, :ctrl_b => :page_up,
22
+ :ctrl_e => :peek_down, :ctrl_y => :peek_up,
23
+ "J" => :peek_down, "K" => :peek_up,
24
+ # actions
25
+ :return => :activate, :enter => :activate, "l" => :activate,
26
+ "h" => :collapse,
27
+ "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
28
+ :ctrl_x => :delete,
29
+ "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin,
30
+ "R" => :refresh, "p" => :toggle_peek, "n" => :new_session,
31
+ :tab => :next_section, :back_tab => :prev_section,
32
+ "/" => :filter, :escape => :escape,
33
+ "q" => :quit, :ctrl_c => :quit
34
+ }.freeze
35
+
36
+ CHORDS = {
37
+ "g" => {"g" => :top},
38
+ "z" => {"o" => :fold_open, "c" => :fold_close, "a" => :fold_toggle}
39
+ }.freeze
40
+
41
+ attr_reader :pending
42
+
43
+ def initialize(clock: -> { Time.now })
44
+ @clock = clock
45
+ @pending = nil
46
+ @pending_at = nil
47
+ end
48
+
49
+ def press(name, raw)
50
+ expire_pending
51
+ if @pending
52
+ table = CHORDS[@pending]
53
+ @pending = nil
54
+ return table[raw] # nil on an unknown second key, chord dropped
55
+ end
56
+ if CHORDS.key?(raw)
57
+ @pending = raw
58
+ @pending_at = @clock.call
59
+ return nil
60
+ end
61
+ BINDINGS[name] || BINDINGS[raw]
62
+ end
63
+
64
+ private
65
+
66
+ def expire_pending
67
+ return unless @pending && @clock.call - @pending_at > CHORD_TIMEOUT
68
+ @pending = nil
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vt_screen"
4
+
5
+ module ClaudeInbox
6
+ # The `claude logs <id>` replay of each session as readable lines, fetched
7
+ # off the main thread, debounced and cached.
8
+ class Logs
9
+ DEBOUNCE = 0.25
10
+ TTL = 10
11
+ MAX_LINES = 400
12
+
13
+ def initialize(client, clock: -> { Time.now })
14
+ @client = client
15
+ @clock = clock
16
+ @cache = {}
17
+ @mutex = Mutex.new
18
+ @pending = nil
19
+ @pending_at = nil
20
+ @requests = Queue.new
21
+ @thread = Thread.new { worker }
22
+ @thread.abort_on_exception = false
23
+ end
24
+
25
+ def cached(id)
26
+ @mutex.synchronize { @cache[id]&.first }
27
+ end
28
+
29
+ def want(id)
30
+ return if id.nil?
31
+ @pending = id
32
+ @pending_at = @clock.call
33
+ end
34
+
35
+ # Promotes a request that has sat still for DEBOUNCE to the worker,
36
+ # unless the cache already has a fresh answer.
37
+ def tick
38
+ return unless @pending && @clock.call - @pending_at >= DEBOUNCE
39
+ id = @pending
40
+ @pending = nil
41
+ fresh = @mutex.synchronize { (e = @cache[id]) && @clock.call - e[1] < TTL }
42
+ @requests << id unless fresh
43
+ end
44
+
45
+ def stop = @thread.kill
46
+
47
+ private
48
+
49
+ def worker
50
+ loop do
51
+ id = @requests.pop
52
+ id = @requests.pop until @requests.empty? # only the latest matters
53
+ lines = fetch(id)
54
+ @mutex.synchronize { @cache[id] = [lines, @clock.call] }
55
+ end
56
+ end
57
+
58
+ def fetch(id)
59
+ raw = @client.logs(id)
60
+ return ["(no output available — the session's process is not running)"] if raw.nil?
61
+ VtScreen.new.feed(raw).lines.last(MAX_LINES)
62
+ rescue => e
63
+ ["(logs failed: #{e.message})"]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ # Parses SGR mouse reports ("\e[<Cb;Cx;Cy(M|m)", emitted once App turns on
5
+ # \e[?1000h\e[?1006h) into clicks and wheel ticks. Pure: no terminal, no
6
+ # IO. Row/col are the terminal's own 1-based coordinates, so callers can
7
+ # index straight into what they just painted.
8
+ module Mouse
9
+ SEQUENCE = /\e\[<(\d+);(\d+);(\d+)([Mm])/
10
+
11
+ Event = Struct.new(:kind, :row, :col)
12
+
13
+ # A raw keypress can glue several reports together the same way a fast
14
+ # "esc gg" glues onto one read (see App#split_keys), so this scans
15
+ # rather than matching once.
16
+ def self.events(raw)
17
+ raw.to_s.scan(SEQUENCE).filter_map do |cb, col, row, type|
18
+ kind = kind_for(cb.to_i, type)
19
+ Event.new(kind, row.to_i, col.to_i) if kind
20
+ end
21
+ end
22
+
23
+ # Bit 6 (0x40) marks a wheel tick, direction in bit 0. Otherwise this is
24
+ # a button press/release: bits 0-1 give the button (0 = left) and bit 5
25
+ # (0x20) marks a drag, so 0x23 masks both at once. Only a left click,
26
+ # released of any drag, counts; middle/right buttons and drags are left
27
+ # for the terminal's own handling.
28
+ def self.kind_for(cb, type)
29
+ return (cb.even? ? :scroll_up : :scroll_down) if cb & 0x40 != 0
30
+ :click if type == "M" && cb & 0x23 == 0
31
+ end
32
+ private_class_method :kind_for
33
+ end
34
+ end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agents_client"
4
+ require_relative "images"
5
+ require_relative "settings"
6
+ require_relative "slash_commands"
7
+ require_relative "text"
8
+ require_relative "text_buffer"
9
+ require_relative "theme"
10
+
11
+ module ClaudeInbox
12
+ # State and key handling for the "new session" modal. Pure: returns what
13
+ # happened so the App can act on it. Rendering returns plain lines; the
14
+ # App wraps them in a box.
15
+ class NewSessionForm
16
+ # `value` is a TextBuffer on :text and :multiline fields and the picked
17
+ # string on :choice ones — `kind` says which.
18
+ Field = Struct.new(:key, :label, :kind, :value, :choices)
19
+
20
+ # "default" stays the internal value so spawn_args leaves the flag off;
21
+ # the screen shows what that resolves to instead.
22
+ DEFAULT = "default"
23
+
24
+ MENU_ROWS = 6
25
+
26
+ def initialize(cwd:, pastel:, theme: Theme.new(enabled: pastel.enabled), home: Dir.home, clipboard: Images.method(:from_clipboard))
27
+ @p = pastel
28
+ @theme = theme
29
+ @home = home
30
+ @clipboard = clipboard
31
+ @fields = [
32
+ Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
33
+ Field.new(:name, "Name", :text, TextBuffer.new),
34
+ Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd.to_s)),
35
+ Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
36
+ Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
37
+ Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
38
+ Field.new(:worktree, "Worktree", :choice, "no", %w[no yes])
39
+ ]
40
+ @focus = 0
41
+ @error = nil
42
+ @defaults_for = nil
43
+ @commands_for = nil
44
+ @pick = 0
45
+ @dismissed = nil
46
+ @confirm_discard = false
47
+ end
48
+
49
+ def focused = @fields[@focus]
50
+
51
+ # => :cancel | :start | :start_and_attach | :changed
52
+ def press(name, raw)
53
+ return confirm_discard_press(name, raw) if @confirm_discard
54
+ @error = nil
55
+ @candidates = nil
56
+ return :changed if menu && menu_press(name)
57
+ before = command_query
58
+ case name
59
+ when :escape then return escape_pressed
60
+ when :ctrl_s then return submit(attach: false)
61
+ when :ctrl_o then return submit(attach: true)
62
+ when :ctrl_v then paste_clipboard
63
+ when :tab then complete_dir || move(1)
64
+ when :down then move(1)
65
+ when :back_tab, :up then move(-1)
66
+ when :return, :enter
67
+ (focused.kind == :multiline) ? focused.value.insert("\n") : move(1)
68
+ else
69
+ editable? ? focused.value.press(name, raw) : choose(name, raw)
70
+ end
71
+ edited = before && command_query && command_query != before
72
+ @pick = 0 if edited
73
+ @dismissed = nil if edited
74
+ :changed
75
+ end
76
+
77
+ # A bracketed paste. Empty is how a terminal pastes an image: there is
78
+ # no text to send, so the clipboard is read directly, the way Claude
79
+ # Code does on Cmd-V. A dropped file arrives as its path, and an image
80
+ # becomes a chip in the prompt rather than text.
81
+ def paste(text)
82
+ @error = nil
83
+ text = text.gsub(/\r\n?/, "\n")
84
+ if text.empty?
85
+ paste_clipboard
86
+ elsif focused.key == :prompt && (path = Images.dropped(text))
87
+ focused.value.attach(path)
88
+ else
89
+ insert(text)
90
+ end
91
+ :changed
92
+ end
93
+
94
+ def command_query
95
+ return nil unless focused.key == :prompt
96
+ focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
97
+ end
98
+
99
+ def menu
100
+ q = command_query
101
+ return nil if q.nil? || @dismissed == q
102
+ found = SlashCommands.match(commands, q)
103
+ found.empty? ? nil : found
104
+ end
105
+
106
+ def picked = menu&.fetch(@pick.clamp(0, menu.size - 1))
107
+
108
+ def values
109
+ @fields.to_h { |f| [f.key, f.value.to_s] }.tap do |v|
110
+ v[:prompt] = @fields[0].value.expand { |chip| AgentsClient.mention(chip.path) }.strip
111
+ v[:worktree] = v[:worktree] == "yes"
112
+ v[:name] = nil if v[:name].strip.empty?
113
+ v[:cwd] = File.expand_path(v[:cwd].strip.empty? ? "." : v[:cwd].strip)
114
+ end
115
+ end
116
+
117
+ # Settings resolve against the directory the session will run in, so
118
+ # they follow the Directory field.
119
+ def defaults
120
+ cwd = values[:cwd]
121
+ return @defaults if @defaults_for == cwd
122
+ @defaults_for = cwd
123
+ @defaults = Settings.defaults(cwd, home: @home)
124
+ end
125
+
126
+ # Project commands live under the Directory field's path, so they
127
+ # follow it as the defaults do.
128
+ def commands
129
+ cwd = values[:cwd]
130
+ return @commands if @commands_for == cwd
131
+ @commands_for = cwd
132
+ @commands = SlashCommands.list(cwd: cwd, home: @home)
133
+ end
134
+
135
+ # Full-screen body: a tall prompt editor, then one row per setting with
136
+ # every choice visible. Exactly `height` lines.
137
+ def screen(width, height)
138
+ inner_w = width - 4
139
+ fixed = 3 + 2 + 1 + (@fields.size - 1) + 1
140
+ menu_rows = menu_lines(inner_w, [height - fixed - 3, MENU_ROWS].min)
141
+ prompt_h = [height - fixed - menu_rows.size, 3].max
142
+ out = [""]
143
+ out << " " + (@confirm_discard ? @theme.red("Discard this session? (y/n)") : @p.bold("New session"))
144
+ out << ""
145
+ out << " " + field_label(@fields[0]) + @p.dim(" ⏎ newline")
146
+ out += prompt_box(@fields[0], inner_w, prompt_h)
147
+ out += menu_rows
148
+ out << ""
149
+ @fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 14) }
150
+ out.first(height) + [""] * [height - out.size, 0].max
151
+ end
152
+
153
+ def footer
154
+ if @confirm_discard
155
+ return [["y", "discard"], ["esc", "keep editing"]]
156
+ .map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
157
+ end
158
+ return @theme.red(@error) if @error
159
+ return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
160
+ if menu
161
+ return [["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]
162
+ .map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
163
+ end
164
+ keys =
165
+ case focused.kind
166
+ when :multiline then [["⏎", "newline"], ["^V", "image"]]
167
+ when :choice then [["← → h l", "change"], ["⏎", "next"]]
168
+ else [["⏎", "next"]]
169
+ end
170
+ keys += [["^S", "start"], ["^O", "start & open"],
171
+ ["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
172
+ keys.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
173
+ end
174
+
175
+ private
176
+
177
+ # Esc with a prompt typed asks first, so a stray keypress can't lose it.
178
+ def escape_pressed
179
+ return :cancel if @fields[0].value.empty?
180
+ @confirm_discard = true
181
+ :changed
182
+ end
183
+
184
+ def confirm_discard_press(name, raw)
185
+ return :cancel if raw == "y"
186
+ @confirm_discard = false if name == :escape || raw == "n" || raw == "q"
187
+ :changed
188
+ end
189
+
190
+ def paste_clipboard
191
+ clip = @clipboard.call
192
+ if clip.image
193
+ return @error = "images go in the prompt" unless focused.key == :prompt
194
+ focused.value.attach(clip.image)
195
+ elsif clip.text
196
+ insert(clip.text)
197
+ else
198
+ @error = "nothing on the clipboard"
199
+ end
200
+ end
201
+
202
+ def insert(text)
203
+ return unless editable?
204
+ focused.value.insert((focused.kind == :multiline) ? text : text.tr("\n", " "))
205
+ end
206
+
207
+ def menu_press(name)
208
+ case name
209
+ when :up, :ctrl_p then @pick = (@pick - 1) % menu.size
210
+ when :down, :ctrl_n then @pick = (@pick + 1) % menu.size
211
+ when :tab, :return, :enter then accept(picked)
212
+ when :escape then @dismissed = command_query
213
+ else return false
214
+ end
215
+ true
216
+ end
217
+
218
+ def accept(cmd)
219
+ focused.value.replace_before(command_query.grapheme_clusters.size + 1, "#{cmd} ")
220
+ @pick = 0
221
+ end
222
+
223
+ # Scrolled so the pick never falls off the bottom; the count of what is
224
+ # cut rides on the last row rather than costing one of its own.
225
+ def menu_lines(w, h)
226
+ items = menu
227
+ return [] if items.nil? || h <= 0
228
+ pick = @pick.clamp(0, items.size - 1)
229
+ first = [pick - h + 1, 0].max
230
+ shown = items[first, h]
231
+ left = items.size - first - shown.size
232
+ more = (left > 0) ? " +#{left} more" : ""
233
+ name_w = [shown.map { |c| Text.width(c.to_s) }.max, 36].min
234
+ rows = shown.each_with_index.map do |c, i|
235
+ on = first + i == pick
236
+ name = Text.pad(c.to_s, name_w)
237
+ tag = (i == shown.size - 1) ? more.size : 0
238
+ desc = Text.truncate(c.description, w - name_w - 8 - tag)
239
+ " " + (on ? @theme.pill(" #{name} ", :cyan) : @theme.cyan(" #{name} ")) + " " + (on ? desc : @p.dim(desc))
240
+ end
241
+ rows[-1] = Text.pad(rows[-1], w - more.size) + @p.dim(more)
242
+ rows
243
+ end
244
+
245
+ def field_label(f)
246
+ on = f.equal?(focused)
247
+ (on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 12)) : @p.dim(Text.pad(f.label, 12)))
248
+ end
249
+
250
+ # The cell the cursor sits on, drawn as a block by inverting it: a bar
251
+ # between cells would shift everything after it a column to the right.
252
+ def caret = ->(cell) { @p.inverse(cell) }
253
+
254
+ def field_value(f, w)
255
+ on = f.equal?(focused)
256
+ if f.kind == :text
257
+ return f.value.row(w, cursor: caret) if on
258
+ if f.value.empty?
259
+ @p.dim((f.key == :name) ? "(none — claude picks one)" : "")
260
+ else
261
+ f.value.row(w)
262
+ end
263
+ else
264
+ f.choices.map { |c|
265
+ text = (c == DEFAULT) ? default_text(f) : c
266
+ if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
267
+ else @p.dim(" #{text} ")
268
+ end
269
+ }.join(" ")
270
+ end
271
+ end
272
+
273
+ def default_text(f)
274
+ resolved = defaults[f.key]
275
+ resolved ? "#{resolved} (settings)" : "auto (cli default)"
276
+ end
277
+
278
+ def prompt_box(f, w, h)
279
+ on = f.equal?(focused)
280
+ edge = on ? ->(s) { @theme.cyan(s) } : ->(s) { @p.dim(s) }
281
+ rows, hidden = f.value.view(w - 4, h, cursor: (caret if on), chip: ->(s) { @theme.cyan_bold(s) })
282
+ rows = [@p.dim("What should this session do?")] if f.value.empty? && !on
283
+ rows += [""] * (h - rows.size)
284
+ [top_edge(edge, w, hidden)] +
285
+ rows.map { |r| " " + edge.call("│") + " " + Text.pad(r, w - 4) + " " + edge.call("│") } +
286
+ [" " + edge.call("└" + "─" * (w - 2) + "┘")]
287
+ end
288
+
289
+ # How much of a long prompt is scrolled out of sight goes in the top
290
+ # border, where it can't collide with the text or the cursor.
291
+ def top_edge(edge, w, hidden)
292
+ label = (hidden > 0) ? @p.dim(" ↑ #{hidden} more ") : ""
293
+ " " + edge.call("┌" + "─" * (w - 2 - Text.width(label))) + label + edge.call("┐")
294
+ end
295
+
296
+ # Tab in the Directory field: extend the path as far as the matching
297
+ # directories agree, listing them while it is still ambiguous. Returns
298
+ # nil when the path is already a directory or matches nothing, so the
299
+ # caller can treat Tab as "next field" instead.
300
+ def complete_dir
301
+ return nil unless focused.key == :cwd
302
+ typed = focused.value.to_s
303
+ base = File.expand_path(typed.empty? ? "." : typed)
304
+ listing = typed.empty? || typed.end_with?("/")
305
+ return nil if !listing && File.directory?(base)
306
+ matches = Dir.glob(listing ? "#{base}/*" : "#{base}*").select { |d| File.directory?(d) }.sort
307
+ return nil if matches.empty?
308
+ if matches.size == 1
309
+ focused.value.replace("#{matches.first}/")
310
+ else
311
+ @candidates = matches.map { |d| File.basename(d) }
312
+ focused.value.replace(common_prefix(matches))
313
+ end
314
+ :changed
315
+ end
316
+
317
+ def common_prefix(paths)
318
+ first, *rest = paths
319
+ first.each_char.with_index.reduce("") do |acc, (c, i)|
320
+ (rest.all? { |o| o[i] == c }) ? acc + c : (break acc)
321
+ end
322
+ end
323
+
324
+ def editable? = %i[text multiline].include?(focused.kind)
325
+
326
+ def move(d) = @focus = (@focus + d) % @fields.size
327
+
328
+ # Keys a :choice field answers to; an editable field spends these on its
329
+ # own text instead.
330
+ def choose(name, raw)
331
+ case name
332
+ when :left then cycle(-1)
333
+ when :right then cycle(1)
334
+ else
335
+ case raw
336
+ when "h" then cycle(-1)
337
+ when "l", " " then cycle(1)
338
+ end
339
+ end
340
+ end
341
+
342
+ def cycle(d)
343
+ f = focused
344
+ return unless f.kind == :choice
345
+ f.value = f.choices[(f.choices.index(f.value) + d) % f.choices.size]
346
+ end
347
+
348
+ def submit(attach:)
349
+ v = values
350
+ if v[:prompt].empty?
351
+ @error = "a prompt is required"
352
+ @focus = 0
353
+ return :changed
354
+ end
355
+ unless File.directory?(v[:cwd])
356
+ @error = "no such directory: #{v[:cwd]}"
357
+ @focus = 2
358
+ return :changed
359
+ end
360
+ attach ? :start_and_attach : :start
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ # The eight colors `/color` offers, mapped to the terminal the same way
5
+ # Claude Code's own tmux code maps them when it tints a teammate pane:
6
+ # six are plain ansi, and orange and pink have no ansi name so they go
7
+ # through a 256-color index.
8
+ #
9
+ # Closing with 39 (default foreground) rather than 0 (reset everything)
10
+ # matters: the label is often already bold or italic and a full reset
11
+ # would strip that back off.
12
+ class Palette
13
+ ANSI = {
14
+ "red" => 31, "green" => 32, "yellow" => 33,
15
+ "blue" => 34, "purple" => 35, "cyan" => 36
16
+ }.freeze
17
+
18
+ INDEXED = {"orange" => 208, "pink" => 205}.freeze
19
+
20
+ RESET = "\e[39m"
21
+
22
+ def self.known?(name) = ANSI.key?(name) || INDEXED.key?(name)
23
+
24
+ def self.sequence(name)
25
+ return "\e[#{ANSI[name]}m" if ANSI.key?(name)
26
+ return "\e[38;5;#{INDEXED[name]}m" if INDEXED.key?(name)
27
+ nil
28
+ end
29
+
30
+ def initialize(enabled: true)
31
+ @enabled = enabled
32
+ end
33
+
34
+ def paint(text, name)
35
+ return text unless @enabled
36
+ seq = self.class.sequence(name)
37
+ seq ? seq + text + RESET : text
38
+ end
39
+ end
40
+ end