summaryrefslogtreecommitdiff
path: root/rsvp/player.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-10-27 16:25:43 -0600
committert <t@tjp.lol>2026-10-27 16:25:47 -0600
commit009d5705a59bcf86f3579a78e25c8b3fcd4cd476 (patch)
treec6496041305da98deac9e40531fbeec0874268f1 /rsvp/player.lua
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.
Diffstat (limited to 'rsvp/player.lua')
-rw-r--r--rsvp/player.lua519
1 files changed, 519 insertions, 0 deletions
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