From 009d5705a59bcf86f3579a78e25c8b3fcd4cd476 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 27 Oct 2026 16:25:43 -0600 Subject: Add panto-rsvp: RSVP reading mode for assistant text Standalone opt-in extension that plays the latest assistant text block one word at a time above the input, using the core mount, input-mode and highlight("markdown") APIs settled in pantograph's rsvp-extension-plan. Registers /rsvp; users bind it via "ctrl+s" = "/rsvp" under [keys]. Player and config logic are pure Lua with an assert-based self-check run against a host/timer stub, since the core APIs land separately. --- _selfcheck.lua | 280 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 _selfcheck.lua (limited to '_selfcheck.lua') diff --git a/_selfcheck.lua b/_selfcheck.lua new file mode 100644 index 0000000..bbcf380 --- /dev/null +++ b/_selfcheck.lua @@ -0,0 +1,280 @@ +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 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 }, + highlight = function(body) + h.highlighted[#h.highlighted + 1] = body + if options.highlight then return options.highlight(body) end + return body + end, + width = function(text) + text = text:gsub("\27%[[0-9;]*m", ""):gsub("\27%]8;;.-\27\\", "") + return #text + end, + truncate = function(text, width) return text:sub(1, width) end, + style = function() return h.styles end, + new_timer = h.new_timer, + mount = h.mount, + input_mode = h.input_mode, + } + return h +end + +local function plain(text) + return (text or ""):gsub("\27%[[0-9;:]*m", "") +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 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() and 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() and 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() and 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() and 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()) + 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()) + 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()) + 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()) + 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 + +print("panto-rsvp self-check passed") -- cgit v1.3