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,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
|