summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--LICENSE18
-rw-r--r--README.md60
-rw-r--r--_selfcheck.lua280
-rw-r--r--init.lua67
-rw-r--r--mise.toml4
-rw-r--r--panto-rsvp-0.1.0-1.rockspec34
-rw-r--r--rsvp/config.lua41
-rw-r--r--rsvp/player.lua519
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/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..876f03c
--- /dev/null
+++ b/LICENSE
@@ -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