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 +4 -4
- data/README.md +41 -5
- data/exe/dopacc +84 -0
- data/lib/dopairb/claude_code.rb +330 -0
- data/lib/dopairb/session.rb +6 -4
- data/lib/dopairb/version.rb +1 -1
- data/lib/dopairb.rb +44 -11
- metadata +4 -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,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/
|
|
6
|
+
https://github.com/user-attachments/assets/98b037e1-5a30-4abd-812d-233a60ab2de4
|
|
7
7
|
|
|
8
|
-
A
|
|
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
|
|
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.
|
|
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
|
+
|
|
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
|
data/lib/dopairb/session.rb
CHANGED
|
@@ -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
|
data/lib/dopairb/version.rb
CHANGED
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;
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
+
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
|