dopairb 0.2.0 → 0.5.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: 83be2be298c392a6739e1fdef9293579796f7d80dd3a5b3aaccc65cf34f60d4c
4
- data.tar.gz: 8afedc6a382a671a5e3dc13720f1934599757cadd6aacb28b88b69cd1986b0ac
3
+ metadata.gz: afb5e40866c1cdf891ccf500611c568c91f693715f120e69822088e7bdcffdf3
4
+ data.tar.gz: 25b611a5ac21351f1f9adf5fd811cbc6c8422651dd7cab94dbb878d68d25930d
5
5
  SHA512:
6
- metadata.gz: 7aed8c09308468b4e3ed6e992cba5d4245d349c66bf88abfa37a4d6a214a1f457dd4610db944f49e5d53c1e024ebfc4bed7bf82fa81ef319d65e158547139633
7
- data.tar.gz: 2c6609aeb7f8939fe95c0278592bf45cc67f68eb52d715e3527837074ecea19dda912505d630715e2a5ef760d630b33a3b778f07a39faba81aef5def8fbe1119
6
+ metadata.gz: 46b1c5c6e4b4450e0297b21d70a24da9ad379603512de745e208837a57a79f7b004eed7c5a748261c78052b1bb1aa44c564595d599552d3471c5055d5182a871
7
+ data.tar.gz: 8979f157991d9e6451e7ceb8df3fc480a86ee39bd350f2afe363d5c87ccc187e62dc729eefab57deaf3aaa6f975aee6e37a6f948cd7f7353b3fef574ee09e406
data/README.md CHANGED
@@ -3,6 +3,10 @@
3
3
  An interactive Ruby shell that answers every keystroke, evaluation, result and exception with loud terminal effects.
4
4
  It is built as an IRB extension. The design spec is [spec.md](spec.md) (in Japanese).
5
5
 
6
+ https://github.com/user-attachments/assets/98b037e1-5a30-4abd-812d-233a60ab2de4
7
+
8
+ A 72-second promo with sound and English/Japanese subtitles: plain irb, then `dopa on`, the setup, and "Happy dopa Ruby!". The scripts that record dopairb sessions like this one are in [docs/recording](docs/recording).
9
+
6
10
  ```
7
11
  dopairb(main):001> (1..100).sum COMBO 00 SCORE 33 [⣿⣿⣀⣀⣀⣀⣀⣀] x1.5
8
12
  ⠂⠌⠟⠃ <- sparks, afterglow and HUD on every keystroke
@@ -29,30 +33,41 @@ To try it without installing, run `ruby exe/dopairb` in the checkout.
29
33
  ## Usage
30
34
 
31
35
  ```console
32
- $ dopairb # start it (IRB options are passed through)
36
+ $ dopairb # start it, at full blast with sound (IRB options are passed through)
33
37
  $ dopairb --calm # quieter: low intensity, no flash, shorter effects
34
- $ dopairb --max # everything at maximum; the biggest moments go full screen
35
38
  $ dopairb --party # max + full-screen flash + sound effects
36
- $ dopairb --sound # synthesized sound effects (--sound=bell for the terminal bell)
39
+ $ dopairb --intensity=normal --no-sound # the tamer pre-0.4.0 defaults
37
40
  $ dopairb --no-flash # suppress flashing, available from the very first start
38
41
  $ DOPAIRB="intensity=low,flash=off" dopairb
39
42
  ```
40
43
 
41
- To enable it in your regular `irb`, add this to `~/.irbrc`:
44
+ To turn your regular `irb` into dopairb, add this to `~/.irbrc`:
42
45
 
43
46
  ```ruby
44
- require "dopairb"
45
- Dopairb.enable # also e.g. Dopairb.enable(flash: :off, intensity: :low)
47
+ begin
48
+ require "dopairb" # adds the `dopa` command; `dopa on` starts the show
49
+ Dopairb.enable # ...or start with the show on (e.g. Dopairb.enable(sound: :off))
50
+ rescue LoadError
51
+ # a Ruby without dopairb keeps plain irb
52
+ end
46
53
  ```
47
54
 
55
+ With only the `require`, irb stays plain until you type `dopa on`.
56
+ The title (and the loading screen on a cold start) plays right before the first prompt, as with the `dopairb` command.
57
+ Sessions opened by `binding.irb` get the effects too; `Dopairb.enable(binding_irb: false)` keeps them plain.
58
+ Settings for a single run (`dopairb` options and `$DOPAIRB`) win over the ones given to `Dopairb.enable` in `.irbrc`.
59
+
48
60
  Inside a session, the `dopa` command changes settings:
49
61
 
50
62
  ```
51
63
  dopa show settings and session stats
64
+ dopa on / dopa off switch the show on (back to the last level) or off
52
65
  dopa off|low|normal|max
53
66
  dopa calm / dopa party
54
67
  dopa demo play every effect once (does not change your score)
55
68
  dopa loading show the loading screen
69
+ dopa gallery the bonus art you have collected (`dopa gallery starry_night` shows one)
70
+ dopa gallery tour a full-screen slideshow of all 100; uncollected ones appear as silhouettes
56
71
  dopa flash=off duration=0.5 ...
57
72
  ```
58
73
 
@@ -60,10 +75,10 @@ dopa flash=off duration=0.5 ...
60
75
 
61
76
  | Setting | Values | Default | Meaning |
62
77
  | --- | --- | --- | --- |
63
- | `intensity` | off / low / normal / max | normal | Overall amount. `off` behaves exactly like plain IRB |
78
+ | `intensity` | off / low / normal / max | max | Overall amount. `off` behaves exactly like plain IRB |
64
79
  | `motion` | on / off | on | `off` disables animation (badges only) |
65
80
  | `flash` | off / soft / full | soft | `soft` flashes only the effect area, `full` inverts the whole screen |
66
- | `sound` | off / bell / sfx | off | `sfx` plays synthesized sound effects, `bell` rings the terminal bell on big moments |
81
+ | `sound` | off / bell / sfx | sfx | `sfx` plays synthesized sound effects, `bell` rings the terminal bell on big moments |
67
82
  | `hud` | on / off | on | COMBO / SCORE / CHARGE on the right of the prompt line |
68
83
  | `duration` | 0.1–5 | 1.0 | Length multiplier for every effect |
69
84
  | `color` | auto / truecolor / 256 / 16 / none | auto | `none` when `NO_COLOR` is set |
@@ -71,6 +86,7 @@ dopa flash=off duration=0.5 ...
71
86
  | `charge` | 0–0.3 s | 0.12 | Wind-up before a fast result lands; 0 disables it |
72
87
  | `intro` | on / off | on | Title animation at startup |
73
88
  | `keys` | on / off | on | Per-keystroke effects |
89
+ | `binding_irb` | on / off | on | Effects in sessions opened by `binding.irb` too |
74
90
 
75
91
  ## Effects
76
92
 
@@ -80,6 +96,7 @@ When several fire at once, only the biggest plays. The smaller ones are folded i
80
96
  | Moment | Effect |
81
97
  | --- | --- |
82
98
  | Typing | A spark trail right of the cursor, a brief glow on the typed character, embers falling below, a x1.0–x3.0 multiplier |
99
+ | Typing streak of 8, 16, 32 … 1024 keys | Powers of two, in big half-block letters under the input: `8-BIT RUSH!`, `16-BIT COMBO!`, `32-BIT BLAZE!!`, `64-BIT OVERDRIVE!!`, `128-BIT HYPER MODE!!`, `0x100 OVERFLOW!!!`, `2^9 GODSPEED!!!`, `1KiB TYPING LEGEND!!!`, with a bonus of 4 points per key |
83
100
  | Resuming after a pause | Reignites with `CHARGE!` |
84
101
  | Backspace / Delete | The deleted character shatters and falls. The next keystroke shows `RECOVERY` |
85
102
  | Cursor movement | A light streak in the direction of travel |
@@ -104,12 +121,32 @@ When several fire at once, only the biggest plays. The smaller ones are folded i
104
121
  | Other exceptions | A red flash and a glitching `FAILED` |
105
122
  | Ctrl-C | The stored charge scatters, `INTERRUPTED` |
106
123
  | First eval / COMBO 5, 10, 25… / eval count 10, 50, 100… | `FIRST HIT!` / `COMBO 10!` / `100 EVALS` |
124
+ | Lucky success (about 1 in 10, up to 1 in 5 with a full CHARGE) | `CRITICAL!!`: a slash, a red flash and x2 / x4 / x8 points |
125
+ | Very lucky success (1 in 67) | `JACKPOT!!`: three slot reels stop on 7-7-7, coins rain, x16 points |
126
+ | COMBO 10 and beyond | `FEVER TIME!`: every point counts double until the next error; the HUD glows in rainbow `FEVER!` |
127
+ | Beating your all-time best combo | `BEST COMBO!` |
128
+ | Level up, jackpot, or a lucky drop (see [Career](#career)) | `LEVEL UP!` / `JACKPOT PRIZE` / `ART DROP!`, then a famous painting is unveiled full screen as bonus art |
107
129
  | Numeric record (more than doubled) | `NEW RECORD` |
108
130
  | Success right after an error | `FIXED!` (after one error) / `COMEBACK!` (after a streak), both huge |
109
131
  | Exit | A full-screen finale: the title drops, stats slam in one by one, the total score counts up under a drum roll, a rank is stamped (S/A/B/C with a cheerful title), then fireworks. The result is left behind as a plain table |
110
132
 
111
133
  Error effects name the kind of error. They never make fun of the failure.
112
134
 
135
+ ## Career
136
+
137
+ Between sessions dopairb keeps a small profile: lifetime XP (the sum of your scores), level, best score, best combo, a day streak and the bonus art you have collected.
138
+ Levels need powers of two: LV 2 at 512 XP, LV 3 at 1,024, LV 4 at 2,048, and so on.
139
+ Each level-up and each jackpot unveils a masterpiece full screen, and now and then (about 1 success in 50) one simply drops: `ART DROP!`. There are 100 public-domain works to collect, from the Lascaux horses and Hokusai's *Great Wave* through Vermeer, Turner and van Gogh to Klimt, Kandinsky and Mondrian, all painted procedurally in half blocks (no image files ship with the gem). New ones come first; `dopa gallery` lists yours.
140
+
141
+ ![100 frames, five of them filled](docs/gallery-teaser.png)
142
+
143
+ The startup title shows your level and day streak, and the finale shows `NEW BEST!` and an XP bar.
144
+
145
+ The profile is `~/.local/state/dopairb/profile.json` (`$XDG_STATE_HOME` is honored). Set `DOPAIRB_PROFILE=path` to use another file, or `DOPAIRB_PROFILE=off` to keep nothing; levels are then not shown.
146
+ Non-interactive runs never touch it.
147
+
148
+ `DOPAIRB_SEED=n` makes the luck (critical hits, jackpots, particles) repeatable, which helps when recording a demo.
149
+
113
150
  ## Sound
114
151
 
115
152
  With `sound=sfx`, every effect has a sound: key clicks, `NICE!` chimes, a comet ping, a stamp thump, a count-up that ends in a boom, fanfares and explosions for the big moments, a buzz or crack for errors.
@@ -129,6 +166,37 @@ PowerShell takes a few hundred milliseconds to start, so with it only the result
129
166
  When no player is found, `sfx` falls back to the terminal bell on big moments.
130
167
  Sound plays on the machine that runs dopairb, so over ssh you will not hear it.
131
168
 
169
+ ## Claude Code (dopacc)
170
+
171
+ The gem also ships `dopacc`, which puts the show on top of [Claude Code](https://claude.com/claude-code):
172
+
173
+ https://github.com/user-attachments/assets/458acc90-72ba-4a81-a352-082cde2096d5
174
+
175
+ ```
176
+ gem install dopairb
177
+ dopacc install # adds hooks + a status line to ~/.claude/settings.json
178
+ ```
179
+
180
+ Then restart Claude Code. From then on:
181
+
182
+ - Every successful tool call adds to the COMBO with a chime. At 10 and above it is FEVER (x2 score).
183
+ - At COMBO 8, 16, 32, ... a full-screen `COMBO 16!` banner with fireworks takes over the screen for two seconds.
184
+ - A tool call has a 1/64 chance of a `JACKPOT!!` (x16).
185
+ - A failed tool call breaks the combo with a crack.
186
+ - When Claude finishes a turn that used tools, a full-screen `MISSION COMPLETE!` shows the turn's tools, score, combo and best.
187
+ - The status line shows `COMBO / SCORE / BEST` (an existing `statusLine` is left alone).
188
+
189
+ ```
190
+ dopacc off / calm / on # nothing / sounds only / everything (inside Claude Code: ! dopacc off)
191
+ dopacc status
192
+ dopacc demo # ! dopacc demo inside Claude Code shows a banner right away
193
+ dopacc uninstall
194
+ ```
195
+
196
+ `dopacc install --project` writes to `./.claude/settings.local.json` instead. `DOPACC=off` in the environment silences it too.
197
+
198
+ How it works: Claude Code owns the terminal and its hooks run without one. So a hook walks up the process tree to Claude Code's tty, draws the banner on it, and then has Claude Code repaint. With the fullscreen TUI (`"tui": "fullscreen"`), it draws over Claude Code's screen and makes it lay itself out again by narrowing the window by one column for a moment. With the classic TUI, it draws on the alternate screen and switches back. Progress is kept in `~/.local/state/dopairb/dopacc.json`. Hooks never fail: whatever happens, they exit 0 silently (`DOPACC_DEBUG=1` prints errors).
199
+
132
200
  ## Readable, never broken
133
201
 
134
202
  - IRB evaluates and prints as usual. `=> value`, exception messages and backtraces come from IRB itself.
@@ -172,7 +240,7 @@ The rest is terminal-independent logic:
172
240
  - Scores live for the session only and are not saved.
173
241
  - Effects use Unicode. Any glyph that is not one column wide is swapped for ASCII or braille at runtime.
174
242
  - Start it with the `dopairb` command, or call `Dopairb.enable` in `.irbrc`.
175
- - IRB sessions entered via `binding.irb` get the same Context hook. The result screen appears only when the outermost session ends.
243
+ - IRB sessions entered via `binding.irb` get the same Context hook (unless `binding_irb=off`; detected with `IRB::Context#from_binding?`). The result screen appears only when the outermost session ends.
176
244
 
177
245
  ## Performance
178
246
 
data/exe/dopacc ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # dopacc: dopairb's show on top of Claude Code.
5
+ require_relative "../lib/dopairb/claude_code"
6
+
7
+ CC = Dopairb::ClaudeCode
8
+
9
+ def settings_path(args)
10
+ if (a = args.find { |x| x.start_with?("--settings=") })
11
+ a.delete_prefix("--settings=")
12
+ elsif args.include?("--project")
13
+ File.join(Dir.pwd, ".claude", "settings.local.json")
14
+ else
15
+ File.join(ENV["CLAUDE_CONFIG_DIR"] || File.expand_path("~/.claude"), "settings.json")
16
+ end
17
+ end
18
+
19
+ USAGE = <<~TXT
20
+ Usage: dopacc COMMAND
21
+
22
+ install [--project] [--no-statusline] add the hooks to Claude Code's settings
23
+ (~/.claude/settings.json, or ./.claude/settings.local.json)
24
+ uninstall [--project] remove them again
25
+ on | calm | off full show / sounds only / nothing
26
+ status mode, combo and score
27
+ demo [TEXT] try the full-screen banner (run it as `! dopacc demo` in Claude Code)
28
+ hook (called by Claude Code with the hook JSON on stdin)
29
+ statusline (called by Claude Code for the status line)
30
+ TXT
31
+
32
+ cmd, *args = ARGV
33
+ case cmd
34
+ when "hook"
35
+ # Never get in Claude Code's way: whatever happens, exit 0 quietly.
36
+ begin
37
+ plan = CC::Hook.run($stdin.read)
38
+ if ENV["DOPACC_DRY"]
39
+ puts JSON.generate(plan)
40
+ else
41
+ plan.each do |e|
42
+ if e[:show]
43
+ CC::Screen.show(e[:show], sub: e[:sub], palette: e[:palette], sound: e[:sound])
44
+ elsif e[:sound]
45
+ CC::Screen.play(e[:sound])
46
+ end
47
+ end
48
+ end
49
+ rescue StandardError, LoadError => e
50
+ warn "dopacc: #{e.class}: #{e.message}" if ENV["DOPACC_DEBUG"]
51
+ end
52
+ exit 0
53
+ when "statusline"
54
+ $stdin.read unless $stdin.tty?
55
+ print CC.statusline
56
+ when *CC::MODES
57
+ CC::State.update { |s| s["mode"] = cmd }
58
+ puts "dopacc: #{cmd}"
59
+ when "status"
60
+ s = CC::State.read
61
+ puts "mode #{s["mode"]} combo #{s["combo"]} best #{s["best"]} score #{CC::Hook.commas(s["score"])} tools #{s["tools"]}"
62
+ puts "hooks #{CC::Installer.installed?(settings_path(args)) ? "installed" : "not installed"} (#{settings_path(args)})"
63
+ when "install"
64
+ path = settings_path(args)
65
+ notes = CC::Installer.install(path, statusline: !args.include?("--no-statusline"))
66
+ puts "dopacc: hooks added to #{path}"
67
+ notes.each { |n| puts " #{n}" }
68
+ puts " restart Claude Code (or open /hooks) to start the show; `dopacc off` to pause it"
69
+ when "uninstall"
70
+ path = settings_path(args)
71
+ CC::Installer.uninstall(path)
72
+ puts "dopacc: hooks removed from #{path}"
73
+ when "demo"
74
+ shown = CC::Screen.show(args.empty? ? "DOPA CLAUDE!" : args.join(" "), sub: "dopacc demo")
75
+ warn "dopacc: no terminal found (run it from inside Claude Code: ! dopacc demo)" unless shown
76
+ when nil, "-h", "--help", "help"
77
+ puts USAGE
78
+ when "--version"
79
+ require_relative "../lib/dopairb/version"
80
+ puts "dopacc (dopairb #{Dopairb::VERSION})"
81
+ else
82
+ warn USAGE
83
+ exit 2
84
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Dopairb
7
+ # dopacc: the dopairb show on top of Claude Code, driven by its hooks.
8
+ #
9
+ # Claude Code owns the terminal, so a hook finds Claude Code's tty by
10
+ # walking up the process tree, draws a full-screen banner on it and then
11
+ # makes Claude Code lay its screen out again by wiggling the window size.
12
+ # Everything here is cheap to load; the effects (`require "dopairb"`) are
13
+ # only loaded when something is actually drawn or played.
14
+ module ClaudeCode
15
+ MODES = %w[on calm off].freeze
16
+ HOOK_EVENTS = %w[PostToolUse PostToolUseFailure Stop].freeze
17
+ JACKPOT_CHANCE = 1 / 64.0
18
+ FEVER_COMBO = 10
19
+ DONE = ["MISSION COMPLETE!", "DONE!", "NICE WORK!", "GG!", "CLEAR!"].freeze
20
+
21
+ # Shared progress in a JSON file, updated under an exclusive lock
22
+ # because hooks of parallel tool calls run at the same time.
23
+ module State
24
+ DEFAULTS = { "mode" => "on", "session" => nil, "combo" => 0, "best" => 0, "score" => 0,
25
+ "tools" => 0, "turn_tools" => 0, "turn_score" => 0 }.freeze
26
+
27
+ module_function
28
+
29
+ def path
30
+ ENV["DOPACC_STATE"] || File.join(ENV["XDG_STATE_HOME"] || File.expand_path("~/.local/state"), "dopairb", "dopacc.json")
31
+ end
32
+
33
+ def read
34
+ DEFAULTS.merge(JSON.parse(File.read(path)))
35
+ rescue SystemCallError, JSON::ParserError
36
+ DEFAULTS.dup
37
+ end
38
+
39
+ # Yields the state; whatever the block changes is written back.
40
+ def update
41
+ FileUtils.mkdir_p(File.dirname(path))
42
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
43
+ f.flock(File::LOCK_EX)
44
+ s = DEFAULTS.merge((JSON.parse(f.read) rescue {}))
45
+ result = yield s
46
+ f.rewind
47
+ f.truncate(0)
48
+ f.write(JSON.pretty_generate(s))
49
+ result
50
+ end
51
+ end
52
+ end
53
+
54
+ # One hook call: hook JSON in, a list of effects out.
55
+ module Hook
56
+ module_function
57
+
58
+ def run(input, rng: nil)
59
+ ev = (JSON.parse(input) rescue {})
60
+ return [] if %w[off 0 false].include?(ENV["DOPACC"].to_s)
61
+ mode = nil
62
+ plan = State.update do |s|
63
+ mode = s["mode"]
64
+ next [] if mode == "off"
65
+ rng ||= rng_for(s["tools"])
66
+ if s["session"] != ev["session_id"]
67
+ s.merge!("session" => ev["session_id"], "combo" => 0, "turn_tools" => 0, "turn_score" => 0)
68
+ end
69
+ case ev["hook_event_name"]
70
+ when "PostToolUse" then success(s, rng)
71
+ when "PostToolUseFailure" then failure(s)
72
+ when "Stop" then stop(s, rng)
73
+ else []
74
+ end
75
+ end
76
+ # calm: sounds only, never take the screen
77
+ plan = plan.map { |e| e[:show] ? { sound: e[:sound] } : e } if mode == "calm"
78
+ plan
79
+ end
80
+
81
+ # DOPACC_SEED makes the luck repeatable (for recordings), call by call.
82
+ def rng_for(tools)
83
+ ENV["DOPACC_SEED"] ? Random.new(ENV["DOPACC_SEED"].to_i * 1000 + tools) : Random.new
84
+ end
85
+
86
+ def success(s, rng)
87
+ s["combo"] += 1
88
+ s["tools"] += 1
89
+ s["turn_tools"] += 1
90
+ combo = s["combo"]
91
+ new_best = combo > s["best"]
92
+ s["best"] = combo if new_best
93
+ gain = (10 + combo) * (combo >= FEVER_COMBO ? 2 : 1)
94
+ jackpot = rng.rand < JACKPOT_CHANCE
95
+ gain *= 16 if jackpot
96
+ s["score"] += gain
97
+ s["turn_score"] += gain
98
+ if jackpot
99
+ [{ show: "JACKPOT!!", sub: "+#{commas(gain)} (x16)", palette: :GOLD, sound: :jackpot }]
100
+ elsif combo >= 8 && (combo & (combo - 1)).zero?
101
+ [{ show: "COMBO #{combo}!", sub: new_best ? "ALL-TIME BEST!" : "#{combo}-HIT CHAIN", palette: :FIRE, sound: :mega }]
102
+ else
103
+ [{ sound: combo >= FEVER_COMBO ? :hit : :nice }]
104
+ end
105
+ end
106
+
107
+ def failure(s)
108
+ broken = s["combo"] >= 4
109
+ s["combo"] = 0
110
+ [{ sound: broken ? :crack : :error }]
111
+ end
112
+
113
+ def stop(s, rng)
114
+ tools = s["turn_tools"]
115
+ score = s["turn_score"]
116
+ s["turn_tools"] = 0
117
+ s["turn_score"] = 0
118
+ # a plain answer (no tools) only gets a jingle
119
+ return [{ sound: :result }] if tools.zero?
120
+ [{ show: DONE.sample(random: rng), sub: "#{tools} TOOLS +#{commas(score)} COMBO #{s["combo"]} BEST #{s["best"]}",
121
+ palette: :GOLD, sound: :mega }]
122
+ end
123
+
124
+ def commas(n) = n.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
125
+ end
126
+
127
+ # Drawing on Claude Code's terminal from a hook process.
128
+ module Screen
129
+ module_function
130
+
131
+ # => [pid, "/dev/pts/N"] of the nearest ancestor that owns a terminal
132
+ def find(pid = Process.ppid)
133
+ while pid && pid > 1
134
+ tty = tty_of(pid)
135
+ return [pid, tty] if tty
136
+ pid = parent_of(pid)
137
+ end
138
+ nil
139
+ end
140
+
141
+ def tty_of(pid)
142
+ if File.directory?("/proc/#{pid}/fd")
143
+ [1, 2, 0].each do |fd|
144
+ t = (File.readlink("/proc/#{pid}/fd/#{fd}") rescue nil)
145
+ return t if t&.start_with?("/dev/pts/", "/dev/tty")
146
+ end
147
+ nil
148
+ else
149
+ t = `ps -o tty= -p #{pid.to_i} 2>/dev/null`.strip
150
+ t.empty? || t.start_with?("?", "-") ? nil : "/dev/#{t.delete_prefix("/dev/")}"
151
+ end
152
+ end
153
+
154
+ def parent_of(pid)
155
+ if File.exist?("/proc/#{pid}/stat")
156
+ File.read("/proc/#{pid}/stat")[/\) \S (\d+)/, 1]&.to_i
157
+ else
158
+ `ps -o ppid= -p #{pid.to_i} 2>/dev/null`.strip.then { |s| s.empty? ? nil : s.to_i }
159
+ end
160
+ rescue SystemCallError
161
+ nil
162
+ end
163
+
164
+ # Claude Code's fullscreen TUI already lives on the alternate screen;
165
+ # the classic one draws on the main screen.
166
+ def fullscreen?(cwd = Dir.pwd)
167
+ return false if truthy?(ENV["CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"])
168
+ return true if truthy?(ENV["CLAUDE_CODE_NO_FLICKER"])
169
+ tui = nil
170
+ [File.expand_path("~/.claude/settings.json"), File.join(cwd, ".claude/settings.json"),
171
+ File.join(cwd, ".claude/settings.local.json")].each do |f|
172
+ v = (JSON.parse(File.read(f))["tui"] rescue nil)
173
+ tui = v if v
174
+ end
175
+ tui == "fullscreen"
176
+ end
177
+
178
+ def truthy?(v) = !v.nil? && !%w[0 false no off].include?(v.to_s.downcase) && !v.empty?
179
+
180
+ def show(text, sub: nil, palette: :GOLD, sound: :mega, secs: 2.0, cwd: Dir.pwd)
181
+ _pid, path = find
182
+ return false unless path
183
+ require_relative "../dopairb"
184
+ # hooks of parallel tool calls take turns on the screen
185
+ FileUtils.mkdir_p(File.dirname(State.path))
186
+ lock = File.open("#{State.path}.lock", File::RDWR | File::CREAT, 0o644)
187
+ lock.flock(File::LOCK_EX)
188
+ tty = File.open(path, "r+")
189
+ tty.sync = true
190
+ Term.out = tty
191
+ Term.input = tty
192
+ rows, cols = tty.winsize
193
+ swap = !fullscreen?(cwd)
194
+ play(sound) if sound
195
+ ctx = Ctx.new(w: cols - 1, rows: rows, depth: Term.depth(Config.new), config: Config.new, rng: Random.new,
196
+ input: nil, pre: 0.0, compact: false)
197
+ banner = Scenes::Banner.new(ctx, nil, text: text, sub: sub, palette: Color.const_get(palette),
198
+ mega: true, rainbow: true, alt: true)
199
+ tty.write(swap ? "\e[?1049h\e[?25l\e[2J" : "\e7\e[?25l\e[2J")
200
+ t0 = now
201
+ while (t = now - t0) < secs
202
+ c = Canvas.new(cols - 1, rows)
203
+ banner.draw(c, t)
204
+ lines = c.render(ctx.depth, shake: banner.shake(t))
205
+ tty.write("\e[H" + lines.map { |l| "#{l}\e[0m\e[K" }.join("\r\n"))
206
+ sleep [1 / 30.0 - (now - t0 - t), 0].max
207
+ end
208
+ if swap
209
+ tty.write("\e[0m\e[?1049l\e[?25h")
210
+ else
211
+ # A real size change makes the TUI lay everything out again.
212
+ tty.write("\e[0m\e[2J\e8")
213
+ tty.winsize = [rows, cols - 1]
214
+ sleep 0.08
215
+ tty.winsize = [rows, cols]
216
+ end
217
+ true
218
+ ensure
219
+ tty&.close
220
+ lock&.close
221
+ end
222
+
223
+ # Players outlive the hook process, which exits right away.
224
+ def play(name)
225
+ require_relative "../dopairb"
226
+ kind, argv = Sound.backend
227
+ return false unless kind
228
+ return Sound.log_play(argv[0], name, 0.0, 1.0) if kind == :log # recordings
229
+ path, = Sound.file(name)
230
+ cmd = if kind == :slow
231
+ win = IO.popen(["wslpath", "-w", path], err: File::NULL, &:read).strip
232
+ argv + ["(New-Object Media.SoundPlayer '#{win}').PlaySync()"]
233
+ else
234
+ argv + [path]
235
+ end
236
+ Process.detach(Process.spawn(*cmd, in: File::NULL, out: File::NULL, err: File::NULL, pgroup: true))
237
+ true
238
+ end
239
+
240
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
241
+ end
242
+
243
+ # `dopacc install`: hooks (and a status line) in Claude Code's settings.
244
+ module Installer
245
+ module_function
246
+
247
+ def command
248
+ bin = File.join(Gem.bindir, "dopacc") if defined?(Gem)
249
+ exe = if bin && File.exist?(bin) then bin
250
+ elsif File.basename($PROGRAM_NAME) == "dopacc" then File.expand_path($PROGRAM_NAME)
251
+ else "dopacc"
252
+ end
253
+ exe.include?(" ") ? "\"#{exe}\"" : exe
254
+ end
255
+
256
+ def ours?(hook) = hook["command"].to_s.match?(/dopacc(\"?)? hook\z/)
257
+
258
+ def install(settings_path, statusline: true)
259
+ s = load(settings_path)
260
+ strip!(s)
261
+ hooks = (s["hooks"] ||= {})
262
+ HOOK_EVENTS.each do |ev|
263
+ (hooks[ev] ||= []) << { "matcher" => "", "hooks" => [{ "type" => "command", "command" => "#{command} hook", "timeout" => 15 }] }
264
+ end
265
+ notes = []
266
+ if statusline
267
+ if s["statusLine"].nil? || s["statusLine"]["command"].to_s.include?("dopacc")
268
+ s["statusLine"] = { "type" => "command", "command" => "#{command} statusline" }
269
+ else
270
+ notes << "kept your statusLine; `dopacc statusline` prints the COMBO line if you want to add it"
271
+ end
272
+ end
273
+ save(settings_path, s)
274
+ notes
275
+ end
276
+
277
+ def uninstall(settings_path)
278
+ s = load(settings_path)
279
+ strip!(s)
280
+ s.delete("statusLine") if s["statusLine"].to_h["command"].to_s.include?("dopacc")
281
+ save(settings_path, s)
282
+ end
283
+
284
+ def installed?(settings_path)
285
+ load(settings_path).fetch("hooks", {}).values.flatten.any? { |m| m.fetch("hooks", []).any? { |h| ours?(h) } }
286
+ end
287
+
288
+ def strip!(s)
289
+ hooks = s["hooks"] or return
290
+ hooks.each_value do |matchers|
291
+ matchers.each { |m| m["hooks"]&.reject! { |h| ours?(h) } }
292
+ matchers.reject! { |m| m["hooks"]&.empty? }
293
+ end
294
+ hooks.reject! { |_, v| v.empty? }
295
+ s.delete("hooks") if hooks.empty?
296
+ end
297
+
298
+ def load(path)
299
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
300
+ end
301
+
302
+ def save(path, s)
303
+ FileUtils.mkdir_p(File.dirname(path))
304
+ FileUtils.cp(path, "#{path}.bak") if File.exist?(path)
305
+ tmp = "#{path}.#{Process.pid}.tmp"
306
+ File.write(tmp, JSON.pretty_generate(s) + "\n")
307
+ File.rename(tmp, path)
308
+ end
309
+ end
310
+
311
+ # `dopacc statusline`: one line, no effects library, fast.
312
+ def self.statusline(s = State.read)
313
+ return "\e[2mdopacc off\e[0m" if s["mode"] == "off"
314
+ combo = s["combo"]
315
+ fever = combo >= FEVER_COMBO
316
+ head = fever ? rainbow("FEVER!") + " " : ""
317
+ "#{head}\e[1;38;2;255;170;60mCOMBO #{combo}\e[0m \e[38;2;255;220;120mSCORE #{Hook.commas(s["score"])}\e[0m " \
318
+ "\e[2mBEST #{s["best"]}#{s["mode"] == "calm" ? " (calm)" : ""}\e[0m"
319
+ end
320
+
321
+ def self.rainbow(str)
322
+ t = Time.now.to_f * 2
323
+ str.chars.each_with_index.map do |ch, i|
324
+ h = (t + i * 0.15) % 1.0
325
+ r, g, b = [0, 2, 4].map { |k| (Math.sin((h + k / 6.0) * 2 * Math::PI) * 127 + 128).round }
326
+ "\e[1;38;2;#{r};#{g};#{b}m#{ch}"
327
+ end.join + "\e[0m"
328
+ end
329
+ end
330
+ end
data/lib/dopairb/cli.rb CHANGED
@@ -15,13 +15,13 @@ module Dopairb
15
15
  o.separator ""
16
16
  o.separator "IRB with fireworks. Every other option is handed to IRB."
17
17
  o.separator ""
18
- o.on("--intensity=LEVEL", Config::INTENSITIES.map(&:to_s), "off / low / normal (default) / max") { |v| settings << [:intensity, v] }
18
+ o.on("--intensity=LEVEL", Config::INTENSITIES.map(&:to_s), "off / low / normal / max (default)") { |v| settings << [:intensity, v] }
19
19
  o.on("--calm", "low intensity, no flash, shorter effects") { settings.concat([[:intensity, :low], [:flash, :off], [:duration, 0.7]]) }
20
- o.on("--max", "everything at maximum") { settings << [:intensity, :max] }
20
+ o.on("--max", "everything at maximum (the default)") { settings << [:intensity, :max] }
21
21
  o.on("--party", "max + full-screen flash + sound effects") { settings.concat([[:intensity, :max], [:flash, :full], [:sound, :sfx]]) }
22
22
  o.on("--[no-]flash[=MODE]", "flash: off / soft (default) / full") { |v| settings << [:flash, v.nil? ? :soft : (v == false ? :off : v)] }
23
23
  o.on("--[no-]motion", "animations (default on)") { |v| settings << [:motion, v] }
24
- o.on("--[no-]sound[=MODE]", "sound: off (default) / bell / sfx") { |v| settings << [:sound, v.nil? ? :sfx : (v == false ? :off : v)] }
24
+ o.on("--[no-]sound[=MODE]", "sound: off / bell / sfx (default)") { |v| settings << [:sound, v.nil? ? :sfx : (v == false ? :off : v)] }
25
25
  o.on("--[no-]hud", "COMBO / SCORE next to the prompt (default on)") { |v| settings << [:hud, v] }
26
26
  o.on("--[no-]keys", "per-keystroke effects (default on)") { |v| settings << [:keys, v] }
27
27
  o.on("--[no-]intro", "title animation (default on)") { |v| settings << [:intro, v] }
@@ -61,9 +61,7 @@ module Dopairb
61
61
  ours, irb_args = split_args(argv.dup)
62
62
  settings = []
63
63
  parser(settings).parse!(ours)
64
- settings.each do |k, v|
65
- k == :string ? Dopairb.config.apply_string(v) : Dopairb.config.set(k, v)
66
- end
64
+ settings.each { |k, v| Dopairb.override(k, v) }
67
65
  run_irb(irb_args)
68
66
  rescue OptionParser::ParseError, ArgumentError => e
69
67
  warn "dopairb: #{e.message}"
@@ -21,15 +21,16 @@ module Dopairb
21
21
  charge: "seconds (max 0.3) of wind-up before a fast result lands; 0 disables",
22
22
  intro: "on / off -- title animation at startup",
23
23
  keys: "on / off -- per-keystroke effects while typing",
24
+ binding_irb: "on / off -- effects in sessions opened by binding.irb too",
24
25
  }.freeze
25
26
 
26
- attr_reader :intensity, :motion, :flash, :sound, :hud, :duration, :color, :trail, :charge, :intro, :keys
27
+ attr_reader :intensity, :motion, :flash, :sound, :hud, :duration, :color, :trail, :charge, :intro, :keys, :binding_irb
27
28
 
28
29
  def initialize
29
- @intensity = :normal
30
+ @intensity = :max
30
31
  @motion = true
31
32
  @flash = :soft
32
- @sound = :off
33
+ @sound = :sfx
33
34
  @hud = true
34
35
  @duration = 1.0
35
36
  @color = :auto
@@ -37,6 +38,7 @@ module Dopairb
37
38
  @charge = 0.12
38
39
  @intro = true
39
40
  @keys = true
41
+ @binding_irb = true
40
42
  end
41
43
 
42
44
  def level
@@ -60,7 +62,7 @@ module Dopairb
60
62
  when :sound
61
63
  v = bool_or_nil(value)
62
64
  @sound = v.nil? ? enum(value, SOUNDS, name) : (v ? :sfx : :off)
63
- when :motion, :hud, :intro, :keys
65
+ when :motion, :hud, :intro, :keys, :binding_irb
64
66
  v = bool_or_nil(value)
65
67
  raise ArgumentError, "#{name} expects on/off, got #{value.inspect}" if v.nil?
66
68
  instance_variable_set(:"@#{name}", v)