1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
|
local here = (arg and arg[0] or "_selfcheck.lua"):match("^(.*)/") or "."
package.path = here .. "/?.lua;" .. here .. "/?/init.lua;" .. package.path
local config = require("rsvp.config")
local module = require("rsvp.player")
local Player = module.Player
local defaults = assert(config.read())
assert(defaults.wpm == 250 and defaults.wpm_step == 25 and defaults.min_wpm == 50)
assert(config.read({ wpm = 25 }).wpm == 50, "configured speed respects lower bound")
assert(config.read({ typo = 1 }) == nil, "unknown settings are rejected")
for _, bad in ipairs({ 0, -1, 0 / 0, math.huge, "fast" }) do
local settings, err = config.read({ wpm = bad })
assert(settings == nil and err:find("rsvp.wpm"), "invalid WPM rejected")
end
local function plain(text)
return (text or ""):gsub("\27%[[0-9;:]*m", ""):gsub("\27%]8;;.-\27\\", "")
end
local function char_width(char)
local cp = utf8.codepoint(char)
if (cp >= 0x0300 and cp <= 0x036f) or cp == 0x200d
or (cp >= 0xfe00 and cp <= 0xfe0f) then return 0 end
if cp >= 0x1100 and (cp <= 0x115f or cp == 0x2329 or cp == 0x232a
or (cp >= 0x2e80 and cp <= 0xa4cf) or (cp >= 0xac00 and cp <= 0xd7a3)
or (cp >= 0xf900 and cp <= 0xfaff) or (cp >= 0xfe10 and cp <= 0xfe6f)
or (cp >= 0xff00 and cp <= 0xff60) or (cp >= 0x1f300 and cp <= 0x1faff)) then return 2 end
return 1
end
local function display_width(text)
local stripped, width = plain(text), 0
for _, cp in utf8.codes(stripped) do width = width + char_width(utf8.char(cp)) end
return width
end
local function truncate_display(text, max_width)
local output, width, i = {}, 0, 1
while i <= #text do
if text:sub(i, i) == "\27" then
local sequence = text:match("^(\27%[[0-9;:]*m)", i) or text:match("^(\27%]8;;.-\27\\)", i)
if sequence then output[#output + 1] = sequence; i = i + #sequence
else output[#output + 1] = text:sub(i, i); i = i + 1 end
else
local cp = utf8.codepoint(text, i)
local char = utf8.char(cp)
local char_width = char_width(char)
if width + char_width > max_width then break end
output[#output + 1] = char
width, i = width + char_width, i + #char
end
end
output[#output + 1] = "\27]8;;\27\\\27[0m"
return table.concat(output)
end
local function host(options)
options = options or {}
local h = { timers = {}, modes = {}, mounts = {}, highlighted = {}, styles = { welcome = "a", tool_header = "b" } }
function h.new_timer()
local timer = { active = false, closing = false, close_count = 0 }
function timer:start(timeout, repeat_ms, callback)
assert(repeat_ms == 0, "word timers must be one-shot")
self.timeout, self.callback, self.active = timeout, callback, true
end
function timer:stop() self.active = false end
function timer:is_active() return self.active end
function timer:is_closing() return self.closing end
function timer:close()
if not self.closing then self.closing, self.active, self.close_count = true, false, self.close_count + 1 end
end
h.timers[#h.timers + 1] = timer
return timer
end
function h.input_mode(spec)
local mode = { spec = spec, closed = false }
function mode:close()
if self.closed then return end
self.closed = true
self.spec.on_exit("closed")
end
function mode:exit(reason, message)
if self.closed then return end
self.closed = true
self.spec.on_exit(reason, message)
end
h.modes[#h.modes + 1] = mode
return mode
end
function h.mount(component, placement)
assert(placement == "above_input")
local mount = { component = component, invalidations = 0, unmounts = 0 }
function mount:invalidate() self.invalidations = self.invalidations + 1 end
function mount:unmount() self.unmounts = self.unmounts + 1 end
h.mounts[#h.mounts + 1] = mount
return mount
end
h.options = options
h.player = Player.new {
settings = options.settings or { wpm = 250, wpm_step = 25, min_wpm = 50 },
save_wpm = options.save_wpm or function() return true end,
persistence_error = options.persistence_error,
highlight = function(body)
h.highlighted[#h.highlighted + 1] = body
if options.highlight then return options.highlight(body) end
return body
end,
width = display_width,
truncate = truncate_display,
style = function() return h.styles end,
new_timer = h.new_timer,
mount = h.mount,
input_mode = h.input_mode,
}
return h
end
local function key(h, chord)
for _, binding in ipairs(h.modes[#h.modes].spec.keys) do
if binding.key == chord then binding.run(h.modes[#h.modes]); return end
end
error("missing mode key " .. chord)
end
local function fire(timer)
assert(timer and timer.callback, "expected a scheduled timer")
timer.active = false
timer.callback()
end
local function finish_paragraph(player)
while player.playback do fire(player.timer) end
end
-- A new block, even an empty one, replaces the prior source. Delta updates
-- retain generation/cursor; only newline-terminated segments are streamable.
do
local h, p = host(), nil
p = h.player
p:set_source("older text\n", true, 4)
local first_generation = p.generation
assert(p:activate())
local idle = p:render(20)
assert(not p.playback and p.timer == nil and idle[1]:sub(11, 11) == "|" and idle[3] == idle[1],
"activation stays idle with stationary centered focus markers")
assert(plain(p:render(80)[4]):find("Ready · Ctrl+S to start", 1, true)
and h.modes[1].spec.keys[1].desc == "start/next", "idle prompt and control description explain playback")
key(h, "ctrl+s")
local stale = p.timer
assert(plain(p.current_word) == "older")
p:set_source("", false, 0)
assert(p.generation == first_generation + 1 and p.current_word == nil)
assert(not p:update_source("stale block", true, 4) and p.source == "", "older block updates are rejected")
assert(stale.closing, "source replacement closes the old timer")
stale.callback()
assert(p.current_word == nil and p.playback == nil, "stale generation cannot paint")
assert(not p:next(), "empty streaming block has no eligible segment")
local generation = p.generation
p:update_source("first", false, 0)
assert(p.generation == generation and not p:next(), "unterminated segment waits")
p:update_source("first\nsecond", false, 0)
assert(p:next() and plain(p.current_word) == "first", "newline makes the first segment eligible")
local word_timer = p.timer
key(h, "ctrl+s")
assert(plain(p.current_word) == "first" and p.timer == word_timer, "Ctrl+S cannot skip during playback")
p:update_source("first\nsecond\n", true, 0)
finish_paragraph(p)
assert(p.cursor == 2, "paragraph read advances only after its final interval")
assert(p:next() and plain(p.current_word) == "second", "completed block exposes its final segment")
finish_paragraph(p)
assert(p.status == "Nothing more to read")
p:cancel("session_end")
end
-- Paragraph progress skips blank source segments but an interrupted active
-- paragraph remains the next unread segment.
do
local h = host()
local p = h.player
p:set_source("\n\nrestart me\n", true, 2)
assert(p:activate())
key(h, "ctrl+s")
assert(plain(p.current_word) == "restart")
assert(p.cursor == 3, "cursor retains the original source segment index")
p:cancel("closed")
assert(p.cursor == 3, "cancel does not mark the active paragraph read")
assert(p:activate())
key(h, "ctrl+s")
assert(plain(p.current_word) == "restart", "next activation restarts unfinished paragraph")
p:cancel("closed")
end
-- Matched backtick/tilde fences are omitted line-for-line; mismatched closers
-- do not end a fence, and an unclosed fence suppresses the remainder.
do
local source = "read\n```lua\nprint(1)\n~~~\nstill code\n```\nafter\n~~~mermaid\ngraph TD\n~~~\nlast"
local filtered = module.filter_source(source)
assert(filtered == "read\n\n\n\n\n\nafter\n\n\n\nlast", "matched fences preserve source lines")
local function newlines(s) local _, n = s:gsub("\n", ""); return n end
assert(newlines(filtered) == newlines(source), "fence omission retains newline boundaries")
local unclosed = module.filter_source("spoken\n~~~mermaid\nsecret\nstill secret\n")
assert(unclosed == "spoken\n\n\n\n", "unclosed fence suppresses to end")
local h, p = host(), nil
p = h.player
p:set_source(source, true, 1)
assert(p:activate())
key(h, "ctrl+s")
assert(plain(p.current_word) == "read")
assert(h.highlighted[1] == filtered, "highlight receives the complete filtered body")
finish_paragraph(p)
assert(p:next() and plain(p.current_word) == "after")
finish_paragraph(p)
assert(p:next() and plain(p.current_word) == "last")
finish_paragraph(p)
assert(p.status == "Nothing more to read")
p:cancel("closed")
end
-- ANSI spans survive word splitting and source-line splitting. Theme changes
-- re-highlight the active snapshot without moving its word cursor.
do
local esc = "\27"
local h = host {
highlight = function(body)
local lines = #body > 0 and (select(2, body:gsub("\n", "")) + 1) or 1
if lines == 1 then return esc .. "[1mone two" .. esc .. "[0m" end
return esc .. "[1mone two" .. esc .. "[0m\n" .. esc .. "[1mthree four" .. esc .. "[0m"
end,
}
local p = h.player
p:set_source("one two\nthree four", true, 1)
assert(p:activate())
key(h, "ctrl+s")
assert(p.current_word:find(esc .. "%[1m"))
local first_timer = p.timer
fire(first_timer)
assert(p.current_word:find("two") and p.current_word:find(esc .. "%[1m"), "style replays at the next word")
local before_cursor, before_word, before_highlights = p.cursor, p.playback.word_index, #h.highlighted
h.styles.welcome = "changed"
p.component:render(40)
assert(#h.highlighted > before_highlights, "theme change rebuilds Markdown styling")
assert(p.cursor == before_cursor and p.playback.word_index == before_word, "theme refresh preserves progress")
fire(p.timer)
assert(p:next() and p.current_word:find("three") and p.current_word:find(esc .. "%[1m"), "style continues on the next source line")
p:cancel("closed")
end
-- Pause/resume gets a fresh interval; speed changes affect the next interval,
-- not the currently displayed word. The final interval commits read progress.
do
local h = host { settings = { wpm = 100, wpm_step = 25, min_wpm = 50 } }
local p = h.player
p:set_source("one two", true, 1)
assert(p:activate())
assert(p.timer == nil and p.current_word == nil, "activation does not schedule playback")
key(h, "ctrl+s")
local first_timer = p.timer
assert(first_timer.timeout == 600)
key(h, "ctrl+s")
assert(p.timer == first_timer and plain(p.current_word) == "one", "Ctrl+S is ignored during playback")
key(h, "space")
assert(p.paused and first_timer.closing and plain(p.current_word) == "one")
key(h, "-")
assert(p.wpm == 75 and p.paused and p.timer == nil, "speed change stays paused")
key(h, "space")
local resumed = p.timer
assert(resumed.timeout == 800 and plain(p.current_word) == "one", "resume gives the current word a fresh interval")
key(h, "+")
assert(p.wpm == 100 and p.timer == resumed, "speed does not restart the current interval")
assert(p.cursor == 1, "not read before the final word interval")
fire(resumed)
assert(plain(p.current_word) == "two" and p.timer.timeout == 600 and p.cursor == 1)
local final = p.timer
fire(final)
assert(p.cursor == 2 and p.status == "Nothing more to read", "final interval marks the paragraph read")
for _ = 1, 10 do p:adjust(-1) end
assert(p.wpm == 50, "speed adjustments stop at the configured lower bound")
p:cancel("closed")
end
-- Markdown failure is visible and never falls back to speaking raw source.
do
local h = host { highlight = function() error("renderer unavailable") end }
local p = h.player
p:set_source("**not raw playback**", true, 1)
assert(p:activate())
key(h, "ctrl+s")
assert(p.playback == nil and p.current_word == nil)
key(h, "ctrl+s")
assert(p.playback == nil and p.current_word == nil)
assert(p.status:find("Markdown rendering failed", 1, true))
p:cancel("closed")
end
-- Suspension stops playback and requires the same explicit Space resume.
do
local h = host()
local p = h.player
p:set_source("stay visible\n", true, 1)
assert(p:activate())
key(h, "ctrl+s")
local timer, word = p.timer, p.current_word
p:suspend()
assert(p.paused and timer.closing and p.current_word == word)
key(h, "space")
assert(not p.paused and p.timer ~= timer and p.current_word == word)
p:cancel("closed")
end
-- Every host exit/cancel path closes stale timers and unmounts exactly once.
for _, exit in ipairs({ "closed", "turn", "session", "error", "teardown", "session_end" }) do
local h = host()
local p = h.player
p:set_source("unfinished paragraph\n", true, 1)
assert(p:activate())
key(h, "ctrl+s")
local stale, mode, mount = p.timer, h.modes[1], h.mounts[1]
local paints = mount.invalidations
if exit == "closed" then
key(h, "escape")
elseif exit == "session_end" then
p:cancel(exit)
else
mode:exit(exit, exit == "error" and "test error" or nil)
end
assert(stale.closing and p.timer == nil and p.mount == nil, exit .. " closes timer and mount")
stale.callback()
assert(p.playback == nil and mount.invalidations == paints, exit .. " stale callback is inert")
assert(mount.unmounts == 1, exit .. " unmounts exactly once")
end
-- Focus buckets count visible non-punctuation characters, not ANSI bytes, and
-- alignment uses display columns including leading punctuation and Unicode width.
do
local h = host()
local p = h.player
p:activate()
local cases = {
{ "x", 1 }, { "ab", 2 }, { "abcde", 2 }, { "abcdef", 3 },
{ "abcdefghi", 3 }, { "abcdefghij", 4 }, { "abcdefghijklm", 4 },
{ "abcdefghijklmn", 5 }, { "well-known,", 3 },
{ "'books,'", 3 }, { "“books,”", 3 }, { "!!!", 1 },
{ "\27[1mabcdef\27[0m", 3 }, { "e\204\129abcd", 3 },
{ "界界界abc", 3 }, { "「界界界。」", 3 },
}
for _, case in ipairs(cases) do
p.current_word = case[1]
local rows = p:render(40)
assert(rows[1]:sub(21, 21) == "|" and rows[3] == rows[1], "focus markers stay centered and stationary")
local visible = plain(rows[2])
local lead = #(visible:match("^( *)") or "")
local word = visible:sub(lead + 1)
local chars = {}
for _, cp in utf8.codes(word) do chars[#chars + 1] = utf8.char(cp) end
local before = 0
for i = 1, case[2] - 1 do before = before + char_width(chars[i]) end
assert(lead + before + 1 == 21, "focus column correct for " .. case[1])
if case[1]:find("\27", 1, true) then assert(rows[2]:find("\27[1m", 1, true), "ANSI styling survives alignment") end
end
p.current_word = "\27[1mabcdefghijklmnopqrstuv\27[0m"
local narrow = p:render(8)
assert(narrow[1] == " |" and narrow[3] == narrow[1], "narrow view bounds centered marker")
for _, row in ipairs(narrow) do assert(display_width(row) <= 8, "rendered row stays within viewport") end
assert(narrow[2]:find("\27[1m", 1, true), "narrow clipping preserves ANSI styling")
p.current_word = "界界界界界界"
narrow = p:render(3)
assert(narrow[1] == " |" and narrow[3] == narrow[1], "marker clamps to the viewport center")
for _, row in ipairs(narrow) do assert(display_width(row) <= 3, "wide glyph clipping stays bounded") end
for _, width in ipairs({ 0, 1, 2, 3, 8, 40 }) do
for _, row in ipairs(p:render(width)) do
assert(display_width(row) <= width, "all rows fit even a zero-width viewport")
end
end
p:cancel("closed")
end
-- Speed changes persist only when the value really changes; failures keep the
-- in-memory speed and remain visible without interrupting RSVP.
do
local writes = {}
local h = host { settings = { wpm = 100, wpm_step = 25, min_wpm = 50 },
save_wpm = function(value) writes[#writes + 1] = value; return nil, "disk unavailable" end }
local p = h.player
assert(p:adjust(-1) == 75 and writes[1] == 75, "changed speed is persisted")
assert(p.persistence_error:find("disk unavailable", 1, true), "save failure is retained")
assert(plain(p:render(80)[5]):find("WPM save failed", 1, true), "save failure is surfaced in the view")
p:adjust(1)
assert(p.wpm == 100 and p.persistence_error, "failed save does not roll back speed")
local count = #writes
for _ = 1, 10 do p:adjust(-1) end
assert(p.wpm == 50 and #writes == count + 2, "clamped no-op adjustments are not persisted")
p.save_wpm = function() return true end
p:adjust(1)
assert(p.persistence_error == nil, "successful save clears the previous warning")
end
-- Init reads/writes only through ext.state and opens in idle mode. Invalid saved
-- values use configured WPM; storage errors stay visible but do not block use.
do
local function launch(saved, read_error, write_error)
local h, events, persisted = host(), {}, saved
local command
local ext = {
config = { rsvp = { wpm = 100, wpm_step = 25, min_wpm = 50 } },
state = {
get = function(key)
assert(key == "rsvp.wpm")
return persisted, read_error
end,
set = function(key, value)
assert(key == "rsvp.wpm")
if write_error then return nil, write_error end
persisted = value
return true
end,
},
on = function(name, callback) events[name] = callback end,
highlight = function(body) return body end,
style = function() return h.styles end,
mount = h.mount,
input_mode = h.input_mode,
register_command = function(spec) command = spec end,
}
package.loaded.panto = { ext = ext, text = { width = display_width, truncate = truncate_display } }
package.loaded.luv = { new_timer = function() return h.new_timer() end }
local extension = dofile(here .. "/init.lua")
extension.activate()
assert(command and command.name == "rsvp")
command.handler("")
return h, events, function() return persisted end
end
local h, events, persisted = launch(30)
local idle = h.mounts[1].component:render(40)
assert(h.timers[1] == nil and idle[1]:sub(21, 21) == "|" and idle[3] == idle[1],
"extension activation is visible but schedules no playback")
assert(plain(idle[4]):find("50 WPM", 1, true), "saved speed clamps to configured minimum")
events.render_assistant_text({ text = "first paragraph\nsecond", index = 1 })
key(h, "ctrl+s")
assert(h.timers[1] and plain(h.mounts[1].component:render(40)[2]):find("first", 1, true),
"first Ctrl+S starts the first paragraph")
key(h, "+")
assert(persisted() == 75, "extension saves WPM through ext.state.set")
key(h, "escape")
h = launch(175)
assert(plain(h.mounts[1].component:render(40)[4]):find("175 WPM", 1, true),
"finite positive saved WPM overrides configured fallback")
key(h, "escape")
for _, invalid in ipairs({ 0, -1, 0 / 0, math.huge, "fast" }) do
h = launch(invalid)
local view = h.mounts[1].component:render(40)
assert(plain(view[4]):find("100 WPM", 1, true), "invalid saved WPM uses configured fallback")
key(h, "escape")
end
h = launch(nil, "read denied")
local warning = h.mounts[1].component:render(60)
assert(plain(warning[4]):find("100 WPM", 1, true) and plain(warning[5]):find("read denied", 1, true),
"read failure falls back and is displayed")
key(h, "+")
assert(#h.mounts[1].component:render(60) == 4, "successful save clears the resolved restore warning")
key(h, "escape")
h = launch(nil, nil, "write denied")
key(h, "+")
assert(plain(h.mounts[1].component:render(60)[5]):find("write denied", 1, true),
"extension displays ext.state.set failures")
key(h, "escape")
end
print("panto-rsvp self-check passed")
|