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 +4 -4
- data/README.md +77 -9
- data/exe/dopacc +84 -0
- data/lib/dopairb/claude_code.rb +330 -0
- data/lib/dopairb/cli.rb +4 -6
- data/lib/dopairb/config.rb +6 -4
- data/lib/dopairb/demo.rb +9 -2
- data/lib/dopairb/director.rb +35 -5
- data/lib/dopairb/fx.rb +23 -0
- data/lib/dopairb/gallery/impressionism.rb +818 -0
- data/lib/dopairb/gallery/japan.rb +693 -0
- data/lib/dopairb/gallery/modern_ancient.rb +774 -0
- data/lib/dopairb/gallery/renaissance.rb +829 -0
- data/lib/dopairb/gallery/romantic.rb +703 -0
- data/lib/dopairb/gallery.rb +349 -0
- data/lib/dopairb/game.rb +84 -7
- data/lib/dopairb/input_fx.rb +63 -9
- data/lib/dopairb/irb_adapter.rb +8 -0
- data/lib/dopairb/profile.rb +106 -0
- data/lib/dopairb/reline_adapter.rb +1 -0
- data/lib/dopairb/scene.rb +5 -0
- data/lib/dopairb/scenes/bonus.rb +279 -0
- data/lib/dopairb/scenes/failure.rb +1 -0
- data/lib/dopairb/scenes/finale.rb +42 -2
- data/lib/dopairb/scenes/session.rb +21 -3
- data/lib/dopairb/scenes/success.rb +11 -4
- data/lib/dopairb/session.rb +53 -7
- data/lib/dopairb/sound.rb +57 -2
- data/lib/dopairb/term.rb +3 -1
- data/lib/dopairb/tour.rb +89 -0
- data/lib/dopairb/version.rb +1 -1
- data/lib/dopairb.rb +101 -12
- data/spec.md +3 -3
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afb5e40866c1cdf891ccf500611c568c91f693715f120e69822088e7bdcffdf3
|
|
4
|
+
data.tar.gz: 25b611a5ac21351f1f9adf5fd811cbc6c8422651dd7cab94dbb878d68d25930d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 --
|
|
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
|
|
44
|
+
To turn your regular `irb` into dopairb, add this to `~/.irbrc`:
|
|
42
45
|
|
|
43
46
|
```ruby
|
|
44
|
-
|
|
45
|
-
|
|
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 |
|
|
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 |
|
|
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
|
+

|
|
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)
|
|
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
|
|
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
|
|
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}"
|
data/lib/dopairb/config.rb
CHANGED
|
@@ -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 = :
|
|
30
|
+
@intensity = :max
|
|
30
31
|
@motion = true
|
|
31
32
|
@flash = :soft
|
|
32
|
-
@sound = :
|
|
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)
|