claude-inbox 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cea52c058efc847eede80d9633f507fb0bcc88352351d2e8df98d57d531cbd57
4
- data.tar.gz: 73953113b090f01dfd646bbcb9295be96d2f6616ecccab116b9f75c2f43f69ed
3
+ metadata.gz: b432b1272fd3e3b923eaefe34fa4e3c30cbd9dfcf57d4beacd761f1cef70785b
4
+ data.tar.gz: '090ff2d41f9701cc7476196af19e4604241b3b3079376a896b441ab9dbf0ce9d'
5
5
  SHA512:
6
- metadata.gz: '080712923012ed2b164ce10dc6e4fbbf511319b7065ee54da07ffac2a6561deb62888f73012d8b7bf1ed3a78ae2d8fae078680fd4102a962bb4a05e345346c77'
7
- data.tar.gz: dd9a7ca33067f30d0eb8cb98742bcc229cb981acfdfa0697966d59b43be9cf4c2451a6683378f6389b3226410261d615b9c5fb711df9c1019e659863ca76796c
6
+ metadata.gz: a075eee59befc0de0e667d5b4325db8086a457040d544534f75b62d8a05b91a5e703b370bd1f3ce52fe89638cefa93403b1d6acd7170f143a86111a9d4c75277
7
+ data.tar.gz: 1d3dbb6ead15764e91834af2de5b8cfa9873493830e0cf2cee6080a4884fe3d74476b2a260b47565f6e70c99f5611d9c57bb422950435b80cc90b12eb55bc859
data/README.md CHANGED
@@ -73,10 +73,8 @@ Vim bindings. Arrows and the mouse work too.
73
73
  | `Ctrl-d` `Ctrl-u` | half page down / up (`Ctrl-f` `Ctrl-b` full page) |
74
74
  | `Enter` `l` | attach (`←` or `Ctrl+Z` returns here), adopt a remote session, or expand Snoozed / Settled |
75
75
  | `Tab` `Shift+Tab` | next / previous section |
76
- | `h` | close the peek pane, else collapse the current fold |
76
+ | `h` | collapse the current fold |
77
77
  | `za` `zo` `zc` | toggle / open / close the fold under the cursor |
78
- | `p` | toggle the read-only peek pane |
79
- | `J` `K` (`Ctrl-e` `Ctrl-y`) | scroll the peek pane |
80
78
  | `n` | new session |
81
79
  | `N` | pair a phone |
82
80
  | `t` | pin / unpin |
@@ -27,13 +27,6 @@ module ClaudeInbox
27
27
  classify_origins(parse(r.out))
28
28
  end
29
29
 
30
- # Raw terminal replay for a session, or nil when the daemon can't serve it
31
- # (finished sessions whose process is gone report "job not found").
32
- def logs(id)
33
- r = Subprocess.capture(@bin, "logs", id)
34
- (r.success? && !r.out.empty?) ? r.out : nil
35
- end
36
-
37
30
  # Poll interval for the agents-view watchdog below.
38
31
  WATCH_INTERVAL = 0.05
39
32
 
@@ -259,10 +252,9 @@ module ClaudeInbox
259
252
 
260
253
  # Reads a committed JSON fixture instead of the daemon.
261
254
  class FixtureClient < AgentsClient
262
- def initialize(path, logs: nil, origins: {}, bridges: {})
255
+ def initialize(path, origins: {}, bridges: {})
263
256
  super(jobs_dir: File.join(File.dirname(path), "jobs"))
264
257
  @path = path
265
- @logs = logs
266
258
  @origins = origins
267
259
  @bridges = bridges
268
260
  end
@@ -274,8 +266,6 @@ module ClaudeInbox
274
266
  assign_origins(parse(File.read(@path)), @origins, @bridges)
275
267
  end
276
268
 
277
- def logs(_id) = @logs
278
-
279
269
  # Stand-in child: prints, waits for a line, exits — enough to prove the
280
270
  # terminal round-trips through cooked mode and back.
281
271
  def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # Handing the terminal to a session and getting it back, and asking the
6
+ # daemon to stop or delete one.
7
+ module Attaching
8
+ private
9
+
10
+ def activate
11
+ return set_expanded(true) if @selected&.fold?
12
+ return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.remote?
13
+ attach(@selected.key) if require_actionable
14
+ end
15
+
16
+ # Guard for attach/stop: refuse politely on a terminal or remote row.
17
+ def require_actionable
18
+ s = selected_session
19
+ return true if s&.actionable?
20
+ if s&.interactive?
21
+ notice(s.remote? ? "that's a remote session — Enter adopts it, w opens it at claude.ai/code" : "that's your own terminal — switch to that window")
22
+ end
23
+ false
24
+ end
25
+
26
+ # The pane in the attached session may ask for a different one: the
27
+ # attach ends and the next starts without passing through the list. The
28
+ # poller keeps running meanwhile, so the pane's snapshot stays fresh. A
29
+ # failed attach leaves its reason on screen until a key is pressed.
30
+ def attach(id)
31
+ while id
32
+ @store.acknowledge(id)
33
+ @switch.clear
34
+ from = session_for(id)&.session_id
35
+ @terminal.release do
36
+ @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
37
+ end
38
+ id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
39
+ end
40
+ end
41
+
42
+ def adopt_session(key)
43
+ s = session_for(key)
44
+ return unless s&.remote?
45
+ notice("adopting #{s.display_name}…")
46
+ in_background do
47
+ id = @client.adopt(session_id: s.session_id, cwd: s.cwd, pid: s.pid)
48
+ @queue << [:notice, "adopted as #{id}"]
49
+ @queue << [:select, id]
50
+ @queue << [:attach, id]
51
+ end
52
+ end
53
+
54
+ def open_confirm(kind)
55
+ return unless require_actionable
56
+ @modal = Dialog::Confirm.new(kind, @selected.key)
57
+ end
58
+
59
+ def stop_session(id)
60
+ in_background do
61
+ @client.stop(id)
62
+ @poller.soon
63
+ end
64
+ end
65
+
66
+ def delete_session(id)
67
+ notice("deleting #{id}…")
68
+ in_background do
69
+ @client.rm(id)
70
+ @store.forget(id)
71
+ @queue << [:notice, "deleted #{id}"]
72
+ @poller.soon
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # o opens the selected session's pull request and w its claude.ai/code
6
+ # page, through whatever opens URLs on this platform.
7
+ module Browsing
8
+ private
9
+
10
+ def open_pr
11
+ pr = selected_session&.pr
12
+ return notice("no pull request linked — P sets one") unless pr
13
+ open_in_browser(pr.url, pr.short)
14
+ end
15
+
16
+ def open_remote
17
+ url = selected_session&.remote_url
18
+ return notice("no claude.ai/code page for this session") unless url
19
+ open_in_browser(url, "claude.ai/code")
20
+ end
21
+
22
+ def open_in_browser(url, what)
23
+ opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
24
+ notice("opening #{what}")
25
+ in_background { Subprocess.capture(opener, url) }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # The `/` filter line and the sections that match it.
6
+ module Filtering
7
+ private
8
+
9
+ def filter_editing? = @filter_editing
10
+
11
+ def filter = @filter
12
+
13
+ def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
14
+
15
+ def start_filter
16
+ @filter ||= TextBuffer.new
17
+ @filter_editing = true
18
+ end
19
+
20
+ def clear_filter
21
+ @filter = nil
22
+ @filter_editing = false
23
+ end
24
+
25
+ def handle_line_key(name, key)
26
+ case name
27
+ when :escape then clear_filter
28
+ when :return, :enter then @filter_editing = false
29
+ when :backspace, :ctrl_h
30
+ @filter.empty? ? clear_filter : @filter.press(name, key)
31
+ else
32
+ @filter.press(name, key)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # Where the cursor is in the sectioned, foldable list and how it moves.
6
+ # The only writer of the selection; the rest of App reads it.
7
+ module Navigating
8
+ private
9
+
10
+ # Remembers where the frame put the rows, so a click can find one.
11
+ def placed(frame)
12
+ @row_items = frame.items
13
+ @top = frame.top
14
+ end
15
+
16
+ # The row to land on once a poll lists it, as after a spawn.
17
+ def select_later(id)
18
+ @pending_select = id
19
+ end
20
+
21
+ def ensure_selection(sections)
22
+ stops = sections.selections(@expanded)
23
+ pending = @pending_select && Store::Selection.row(@pending_select)
24
+ if pending && stops.include?(pending)
25
+ select(pending)
26
+ @pending_select = nil
27
+ return
28
+ end
29
+ return if stops.include?(@selected)
30
+ select(stops.first)
31
+ end
32
+
33
+ def select(selection)
34
+ @selected = selection
35
+ end
36
+
37
+ def session_for(key) = @store.sessions.find { |s| s.key == key }
38
+
39
+ def selected_session = session_for(@selected&.key)
40
+
41
+ def page = [Renderer.body_height(@terminal.size[1]), 1].max
42
+
43
+ def move(delta)
44
+ stops = filtered.selections(@expanded)
45
+ return if stops.empty?
46
+ idx = stops.index(@selected) || 0
47
+ select(stops[(idx + delta).clamp(0, stops.size - 1)])
48
+ end
49
+
50
+ # Tab / Shift-Tab: first selectable row of the next / previous section.
51
+ def jump_section(dir)
52
+ sections = filtered
53
+ heads = sections.heads(@expanded)
54
+ return if heads.empty?
55
+ current = sections.section_of(@selected)
56
+ idx = heads.index { |name, _| name == current } || -1
57
+ select(heads[(idx + dir) % heads.size].last)
58
+ end
59
+
60
+ # The foldable section the cursor is currently on or inside, if any.
61
+ def current_fold_section
62
+ name = filtered.section_of(@selected)
63
+ name if Store::FOLDABLE_SECTIONS.include?(name)
64
+ end
65
+
66
+ def set_expanded(value)
67
+ name = current_fold_section
68
+ @expanded[name] = value if name
69
+ end
70
+
71
+ def toggle_expanded = set_expanded(!@expanded[current_fold_section])
72
+
73
+ # Clicking a row selects it and attaches, same as landing on it with
74
+ # j/k and pressing Enter — activate already knows how to expand a fold
75
+ # or refuse a terminal/remote row, so this doesn't repeat that.
76
+ def click_row(row)
77
+ item = row_item_at(row)
78
+ return unless item
79
+ select(item)
80
+ activate
81
+ end
82
+
83
+ # The wrapped detail line under a two-line row ("↳ ~/code/x") carries
84
+ # no item of its own; a click there resolves to the row above it.
85
+ def row_item_at(row)
86
+ idx = row - 1
87
+ @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # Starting a session, from the `n` form here or from another device.
6
+ module Spawning
7
+ private
8
+
9
+ def open_new_session
10
+ cwd = selected_session&.cwd || Dir.pwd
11
+ dirs = SessionRequest.directories(@store.sessions, @trust.call)
12
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
13
+ end
14
+
15
+ # `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
16
+ # closes itself once the spawn is confirmed to have started (`:form_started`)
17
+ # or reopens with the failure and the prompt intact (`:form_failed`).
18
+ def handle_form_key(name, key)
19
+ form = @modal
20
+ case form.press(name, key)
21
+ when :cancel then @modal = nil
22
+ when :start then start_session(form, attach: false)
23
+ when :start_and_attach then start_session(form, attach: true)
24
+ end
25
+ end
26
+
27
+ # `attach:` hands the terminal over as soon as the session starts. Without
28
+ # it we stay in the inbox and poll, so the new row shows up right away
29
+ # rather than at the next tick.
30
+ def start_session(form, attach:)
31
+ v = form.values
32
+ notice("starting session…")
33
+ in_background do
34
+ id = @client.spawn(**v)
35
+ @queue << [:notice, "started #{id}"]
36
+ @queue << [:form_started, form]
37
+ @queue << [:select, id]
38
+ attach ? @queue << [:attach, id] : @poller.soon
39
+ rescue AgentsClient::Error => e
40
+ @queue << [:form_failed, form, e.message]
41
+ end
42
+ end
43
+
44
+ def form_started(form)
45
+ @modal = nil if @modal.equal?(form)
46
+ end
47
+
48
+ # A spawn failure hands the form back rather than just logging it, so
49
+ # the composed prompt survives (see NewSessionForm#submission_failed).
50
+ def form_failed(form, message)
51
+ @modal.equal?(form) ? form.submission_failed(message) : (@error = message)
52
+ end
53
+
54
+ # Someone at the desk may be mid-thought, so a start from another
55
+ # device never moves the cursor, attaches or closes what is open.
56
+ def remote_started(id, via)
57
+ notice("started #{id} from #{via}")
58
+ @poller.soon
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # The edits of the selected row that live in our own store, and the two
6
+ # guards they share.
7
+ module Triaging
8
+ private
9
+
10
+ # Guard for snooze/wake/alias: anything with a key, since those live in
11
+ # our own store. A terminal you are sitting in is the one exception.
12
+ def require_storable
13
+ s = selected_session
14
+ return true if @selected&.row? && !s&.terminal?
15
+ notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
16
+ false
17
+ end
18
+
19
+ # The edits with no request form, which a second inbox cannot keep.
20
+ def require_writable(verb)
21
+ return true if @store.writable?
22
+ notice("another claude-inbox owns state.json — #{verb} there")
23
+ false
24
+ end
25
+
26
+ def wake_selected
27
+ @store.wake(@selected.key) if require_storable
28
+ end
29
+
30
+ def toggle_pin_selected
31
+ @store.toggle_pin(@selected.key) if require_storable
32
+ end
33
+
34
+ def settle_selected
35
+ return unless require_storable
36
+ @store.settle(@selected.key)
37
+ notice("settled — u brings it back")
38
+ end
39
+
40
+ def open_snooze_menu
41
+ return unless require_storable
42
+ @modal = Dialog::Snooze.new(@selected.key)
43
+ end
44
+
45
+ def snooze_chosen
46
+ @store.snooze(@modal.id, @modal.choice)
47
+ @modal = nil
48
+ end
49
+
50
+ def open_alias_editor
51
+ return unless require_storable && require_writable("rename")
52
+ current = @store.alias_for(@selected.key) || ""
53
+ @modal = Dialog::Prompt.new(:alias, @selected.key, current)
54
+ end
55
+
56
+ def open_pr_editor
57
+ return unless require_storable && require_writable("link a pull request")
58
+ current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
59
+ @modal = Dialog::Prompt.new(:pr, @selected.key, current)
60
+ end
61
+
62
+ def save_prompt
63
+ value = @modal.value.strip
64
+ if @modal.kind == :alias
65
+ @store.set_alias(@modal.id, value)
66
+ elsif value.empty? || PullRequests.valid_url?(value)
67
+ @store.set_pr(@modal.id, value)
68
+ @poller.soon
69
+ else
70
+ return notice("that's not a github.com pull request url")
71
+ end
72
+ @modal = nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # The newer-release check, the header's word on it, and U to install
6
+ # and relaunch.
7
+ module Updating
8
+ private
9
+
10
+ # Once, on the first step: a stale cache means a network round trip, so
11
+ # it runs off the main thread. A laptop that is offline stays quiet
12
+ # rather than show an error at every launch; U says what went wrong.
13
+ def ensure_update_checked
14
+ return if @update_status
15
+ @update_status = @updater ? "checking for updates…" : "update check is off — claude-inbox update installs the latest"
16
+ return unless @updater
17
+ in_background do
18
+ @queue << [:update, @updater.check]
19
+ rescue Updater::Error => e
20
+ @queue << [:update, nil, e.message]
21
+ end
22
+ end
23
+
24
+ def update_checked(update, failure = nil)
25
+ @update = update
26
+ @snapshot.update = update&.notice
27
+ @update_status = failure ? "couldn't check for updates: #{failure}" : "up to date"
28
+ end
29
+
30
+ # The header's line once a newer release is known; nil until then.
31
+ def update_notice
32
+ "#{@update.notice} · U updates" if @update
33
+ end
34
+
35
+ # The install prints on the real screen, and the relaunch replaces this
36
+ # process. A failed install relaunches this version instead, once its
37
+ # error has been read, rather than leave the shell showing.
38
+ def update_inbox
39
+ return notice(@update_status) unless @update
40
+ @terminal.release do
41
+ @out.puts @update.installing
42
+ applied = @updater.apply
43
+ @out.puts applied.output unless applied.output.empty?
44
+ @terminal.pause unless applied.success?
45
+ relaunch
46
+ end
47
+ end
48
+
49
+ def relaunch
50
+ @updater.relaunch(@argv)
51
+ rescue SystemCallError => e
52
+ @error = "relaunch failed: #{e.message}"
53
+ end
54
+ end
55
+ end
56
+ end