diff options
| author | t <t@tjp.lol> | 2026-10-27 16:25:43 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-10-27 16:25:47 -0600 |
| commit | 009d5705a59bcf86f3579a78e25c8b3fcd4cd476 (patch) | |
| tree | c6496041305da98deac9e40531fbeec0874268f1 | |
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.
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | LICENSE | 18 | ||||
| -rw-r--r-- | README.md | 60 | ||||
| -rw-r--r-- | _selfcheck.lua | 280 | ||||
| -rw-r--r-- | init.lua | 67 | ||||
| -rw-r--r-- | mise.toml | 4 | ||||
| -rw-r--r-- | panto-rsvp-0.1.0-1.rockspec | 34 | ||||
| -rw-r--r-- | rsvp/config.lua | 41 | ||||
| -rw-r--r-- | rsvp/player.lua | 519 |
9 files changed, 1031 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6e4a75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.panto/local/ +AGENTS.local.md +CLAUDE.local.md + +# Build artifact left by `luarocks upload` / `mise run release`. +*.src.rock +# Local luarocks tree created by `mise run deps`. +.rocks/ @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 Travis Parker + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..42148f6 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# panto-rsvp + +Rapid serial presentation for assistant replies. It presents the latest assistant text one newline-delimited paragraph at a time, omitting fenced code; it does not change ordinary transcript rendering. + +RSVP is an optional **focus mode**, not a comprehension or speed promise. + +## Install + +1. Add the rock to a layered `config.toml`: + + ```toml + [extensions] + rocks = ["panto-rsvp"] + ``` + + For a local checkout, use `paths = ["/path/to/panto-rsvp"]` instead; panto's own LuaRocks tree must already provide `luv` (it ships with panto). +2. Bind activation to Ctrl+S: + + ```toml + [keys] + "ctrl+s" = "/rsvp" + ``` + +3. Restart panto. Run `/rsvp` or Ctrl+S while idle to start the next unread paragraph. + +If `[extensions]` or `[keys]` already exists, add these entries to the existing tables rather than declaring them twice. + +## Controls + +- Ctrl+S: play the next unread paragraph while waiting; ignored during playback. +- Space: pause or resume. Resume gives the current word a fresh interval. +- `-` / `+`: adjust speed; changes apply to the next word interval. +- Esc: leave. An unfinished paragraph remains unread and restarts on the next activation. + +The current word stays centered above the input, with speed and paragraph progress. Text arriving while a paragraph is playing does not change that paragraph's word snapshot. Progress resets on session replacement and is not persisted. + +## Settings + +Optional settings live in `[rsvp]`: + +```toml +[rsvp] +wpm = 250 +wpm_step = 25 +min_wpm = 50 +``` + +| Setting | Default | Meaning | +| --- | ---: | --- | +| `wpm` | `250` | Initial words per minute; values below `min_wpm` start at the lower bound. | +| `wpm_step` | `25` | Change per `-` or `+`. | +| `min_wpm` | `50` | Lowest speed; `-` stops here. | + +All three values must be finite positive numbers. Theme changes re-render the active paragraph using the current Markdown display style. + +## Development + +```sh +mise run check # panto lua _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") diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..532152a --- /dev/null +++ b/init.lua @@ -0,0 +1,67 @@ +local config = require("rsvp.config") +local Player = require("rsvp.player").Player + +local function activate() + local panto = require("panto") + local ext = panto.ext + local settings, err = config.read(ext.config and ext.config.rsvp) + if not settings then error("panto-rsvp: " .. err) end + local uv = require("luv") + + local player = Player.new { + settings = settings, + highlight = function(body) return ext.highlight(body, "markdown") end, + width = panto.text.width, + truncate = panto.text.truncate, + style = function() return ext.style() end, + new_timer = function() return uv.new_timer() end, + mount = function(component, placement) return ext.mount(component, placement) end, + input_mode = function(spec) return ext.input_mode(spec) end, + } + + local function subscribe(name, handler) + ext.on(name, handler) + end + + subscribe("session_ready", function() + local ok, text = pcall(function() + local latest + for _, message in ipairs(ext.agent:conversation():messages()) do + if message.role == "assistant" then + for _, block in ipairs(message.blocks or {}) do + if block.type == "text" then latest = block.text or "" end + end + end + end + return latest or "" + end) + player:snapshot_source(ok and text or "") + end) + subscribe("render_assistant_text", function(event) + player:set_source(event.text or "", false, event.index) + end) + subscribe("render_assistant_text_delta", function(event) + if event.text ~= nil then player:update_source(event.text, false, event.index) end + end) + subscribe("render_assistant_text_complete", function(event) + player:update_source(event.text or "", true, event.index) + end) + subscribe("suspend", function() player:suspend() end) + subscribe("turn_start", function() player:cancel("turn") end) + subscribe("turn_interrupt", function() player:cancel("turn") end) + subscribe("session_end", function() player:cancel("session_end") end) + + ext.register_command { + name = "rsvp", + description = "Read the latest assistant text one paragraph at a time.", + handler = function(arguments) + if arguments and arguments ~= "" then return "Usage: /rsvp" end + local ok, why = player:activate() + if not ok then + return "RSVP unavailable" .. (why and (": " .. tostring(why)) or "") + end + end, + } +end + +return { name = "rsvp", activate = activate } diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..ddb4f53 --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tasks.check] +description = "Run the extension self-check inside panto's interpreter" +dir = "{{config_root}}" +run = "panto lua _selfcheck.lua" diff --git a/panto-rsvp-0.1.0-1.rockspec b/panto-rsvp-0.1.0-1.rockspec new file mode 100644 index 0000000..ed59cb4 --- /dev/null +++ b/panto-rsvp-0.1.0-1.rockspec @@ -0,0 +1,34 @@ +-- LuaRocks rockspec for `panto-rsvp`: a standalone Pantograph RSVP extension. +rockspec_format = "3.0" +package = "panto-rsvp" +version = "0.1.0-1" + +source = { + url = "git+https://code.tjp.lol/public/panto-rsvp.git", + tag = "v0.1.0", +} + +description = { + summary = "Rapid serial presentation focus mode for Pantograph assistant text.", + detailed = [[ + Presents the latest assistant text one newline-delimited paragraph at + a time, with Markdown display styling and playback controls. + ]], + homepage = "https://code.tjp.lol/public/panto-rsvp", + license = "MIT", + labels = { "ai", "llm", "panto", "pantograph", "extension", "rsvp" }, +} + +dependencies = { + "lua >= 5.4, < 5.5", + "luv >= 1.48", +} + +build = { + type = "builtin", + modules = { + ["panto-rsvp"] = "init.lua", + ["rsvp.config"] = "rsvp/config.lua", + ["rsvp.player"] = "rsvp/player.lua", + }, +} diff --git a/rsvp/config.lua b/rsvp/config.lua new file mode 100644 index 0000000..fb4b8fa --- /dev/null +++ b/rsvp/config.lua @@ -0,0 +1,41 @@ +local M = {} + +M.DEFAULT_WPM = 250 +M.DEFAULT_STEP = 25 +M.DEFAULT_MIN_WPM = 50 + +local function positive_finite(value) + return type(value) == "number" + and value == value + and value ~= math.huge + and value ~= -math.huge + and value > 0 +end + +function M.read(raw) + if raw == nil then raw = {} end + if type(raw) ~= "table" then + return nil, "rsvp settings must be a table" + end + + for key in pairs(raw) do + if key ~= "wpm" and key ~= "wpm_step" and key ~= "min_wpm" then + return nil, string.format("unknown rsvp setting %s", tostring(key)) + end + end + + local settings = { + wpm = raw.wpm == nil and M.DEFAULT_WPM or raw.wpm, + wpm_step = raw.wpm_step == nil and M.DEFAULT_STEP or raw.wpm_step, + min_wpm = raw.min_wpm == nil and M.DEFAULT_MIN_WPM or raw.min_wpm, + } + for key, value in pairs(settings) do + if not positive_finite(value) then + return nil, string.format("rsvp.%s must be a finite positive number", key) + end + end + settings.wpm = math.max(settings.wpm, settings.min_wpm) + return settings +end + +return M diff --git a/rsvp/player.lua b/rsvp/player.lua new file mode 100644 index 0000000..b98d00e --- /dev/null +++ b/rsvp/player.lua @@ -0,0 +1,519 @@ +local M = {} + +local function split_lines(text) + text = text:gsub("\r\n", "\n") + local lines, start = {}, 1 + while true do + local newline = text:find("\n", start, true) + if not newline then + lines[#lines + 1] = { text = text:sub(start), terminated = false } + break + end + lines[#lines + 1] = { text = text:sub(start, newline - 1), terminated = true } + start = newline + 1 + end + return lines, text +end + +local function fence(line) + local spaces = line:match("^( *)") or "" + if #spaces > 3 then return nil end + local rest = line:sub(#spaces + 1) + local char = rest:sub(1, 1) + if char ~= "`" and char ~= "~" then return nil end + local run = rest:match("^(" .. char .. "+)") or "" + if #run < 3 then return nil end + return char, #run, rest:sub(#run + 1) +end + +local function is_close_fence(line, char, length) + local found_char, found_length, tail = fence(line) + return found_char == char and found_length >= length and tail:match("^[ \t]*$") ~= nil +end + +local function filter_source(source) + local lines, normalized = split_lines(source) + local filtered, open_char, open_length = {}, nil, nil + for i, line in ipairs(lines) do + local text = line.text + if open_char then + filtered[i] = "" + if is_close_fence(text, open_char, open_length) then + open_char, open_length = nil, nil + end + else + local char, length = fence(text) + if char then + filtered[i] = "" + open_char, open_length = char, length + else + filtered[i] = text + end + end + end + return table.concat(filtered, "\n"), lines, normalized +end + +local function control_at(text, at) + if text:sub(at, at) ~= "\27" then return nil end + local second = text:sub(at + 1, at + 1) + if second == "[" then + for i = at + 2, #text do + local byte = text:byte(i) + if byte >= 0x40 and byte <= 0x7e then return text:sub(at, i), i + 1 end + end + elseif second == "]" then + local i = at + 2 + while i <= #text do + local byte = text:byte(i) + if byte == 7 then return text:sub(at, i), i + 1 end + if byte == 0x1b and text:sub(i + 1, i + 1) == "\\" then + return text:sub(at, i + 1), i + 2 + end + i = i + 1 + end + end + if second ~= "" then return text:sub(at, at + 1), at + 2 end + return text:sub(at), #text + 1 +end + +local function update_style(sequence, state) + if sequence:sub(1, 2) == "\27[" and sequence:sub(-1) == "m" then + local params = sequence:sub(3, -2) + local resets = params == "" + for code in params:gmatch("[^;:]+") do + if code == "0" then resets = true end + end + state.sgr = resets and sequence or state.sgr .. sequence + elseif sequence:sub(1, 2) == "\27]" then + local body = sequence:sub(3):gsub("\7$", ""):gsub("\27\\$", "") + local _, uri = body:match("^(8;[^;]*;)(.*)$") + if uri ~= nil then + state.link = uri == "" and "" or sequence + end + end +end + +local function tokenize(line) + local words, state, token = {}, { sgr = "", link = "" }, nil + local function finish() + if token then + token.text = token.text .. (state.link ~= "" and "\27]8;;\27\\" or "") .. "\27[0m" + words[#words + 1] = token + token = nil + end + end + + local i = 1 + while i <= #line do + local sequence, after = control_at(line, i) + if sequence then + update_style(sequence, state) + if token then token.text = token.text .. sequence end + i = after + else + local byte = line:byte(i) + local whitespace = byte == 32 or byte == 9 or byte == 11 or byte == 12 or byte == 13 + if whitespace then + finish() + else + if not token then + token = { text = state.sgr .. state.link } + end + token.text = token.text .. line:sub(i, i) + end + i = i + 1 + end + end + finish() + return words +end + +local function visible_line_count(lines, complete) + local n = 0 + for _, line in ipairs(lines) do + if line.terminated or complete then n = n + 1 end + end + return n +end + +local function finite_width(value) + return type(value) == "number" and value == value and value > 0 and value < math.huge +end + +local Player = {} +Player.__index = Player + +function Player.new(options) + local self = setmetatable({ + highlight = assert(options.highlight), + width = assert(options.width), + truncate = assert(options.truncate), + style = options.style or function() return {} end, + new_timer = assert(options.new_timer), + mount_component = assert(options.mount), + acquire_mode = assert(options.input_mode), + settings = assert(options.settings), + wpm = options.settings.wpm, + source = "", + source_complete = false, + generation = 0, + block_index = nil, + cursor = 1, + active = false, + released = true, + mode = nil, + mount = nil, + timer = nil, + timer_token = 0, + playback = nil, + paused = false, + current_word = nil, + status = "No assistant text yet", + read_count = 0, + }, Player) + self.component = { render = function(_, width) return self:render(width) end } + return self +end + +function Player:_invalidate() + local handle = self.mount + if handle then pcall(handle.invalidate, handle) end +end + +function Player:_close_timer(timer) + if not timer then return end + pcall(function() + if timer.is_active and timer:is_active() then timer:stop() end + end) + pcall(function() + if not timer.is_closing or not timer:is_closing() then timer:close() end + end) +end + +function Player:_cancel_timer() + self.timer_token = self.timer_token + 1 + local timer = self.timer + self.timer = nil + self:_close_timer(timer) +end + +function Player:_release() + if self.released then return end + self.released = true + self.active = false + self.paused = false + self:_cancel_timer() + self.playback = nil + self.current_word = nil + local mount = self.mount + self.mount = nil + if mount then pcall(mount.unmount, mount) end + self.mode = nil +end + +function Player:mode_exited(reason, message) + if not self.released then + self.status = reason == "error" and ("Input mode error: " .. tostring(message or "unknown error")) or "RSVP closed" + self:_release() + end +end + +function Player:cancel(reason) + if self.released and not self.active then return end + local mode = self.mode + self.status = reason == "error" and "RSVP stopped after an error" or "RSVP stopped" + self:_release() + if mode then pcall(mode.close, mode) end +end + +function Player:_mode_spec() + local self_ref = self + return { + keys = { + { key = "ctrl+s", desc = "next", run = function() self_ref:next() end }, + { key = "space", desc = "pause/resume", run = function() self_ref:toggle_pause() end }, + { key = "-", desc = "slower", run = function() self_ref:adjust(-1) end }, + { key = "+", desc = "faster", run = function() self_ref:adjust(1) end }, + { key = "escape", desc = "exit", run = function(mode) mode:close() end }, + }, + on_exit = function(reason, message) self_ref:mode_exited(reason, message) end, + } +end + +function Player:activate() + if self.active then return nil, "already_active" end + local mode, why = self.acquire_mode(self:_mode_spec()) + if not mode then return nil, why end + + self.released = false + self.active = true + self.mode = mode + self.status = "Ready" + local ok, mounted = pcall(self.mount_component, self.component, "above_input") + if not ok then + self:cancel("error") + return nil, tostring(mounted) + end + self.mount = mounted + self:next() + self:_invalidate() + return true +end + +function Player:set_source(text, complete, block_index) + self.generation = self.generation + 1 + self.block_index = block_index + self.source = type(text) == "string" and text or "" + self.source_complete = complete == true + self.cursor = 1 + self.read_count = 0 + self:_cancel_timer() + self.playback = nil + self.paused = false + self.current_word = nil + self.status = self.active and "New text available · Ctrl+S" or (self.source == "" and "No assistant text yet" or "Ready") + self:_invalidate() + return self.generation +end + +function Player:update_source(text, complete, block_index) + if block_index ~= self.block_index then return false end + self.source = type(text) == "string" and text or "" + self.source_complete = complete == true + self:_invalidate() + return true +end + +function Player:snapshot_source(text) + self:set_source(text, true, nil) +end + +function Player:_prepared(filtered_source) + local ok, styled = pcall(self.highlight, filtered_source) + if not ok then return nil, "Markdown rendering failed: " .. tostring(styled) end + if type(styled) ~= "string" then return nil, "Markdown rendering failed: expected ANSI text" end + local styled_lines = split_lines(styled) + local source_lines = split_lines(filtered_source) + if #styled_lines ~= #source_lines then + return nil, "Markdown rendering failed: source line boundaries changed" + end + return styled_lines +end + +function Player:_readable(start_segment) + local filtered, lines = filter_source(self.source) + local line_limit = visible_line_count(lines, self.source_complete) + local styled_lines, err = self:_prepared(filtered) + if not styled_lines then return nil, nil, err end + + local readable, skipped_to = {}, math.max(1, start_segment) + for i = 1, line_limit do + local words = tokenize(styled_lines[i].text) + if #words > 0 then + readable[#readable + 1] = { index = i, words = words } + elseif i >= skipped_to then + skipped_to = i + 1 + end + end + return readable, skipped_to, filtered +end + +function Player:_next_candidate(start_segment) + local readable, skipped_to, filtered = self:_readable(start_segment) + if not readable then return nil, nil, filtered end + for rank, paragraph in ipairs(readable) do + if paragraph.index >= start_segment then + paragraph.rank = rank + paragraph.total = #readable + return paragraph, paragraph.index, nil, filtered + end + end + return nil, skipped_to, nil, filtered +end + +function Player:_schedule() + self:_cancel_timer() + if not self.active or self.paused or not self.playback then return end + local timer = self.new_timer() + local token = self.timer_token + local generation = self.generation + local playback = self.playback + self.timer = timer + local delay = math.max(1, math.floor(60000 / self.wpm + 0.5)) + local ok, err = pcall(timer.start, timer, delay, 0, function() + self:_close_timer(timer) + if self.timer == timer then self.timer = nil end + if token ~= self.timer_token or generation ~= self.generation or not self.active + or self.paused or self.playback ~= playback then + return + end + self.timer_token = self.timer_token + 1 + local success, failure = pcall(function() self:_word_interval_complete() end) + if not success then + self:cancel("error") + self.status = "Playback error: " .. tostring(failure) + end + end) + if not ok then + self:_close_timer(timer) + if self.timer == timer then self.timer = nil end + self:cancel("error") + self.status = "Timer error: " .. tostring(err) + end +end + +function Player:_word_interval_complete() + local playback = self.playback + if not playback then return end + if playback.word_index < #playback.words then + playback.word_index = playback.word_index + 1 + self.current_word = playback.words[playback.word_index].text + self:_schedule() + self:_invalidate() + return + end + + self.cursor = playback.index + 1 + self.read_count = self.read_count + 1 + self.playback = nil + self.current_word = nil + self.paused = false + local next_one, skipped_to, err = self:_next_candidate(self.cursor) + if err then + self.status = err + elseif next_one then + self.status = "Paragraph complete · Ctrl+S for next" + elseif self.source_complete then + self.status = "Nothing more to read" + else + self.cursor = math.max(self.cursor, skipped_to or self.cursor) + self.status = "Paragraph complete · waiting for more text" + end + self:_invalidate() +end + +function Player:next() + if not self.active or self.playback then return false end + local paragraph, skipped_to, err, filtered = self:_next_candidate(self.cursor) + if err then + self.status = err + self.current_word = nil + self:_invalidate() + return false + end + if not paragraph then + if self.source_complete then + self.status = "Nothing more to read" + else + self.cursor = math.max(self.cursor, skipped_to or self.cursor) + self.status = "Waiting for the next paragraph" + end + self.current_word = nil + self:_invalidate() + return false + end + + self.cursor = math.max(self.cursor, skipped_to or self.cursor) + self.playback = { + index = paragraph.index, + rank = paragraph.rank, + total = paragraph.total, + words = paragraph.words, + word_index = 1, + filtered_source = filtered, + source_generation = self.generation, + } + self.current_word = paragraph.words[1].text + self.paused = false + self.status = "Playing" + self:_schedule() + self:_invalidate() + return true +end + +function Player:toggle_pause() + if not self.playback then return false end + if self.paused then + self.paused = false + self.status = "Playing" + self:_schedule() + else + self.paused = true + self.status = "Paused" + self:_cancel_timer() + end + self:_invalidate() + return true +end + +function Player:suspend() + if self.playback and not self.paused then + self.paused = true + self.status = "Paused · Space to resume" + self:_cancel_timer() + self:_invalidate() + end +end + +function Player:adjust(direction) + local candidate = self.wpm + self.settings.wpm_step * direction + self.wpm = math.max(self.settings.min_wpm, candidate) + self:_invalidate() + return self.wpm +end + +function Player:_theme_signature() + local ok, style = pcall(self.style) + if not ok or type(style) ~= "table" then return "" end + return tostring(style.welcome or "") .. "\0" .. tostring(style.tool_header or "") +end + +function Player:_refresh_active_theme() + local playback = self.playback + if not playback then return end + local signature = self:_theme_signature() + if signature == playback.theme_signature then return end + local styled, err = self:_prepared(playback.filtered_source) + if not styled then + self.status = err + return + end + local words = tokenize(styled[playback.index].text) + if #words == #playback.words then + playback.words = words + self.current_word = words[playback.word_index].text + playback.theme_signature = signature + end +end + +function Player:_center(text, width) + if width <= 0 then return "" end + local text_width = self.width(text) + if not finite_width(text_width) then text_width = 0 end + if text_width > width then + text = self.truncate(text, width) + text_width = math.min(width, math.max(0, self.width(text))) + end + return string.rep(" ", math.floor(math.max(0, width - text_width) / 2)) .. text +end + +function Player:render(width) + self:_refresh_active_theme() + local word = self.current_word or (self.status:find("failed", 1, true) and "Markdown rendering failed" or "Rapid serial presentation") + local detail + if self.playback then + detail = string.format("%g WPM · paragraph %d/%d · %s", self.wpm, self.playback.rank, self.playback.total, + self.status:find("failed", 1, true) and "styling failed" or (self.paused and "paused" or "playing")) + elseif self.status == "Nothing more to read" then + detail = string.format("%g WPM · %d paragraphs read · %s", self.wpm, self.read_count, self.status) + else + detail = string.format("%g WPM · paragraph %d · %s", self.wpm, self.read_count + 1, self.status) + end + return { self:_center(word, width), self:_center(detail, width) } +end + +M.Player = Player +M.filter_source = filter_source +M.tokenize = tokenize +return M |
