dopairb 0.4.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: cbc57c42e0e25caed6e783bf761e9edda9822c1d853fa4d55c144ce6704a875e
4
- data.tar.gz: a3d81e85abe05052cbbf99907162215b4d27796fd0ed14827d83190c0252672e
3
+ metadata.gz: afb5e40866c1cdf891ccf500611c568c91f693715f120e69822088e7bdcffdf3
4
+ data.tar.gz: 25b611a5ac21351f1f9adf5fd811cbc6c8422651dd7cab94dbb878d68d25930d
5
5
  SHA512:
6
- metadata.gz: 9522cb64cb142280f6ed6070e0e92884206fb0a7eccd1d289202c64ed9cb28a2cbb14d291a686362ce5797654910dec25f91ab2383193e82782cbd9bcceb7943
7
- data.tar.gz: 6cae99bcfcdcf7419a2e943fda49cbb97f8603a7e7a09d17f23bf5beb165b8ab85e6cea0f99c6d20f0daa6304dcb13341c5ef9f2781e635642003a4e6dd4f11f
6
+ metadata.gz: 46b1c5c6e4b4450e0297b21d70a24da9ad379603512de745e208837a57a79f7b004eed7c5a748261c78052b1bb1aa44c564595d599552d3471c5055d5182a871
7
+ data.tar.gz: 8979f157991d9e6451e7ceb8df3fc480a86ee39bd350f2afe363d5c87ccc187e62dc729eefab57deaf3aaa6f975aee6e37a6f948cd7f7353b3fef574ee09e406
data/README.md CHANGED
@@ -3,9 +3,9 @@
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/b45e4a97-ffde-4fb2-be73-ef69d5c8af42
6
+ https://github.com/user-attachments/assets/98b037e1-5a30-4abd-812d-233a60ab2de4
7
7
 
8
- A 70-second session with sound (`dopairb`, the defaults); also in [docs/dopairb-demo.mp4](docs/dopairb-demo.mp4). The scripts that recorded it are in [docs/recording](docs/recording).
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
9
 
10
10
  ```
11
11
  dopairb(main):001> (1..100).sum COMBO 00 SCORE 33 [⣿⣿⣀⣀⣀⣀⣀⣀] x1.5
@@ -45,13 +45,14 @@ To turn your regular `irb` into dopairb, add this to `~/.irbrc`:
45
45
 
46
46
  ```ruby
47
47
  begin
48
- require "dopairb"
49
- Dopairb.enable # also e.g. Dopairb.enable(sound: :off, intensity: :normal)
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
50
  rescue LoadError
51
51
  # a Ruby without dopairb keeps plain irb
52
52
  end
53
53
  ```
54
54
 
55
+ With only the `require`, irb stays plain until you type `dopa on`.
55
56
  The title (and the loading screen on a cold start) plays right before the first prompt, as with the `dopairb` command.
56
57
  Sessions opened by `binding.irb` get the effects too; `Dopairb.enable(binding_irb: false)` keeps them plain.
57
58
  Settings for a single run (`dopairb` options and `$DOPAIRB`) win over the ones given to `Dopairb.enable` in `.irbrc`.
@@ -60,6 +61,7 @@ Inside a session, the `dopa` command changes settings:
60
61
 
61
62
  ```
62
63
  dopa show settings and session stats
64
+ dopa on / dopa off switch the show on (back to the last level) or off
63
65
  dopa off|low|normal|max
64
66
  dopa calm / dopa party
65
67
  dopa demo play every effect once (does not change your score)
@@ -134,7 +136,10 @@ Error effects name the kind of error. They never make fun of the failure.
134
136
 
135
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.
136
138
  Levels need powers of two: LV 2 at 512 XP, LV 3 at 1,024, LV 4 at 2,048, and so on.
137
- 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. (Spoilers: all 100 are in [docs/gallery.png](docs/gallery.png).)
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
+
138
143
  The startup title shows your level and day streak, and the finale shows `NEW BEST!` and an XP bar.
139
144
 
140
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.
@@ -161,6 +166,37 @@ PowerShell takes a few hundred milliseconds to start, so with it only the result
161
166
  When no player is found, `sfx` falls back to the terminal bell on big moments.
162
167
  Sound plays on the machine that runs dopairb, so over ssh you will not hear it.
163
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
+
164
200
  ## Readable, never broken
165
201
 
166
202
  - IRB evaluates and prints as usual. `=> value`, exception messages and backtraces come from IRB itself.
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
@@ -6,6 +6,8 @@ module Dopairb
6
6
  class Session
7
7
  attr_reader :config, :game, :director, :fx, :collected
8
8
  attr_accessor :cold_start
9
+ # Hooked into IRB but not playing: `require "dopairb"` without enable.
10
+ attr_accessor :dormant
9
11
 
10
12
  def initialize(config)
11
13
  @config = config
@@ -31,15 +33,15 @@ module Dopairb
31
33
  end
32
34
 
33
35
  def active?
34
- !@config.off? && Term.interactive? && (@config.binding_irb || !IrbAdapter.from_binding?)
36
+ !@dormant && !@config.off? && Term.interactive? && (@config.binding_irb || !IrbAdapter.from_binding?)
35
37
  end
36
38
 
37
39
  # Once per process: from the dopairb command before IRB starts, or for
38
- # `Dopairb.enable` in .irbrc right before the first prompt.
40
+ # `Dopairb.enable` in .irbrc right before the first prompt -- or, when it
41
+ # starts switched off, before the first prompt after `dopa on`.
39
42
  def intro
40
- return if @introduced
43
+ return if @introduced || !active?
41
44
  @introduced = true
42
- return unless active?
43
45
  prof = profile
44
46
  grew = prof.visit
45
47
  prof.save if grew
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dopairb
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/dopairb.rb CHANGED
@@ -68,26 +68,46 @@ module Dopairb
68
68
  overrides << [name, value]
69
69
  end
70
70
 
71
- # Hook into IRB. Safe to call more than once; options are config settings.
71
+ # Hook into IRB and switch the show on. Safe to call more than once;
72
+ # options are config settings.
72
73
  def enable(intro: nil, **settings)
73
74
  settings.each { |k, v| config.set(k, v) }
74
75
  config.set(:intro, intro) unless intro.nil?
75
76
  overrides.each { |k, v| k == :string ? config.apply_string(v) : config.set(k, v) }
76
- @session ||= Session.new(config)
77
- if IrbAdapter.install
78
- RelineAdapter.install(@session.fx)
79
- OutputTap.install
80
- if active?
81
- # decided before warming up: the first run shows the loading screen
82
- @session.cold_start = Sound.available? && !Sound.ready?(:intro, config.duration)
83
- Sound.warm_up(config.duration)
84
- end
77
+ if hook
78
+ wake
85
79
  else
86
80
  warn "dopairb: IRB #{defined?(IRB::VERSION) ? IRB::VERSION : '?'} is not supported; running plain IRB"
87
81
  end
88
82
  @session
89
83
  end
90
84
 
85
+ # Just add the `dopa` command; the show stays dormant until `dopa on` or
86
+ # Dopairb.enable. This is what a bare `require "dopairb"` inside IRB does.
87
+ def install
88
+ hook
89
+ @session
90
+ end
91
+
92
+ def hook
93
+ @session ||= Session.new(config)
94
+ return false unless IrbAdapter.install
95
+ RelineAdapter.install(@session.fx)
96
+ OutputTap.install
97
+ true
98
+ end
99
+
100
+ # Out of dormancy (into whatever the intensity says).
101
+ def wake
102
+ return unless @session
103
+ was = @session.dormant
104
+ @session.dormant = false
105
+ return unless active?
106
+ # decided before warming up: the first run shows the loading screen
107
+ @session.cold_start = Sound.available? && !Sound.ready?(:intro, config.duration) if was || @session.cold_start.nil?
108
+ Sound.warm_up(config.duration)
109
+ end
110
+
91
111
  def active?
92
112
  @session&.active? || false
93
113
  end
@@ -110,6 +130,7 @@ module Dopairb
110
130
 
111
131
  HELP = <<~TXT
112
132
  dopa show settings and session stats
133
+ dopa on / dopa off switch the show on (back to where it was) or off
113
134
  dopa off|low|normal|max
114
135
  dopa calm low intensity, no flash, shorter
115
136
  dopa party max intensity, full-screen flash, sound effects
@@ -153,7 +174,11 @@ module Dopairb
153
174
  words = arg.split
154
175
  if words.empty? || words == ["status"]
155
176
  s = @session&.game
156
- puts "dopairb #{VERSION} (#{active? ? 'active' : 'inactive: not a terminal or intensity=off'})"
177
+ state = if active? then "active"
178
+ elsif @session&.dormant then "dormant: `dopa on` to start the show"
179
+ else "inactive: not a terminal or intensity=off"
180
+ end
181
+ puts "dopairb #{VERSION} (#{state})"
157
182
  puts config.describe
158
183
  puts " sound player: #{Sound.backend ? Sound.backend[1].first : '(none; bell only)'} cache: #{Sound.dir}"
159
184
  puts " session: #{s.evals} evals, #{s.successes} hits, #{s.failures} errors, max combo #{s.max_combo}, score #{Fx.number_with_commas(s.score)}" if s
@@ -174,7 +199,12 @@ module Dopairb
174
199
  elsif words == ["loading"]
175
200
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
176
201
  @session&.director&.loading { Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 > 2.5 } if active?
202
+ elsif words == ["on"]
203
+ config.set(:intensity, @resume_intensity || Config.new.intensity) if config.off?
204
+ wake
205
+ puts "dopairb: on (#{config.intensity})"
177
206
  elsif words.size == 1 && PRESETS.key?(words[0])
207
+ @resume_intensity = config.intensity if words[0] == "off" && !config.off?
178
208
  PRESETS[words[0]].each { |k, v| config.set(k, v) }
179
209
  Sound.warm_up(config.duration) if active?
180
210
  puts "dopairb: #{words[0]}"
@@ -194,3 +224,6 @@ module Dopairb
194
224
  end
195
225
  end
196
226
  end
227
+
228
+ # Required from inside IRB (e.g. .irbrc): add the `dopa` command, nothing else yet.
229
+ Dopairb.install.dormant = true if defined?(IRB.conf) && IRB.conf[:AP_NAME] && !Dopairb.session
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dopairb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Koichi Sasada
@@ -55,16 +55,19 @@ description: 'dopairb extends IRB with game-like terminal effects: sparks while
55
55
  email:
56
56
  - ko1@atdot.net
57
57
  executables:
58
+ - dopacc
58
59
  - dopairb
59
60
  extensions: []
60
61
  extra_rdoc_files: []
61
62
  files:
62
63
  - LICENSE
63
64
  - README.md
65
+ - exe/dopacc
64
66
  - exe/dopairb
65
67
  - lib/dopairb.rb
66
68
  - lib/dopairb/canvas.rb
67
69
  - lib/dopairb/charge_line.rb
70
+ - lib/dopairb/claude_code.rb
68
71
  - lib/dopairb/cli.rb
69
72
  - lib/dopairb/color.rb
70
73
  - lib/dopairb/config.rb