feat(welcome): greet an unconfigured launch — journey step 4

Implements `docs/journey-stage1b3-welcome-framing.md` (approved at
revision 4, after three review rounds). The last of the 1b split.

`COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, no
cheat sheet reachable from inside the editor, and `M-x` — the only door
in — discoverable only by already knowing about it. A fresh `pmacs` now
greets an untouched `*scratch*` with three lines naming `M-x` and four
real bindings, and `M-x help` renders a cheat sheet.

The startup seam is the substance. No constructor is the right hook:
`EditorState::open` calls `new` before resolving its target, the daemon
constructs one too, `init.lua` runs inside `new`, and desktop restore
happens later still. So `run()`'s terminal-free prefix is extracted into
`prepare_startup`, which `run` delegates to, and the greeting happens
there — after config, after attach dispatch resolves to local, and
after desktop restore. Extracting it is also what makes the wiring
testable: with the greeting called by hand from tests instead, deleting
the production call would leave every assertion green while shipping no
welcome.

Lua owns what is said, Rust owns when and where. `pmacs.welcome.entries`
is a structured list that both renders the text and drives the binding
checks — scraping the rendered prose would be ambiguous, since `C-c c`
is two chords and nothing in the text marks the boundary.

The greeting is deliberately NOT written through
`set_generated_contents`: that would lift read-only, discard history and
mark the buffer generated, all wrong for the buffer journey step 5
requires the user to type into immediately. It is left unmodified so it
does not look like unsaved work.

`M-x help` renders through `editor.describe-command`'s existing `*help*`
mechanism via a new `pmacs.editor._show_help` seam, rather than growing
a second help surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-31 15:54:05 -04:00
parent 2927d2fec1
commit df500b115b
4 changed files with 560 additions and 39 deletions

View File

@ -1259,6 +1259,21 @@ local function show_help_text(text)
pmacs.window.switch_buffer(buf)
end
--- Internal seam (journey Stage 1b-3): render `text` into the shared
--- `*help*` buffer. Exposed under the underscore convention so
--- `runtime/welcome.lua`'s `M-x help` renders through THIS mechanism
--- rather than growing a second help surface — `commands/default.lua`
--- loads before the runtime chunks, so the seam is present by then.
---
--- Inherits this mechanism's two known gaps, recorded rather than
--- papered over: it writes with `delete`/`insert` instead of
--- `set_generated_contents` (so the buffer stays ordinarily editable and
--- keeps its undo history), and it finds `*help*` BY NAME, so a foreign
--- buffer of that name would be cleared.
function pmacs.editor._show_help(text)
show_help_text(text)
end
cmd { name = "editor.describe-command",
description = "Prompt for a command name and render its description in *help*.",
fn = function()

102
builtin/runtime/welcome.lua Normal file
View File

@ -0,0 +1,102 @@
-- welcome.lua --- journey step 4: say something when the editor opens.
-- Framing: docs/journey-stage1b3-welcome-framing.md.
--
-- `COHERENCE.md` §18 graded onboarding "missing entirely": no welcome,
-- no tutorial, no cheat sheet reachable from inside the editor. The sole
-- discovery affordance was knowing to press `M-x`.
--
-- Split of responsibility with Rust: this file owns WHAT is said (the
-- entries and their rendering) and the `help` command; the Rust seam
-- `EditorState::finalize_local_launch` owns WHEN and WHERE — it alone
-- decides that this is a local, no-target launch whose `*scratch*` is
-- still untouched, and it clears the modified flag afterwards (there is
-- no Lua API for that, deliberately).
pmacs.welcome = pmacs.welcome or {}
--- The keys the welcome advertises, in display order.
---
--- `keys` is EXACTLY what `pmacs.keymap.lookup` accepts, which is the
--- whole point of the shape: the acceptance suite checks every entry
--- resolves, so the welcome can never advertise a binding a later stage
--- removed. Scraping the rendered prose instead would be ambiguous —
--- `C-c c` is two chords and nothing in the text marks the boundary.
---
--- Public so a user who rebinds can rebuild it from `init.lua`.
pmacs.welcome.entries = {
{ keys = "C-x C-f", label = "open a file" },
{ keys = "C-c t", label = "terminal" },
{ keys = "C-c c", label = "build" },
{ keys = "C-x b", label = "switch buffer" },
}
-- Two entries per line, padded so the labels align. Kept deliberately
-- small: three lines total, because the greeting a user must delete
-- before typing should not be chrome.
local function entry_columns()
local width = 0
for _, e in ipairs(pmacs.welcome.entries) do
if #e.keys > width then width = #e.keys end
end
local lines, pending = {}, nil
for _, e in ipairs(pmacs.welcome.entries) do
local cell = string.format("%-" .. width .. "s %s", e.keys, e.label)
if pending then
lines[#lines + 1] = " " .. string.format("%-24s", pending) .. cell
pending = nil
else
pending = cell
end
end
if pending then lines[#lines + 1] = " " .. pending end
return lines
end
--- The welcome text, as written into an untouched `*scratch*`.
---
--- `M-x` and `M-x help` are prose rather than entries: `M-x` is the
--- palette itself and `help` is a command name, so neither is a keymap
--- lookup. The acceptance checks the command exists instead.
function pmacs.welcome.text()
local lines = { "Welcome to pmacs. M-x runs any command; M-x help lists the keys." }
for _, line in ipairs(entry_columns()) do
lines[#lines + 1] = line
end
return table.concat(lines, "\n") .. "\n"
end
-- ---------------------------------------------------------------------
-- M-x help
-- ---------------------------------------------------------------------
--
-- The smallest version of §18's second item, included because the
-- welcome would otherwise point at nothing. It is the ROOT of the
-- eventual family: when the discovery arc adds `help.keys` and friends,
-- `help` stays the index they are reached from, so no rename is owed.
--
-- Renders through `editor.describe-command`'s existing `*help*`
-- mechanism rather than growing a second help surface.
local function help_text()
local lines = {
"pmacs help",
"",
" M-x run a command by name",
}
for _, e in ipairs(pmacs.welcome.entries) do
lines[#lines + 1] = string.format(" %-18s %s", e.keys, e.label)
end
lines[#lines + 1] = ""
lines[#lines + 1] = " M-x editor.describe-command what a command does"
lines[#lines + 1] = " M-x editor.list-buffers every open buffer"
lines[#lines + 1] = ""
lines[#lines + 1] = "The full keymap reference is docs/keybindings.md."
return table.concat(lines, "\n") .. "\n"
end
pmacs.command.define {
name = "help",
description = "Show the pmacs key and command cheat sheet.",
fn = function()
pmacs.editor._show_help(help_text())
end,
}

View File

@ -705,6 +705,17 @@ impl EditorState {
include_str!("../builtin/runtime/compile.lua"),
)
.expect("load compile builtin chunk");
// Journey Stage 1b-3: the welcome text and `M-x help`. Loaded
// after `commands/default.lua` (which `attach_editor` above ran)
// so `pmacs.editor._show_help` exists, and after the runtime
// chunks whose keys it advertises, so a binding it names is
// already registered when the acceptance suite checks them.
lua_host
.eval(
Some("@pmacs/builtin/runtime/welcome.lua"),
include_str!("../builtin/runtime/welcome.lua"),
)
.expect("load welcome builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -878,6 +889,73 @@ impl EditorState {
}
}
/// Final step of a **local, no-target** launch: greet an untouched
/// `*scratch*` (journey step 4, `COHERENCE.md` §18).
///
/// Called from [`prepare_startup`] after config has run, after
/// attach dispatch resolved to local, and after
/// [`Self::restore_desktop_if_armed`]. None of the constructors is
/// the right hook: `EditorState::open` calls `new` *before*
/// resolving its target, the daemon constructs one too, and
/// `init.lua` runs inside `new` — so a greeting written there would
/// reach a daemon session, precede the file argument that replaces
/// the buffer, and outrun anything config or a restored desktop puts
/// in `*scratch*`.
///
/// Greets only when all four hold; each excludes one of those cases:
///
/// 1. `had_file` is false — a positional argument means "open this".
/// 2. the session is local — guaranteed by the call site.
/// 3. `*scratch*` is the active buffer — restore may have moved it.
/// 4. `*scratch*` is empty — never overwrite config or a restore.
///
/// Leaves the buffer **unmodified**: the greeting must not look like
/// unsaved work. It is deliberately *not* written through
/// `set_generated_contents`, which would lift read-only, discard
/// history and mark the buffer generated — all wrong for a buffer
/// journey step 5 requires the user to type into immediately.
pub fn finalize_local_launch(&mut self, had_file: bool) {
if had_file {
return;
}
let buffer_id = self.core.borrow().active_buffer_id();
{
let registry = self.core.borrow().registry.clone();
let reg = registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
return;
};
if buf.name() != "*scratch*" || !buf.is_empty() {
return;
}
}
let text: String = match self
.lua_host
.lua()
.load("return pmacs.welcome.text()")
.eval()
{
Ok(text) => text,
// A user who replaced `pmacs.welcome` with something broken
// gets no greeting, not a failed launch.
Err(_) => return,
};
let registry = self.core.borrow().registry.clone();
let mut reg = registry.borrow_mut();
let Ok(buf) = reg.get_mut(buffer_id) else {
return;
};
if buf
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: text.as_bytes(),
})
.is_ok()
{
buf.mark_clean();
}
}
/// Restore the session saved under this desktop's key, if armed
/// (`pmacs.session.desktop_mode(true)` called it) and no positional
/// file arg was given (Q#DS7). Called from the `RunLocal` arm of
@ -3590,6 +3668,68 @@ impl Default for EditorState {
// Run loop
// ---------------------------------------------------------------------------
/// Outcome of [`prepare_startup`]: either a session ready for the local
/// TUI loop, or a hand-off the caller must perform.
pub enum Startup {
/// Ready to enter the local loop — desktop restored, launch
/// finalized.
Local(Box<EditorState>),
/// The init-time attach request resolved to something other than
/// local. Performing it takes over the terminal, so it stays out of
/// [`prepare_startup`] and is the caller's job.
HandOff(crate::attach_dispatch::AttachDispatch),
}
/// Everything [`run`] does **before** it touches the terminal:
/// construct from the target, install state dirs, dispatch the
/// init-time attach request, and — on the local path — restore the
/// desktop and finalize the launch.
///
/// Extracted so the local-startup sequence is testable: `run` adds only
/// `Frontend::new()` and the event loop, which is where terminal
/// takeover genuinely lives. Without this split, deleting the
/// [`EditorState::finalize_local_launch`] call would leave every
/// direct-call test green while shipping no welcome at all.
///
/// `pub` rather than `pub(crate)` because the journey acceptance suite
/// is a separate integration crate — and because the rest of this
/// sequence (`run`, `EditorState::new`, `EditorState::open`,
/// `install_state_dirs`, `restore_desktop_if_armed`) is already public,
/// so this completes that surface rather than widening it.
pub fn prepare_startup(file: Option<PathBuf>) -> io::Result<Startup> {
// Capture before the `match` consumes `file`: a positional file arg
// means "open this", not "restore my desktop" (Q#DS7).
let had_file = file.is_some();
let mut state = match file {
Some(path) => EditorState::open(path)?,
None => EditorState::new(),
};
// Real session: wire up on-disk persistence (history + pmacs.state).
state.install_state_dirs();
// Post-init dispatch: read whatever init.lua left in the
// RequestedAttach slot and decide whether to run local or hand off
// to attach mode. `take_requested_attach` consumes the slot.
let requested = state.lua_host.take_requested_attach();
match crate::attach_dispatch::dispatch_attach(requested) {
crate::attach_dispatch::AttachDispatch::RunLocal => {
// Committed to local mode: restore the desktop if armed and
// no file arg was given (Q#DS7). Done here, not right after
// construction, so a hand-off never populates an
// EditorState it's about to drop.
state.restore_desktop_if_armed(had_file);
// Journey step 4: the last thing before the loop, so config
// and any restored desktop have already had their say.
state.finalize_local_launch(had_file);
Ok(Startup::Local(Box::new(state)))
}
// The EditorState is dropped by the caller before it takes over
// the terminal: attach mode constructs its own Frontend, and a
// locally-built one would leak its alternate-screen / raw-mode
// setup if held across the call.
other => Ok(Startup::HandOff(other)),
}
}
/// Main run loop. Opens the file (if any), takes over the terminal,
/// renders, dispatches keys, until the user quits.
///
@ -3611,54 +3751,22 @@ impl Default for EditorState {
/// local-TUI for the terminal.
pub fn run(file: Option<PathBuf>) -> io::Result<()> {
install_panic_hook();
// Capture before the `match` consumes `file`: a positional file arg
// means "open this", not "restore my desktop" (Q#DS7).
let had_file = file.is_some();
let mut state = match file {
Some(path) => EditorState::open(path)?,
None => EditorState::new(),
};
// Real session: wire up on-disk persistence (history + pmacs.state).
state.install_state_dirs();
// Post-init dispatch: read whatever init.lua left in the
// RequestedAttach slot and decide whether to run local or hand
// off to attach mode. `take_requested_attach` consumes the slot
// — even on the hand-off path the local EditorState is dropped
// before attach::run_attach takes over the terminal, so the
// request is consumed exactly once.
let requested = state.lua_host.take_requested_attach();
match crate::attach_dispatch::dispatch_attach(requested) {
crate::attach_dispatch::AttachDispatch::RunLocal => {
// Committed to local mode: restore the desktop if armed and
// no file arg was given (Q#DS7). Done here, not right after
// construction, so a hand-off to attach mode (above) never
// populates an EditorState it's about to drop.
state.restore_desktop_if_armed(had_file);
// Fall through to the local TUI loop below.
}
crate::attach_dispatch::AttachDispatch::RunAttachLocalSocket(socket) => {
// Drop the local EditorState before taking over the
// terminal: attach mode constructs its own Frontend, and
// the locally-built one would leak its alternate-screen
// / raw-mode setup if held across the call.
drop(state);
let mut state = match prepare_startup(file)? {
Startup::Local(state) => *state,
Startup::HandOff(crate::attach_dispatch::AttachDispatch::RunAttachLocalSocket(socket)) => {
return crate::attach::run_attach(socket).map_err(|e| io::Error::other(format!("{e}")));
}
crate::attach_dispatch::AttachDispatch::RunAttachSsh(target) => {
// Same EditorState-drop reasoning as the local-socket
// path: SSH attach takes over the terminal.
drop(state);
Startup::HandOff(crate::attach_dispatch::AttachDispatch::RunAttachSsh(target)) => {
return crate::attach::run_attach_ssh(target)
.map_err(|e| io::Error::other(format!("{e}")));
}
dispatch @ crate::attach_dispatch::AttachDispatch::DeferredInV01 { .. } => {
Startup::HandOff(dispatch) => {
let msg = dispatch
.deferred_message()
.expect("DeferredInV01 always has a message");
.unwrap_or_else(|| "unsupported attach dispatch".to_owned());
return Err(io::Error::other(msg));
}
}
};
let mut frontend = Frontend::new()?;
let mut render_state = crate::instance_render::RenderState::new(frontend.size());

View File

@ -1742,3 +1742,299 @@ fn journey_step9_the_compile_directory_is_detection_canonical() {
"the header must name the directory detection resolved to;\n{text}"
);
}
// ---------------------------------------------------------------------------
// Step 4 — understand the visible interface (Journey Stage 1b-3)
//
// `COHERENCE.md` §18 graded onboarding "missing entirely": no welcome,
// no cheat sheet reachable from inside the editor, and `M-x` the only
// door in — which a new user has no way to learn about.
//
// These rows drive `prepare_startup`, the production call `run()` makes.
// Calling `finalize_local_launch` by hand instead would leave the wiring
// unpinned: deleting the one call inside `prepare_startup` would keep
// every other assertion here green while shipping no welcome at all.
// ---------------------------------------------------------------------------
/// The `*scratch*` buffer's text, wherever it currently sits.
fn scratch_text(s: &EditorState) -> String {
eval(
s,
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == "*scratch*" then
return id:slice(0, id:len())
end
end
return ""
"#,
)
}
fn welcome_entries(s: &EditorState) -> Vec<(String, String)> {
let raw: Vec<String> = eval(
s,
"local out = {}
for _, e in ipairs(pmacs.welcome.entries) do
out[#out + 1] = e.keys .. '\\1' .. e.label
end
return out",
);
raw.into_iter()
.map(|row| {
let (keys, label) = row
.split_once('\u{1}')
.expect("entry encodes keys and label");
(keys.to_owned(), label.to_owned())
})
.collect()
}
/// Drive the production startup path with no target, as `pmacs` does.
fn start_local() -> EditorState {
match pmacs::editor::prepare_startup(None).expect("startup must not fail") {
pmacs::editor::Startup::Local(state) => *state,
pmacs::editor::Startup::HandOff(_) => panic!("no init.lua attach request in a test"),
}
}
/// **N** — journey step 4: a no-target local launch greets.
///
/// Falsified by deleting the `finalize_local_launch` call inside
/// `prepare_startup` — the mutation every by-hand pin would survive.
#[test]
fn journey_step4_a_no_target_launch_greets_in_scratch() {
let s = start_local();
// Preconditions asserted, not assumed (framing §3.2b): a developer
// whose real init.lua arms desktop mode would otherwise get a
// restored scratch and a silently different result.
assert!(
eval::<bool>(
&s,
"return pmacs.session == nil or pmacs.session.desktop_armed ~= true"
),
"precondition: desktop restore must be unarmed for this pin to mean anything"
);
assert_eq!(active_name(&s), "*scratch*");
let text = active_text(&s);
assert!(
!text.is_empty(),
"an unconfigured launch must say something"
);
assert!(
text.contains("M-x"),
"and must name the one key that opens everything; got {text:?}"
);
}
/// **N** — every entry the welcome names is actually bound.
///
/// A property over the structured list, not a scrape of prose: `C-c c`
/// is two chords and nothing in the rendered text marks the boundary.
#[test]
fn journey_step4_every_advertised_key_is_bound() {
let s = start_local();
let entries = welcome_entries(&s);
assert!(
!entries.is_empty(),
"precondition: the entry list must be non-empty or this loop is vacuous"
);
for (keys, label) in entries {
let bound: Option<String> = eval(
&s,
&format!("local b = pmacs.keymap.lookup({keys:?}) return b and b.command"),
);
assert!(
bound.is_some(),
"the welcome advertises {keys:?} ({label}) but nothing is bound to it"
);
}
}
/// **N** — the rendered text contains every entry.
///
/// Pin 2 alone would pass if rendering silently dropped one.
#[test]
fn journey_step4_the_rendered_welcome_contains_every_entry() {
let s = start_local();
let text = active_text(&s);
for (keys, label) in welcome_entries(&s) {
assert!(
text.contains(&keys),
"welcome text omits the key {keys:?}; got {text:?}"
);
assert!(
text.contains(&label),
"welcome text omits the label {label:?}; got {text:?}"
);
}
}
/// **N** — `M-x help` renders the cheat sheet, reached the way a user
/// reaches it.
///
/// `pmacs.command.invoke` is the *programmatic* API; M-x is
/// `editor.execute-command`, a minibuffer with the `commands` completion
/// source that calls `invoke_interactive` only on accept.
///
/// The selection is asserted **before** RET: a selected candidate
/// shadows typed text, and `Minibuffer::accept` does `session.take()`,
/// so nothing about the accepted value survives afterwards.
#[test]
fn journey_step4_m_x_help_renders_the_cheat_sheet() {
let mut s = start_local();
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('x'), KeyModifiers::ALT),
);
assert!(
eval::<bool>(&s, "return pmacs.minibuffer.is_active()"),
"M-x must open the command palette"
);
for ch in "help".chars() {
type_char(&mut s, ch);
}
assert_eq!(
eval::<Option<String>>(&s, "return pmacs.minibuffer.selected()").as_deref(),
Some("help"),
"the completion source must have `help` selected; a different \
candidate would accept a different command"
);
press(&mut s, KeyCode::Enter);
pump(&mut s);
let help = named_text(&s, "*help*");
for (keys, _) in welcome_entries(&s) {
assert!(
help.contains(&keys),
"the cheat sheet omits {keys:?}; got:\n{help}"
);
}
}
/// **P** — the greeted buffer is editable and unmodified.
///
/// Targeted mutation: rendering through `set_generated_contents`, which
/// would lift read-only, discard history, and fail the insert.
#[test]
fn journey_step4_preservation_the_greeted_scratch_is_editable_and_clean() {
let mut s = start_local();
assert!(
eval::<bool>(
&s,
"return pmacs.describe.buffer(pmacs.window.buffer()).modified == false"
),
"a greeting must not look like unsaved work"
);
let before = active_text(&s).len();
type_char(&mut s, 'X');
assert!(
active_text(&s).len() > before,
"step 5 must still work from the first frame: typing inserts"
);
}
/// **P** — a file target does not greet.
#[test]
fn journey_step4_preservation_a_file_target_does_not_greet() {
let td = project();
let path = td.path().join("alpha.txt");
let s = match pmacs::editor::prepare_startup(Some(path.clone())).expect("startup") {
pmacs::editor::Startup::Local(state) => *state,
pmacs::editor::Startup::HandOff(_) => panic!("no attach request"),
};
assert_eq!(active_name(&s), path.display().to_string());
assert_eq!(
scratch_text(&s),
"",
"a positional argument means \"open this\", not \"greet me\""
);
}
/// **P** — a directory target does not greet either, so Stage 1a's
/// dired listing is what the user sees.
///
/// Separate from the file pin because the directory path reaches
/// `*scratch*` differently: the bootstrap replaces the window's buffer
/// and `replace_active_buffer` removes nothing, so the scratch buffer
/// still exists to be wrongly greeted.
#[test]
fn journey_step4_preservation_a_directory_target_does_not_greet() {
let td = project();
let mut s =
match pmacs::editor::prepare_startup(Some(td.path().to_path_buf())).expect("startup") {
pmacs::editor::Startup::Local(state) => *state,
pmacs::editor::Startup::HandOff(_) => panic!("no attach request"),
};
pump(&mut s);
assert!(active_name(&s).starts_with("*dired:"));
assert_eq!(scratch_text(&s), "", "the dired listing is the greeting");
}
/// **P** — a non-empty `*scratch*` is never overwritten.
#[test]
fn journey_step4_preservation_existing_scratch_content_survives() {
let mut s = EditorState::new();
exec(&s, "pmacs.window.buffer():insert(0, 'user content')");
s.finalize_local_launch(false);
assert_eq!(
active_text(&s),
"user content",
"config or a restored desktop owns whatever is already there"
);
}
/// **P** — a non-active `*scratch*` is not greeted. Stands in for a
/// desktop restore having put something else in front.
#[test]
fn journey_step4_preservation_a_backgrounded_scratch_is_not_greeted() {
let td = project();
let mut s = EditorState::new();
exec(
&s,
&format!(
"pmacs.window.display_file({:?})",
td.path().join("alpha.txt").display().to_string()
),
);
pump(&mut s);
s.finalize_local_launch(false);
assert_eq!(
scratch_text(&s),
"",
"only the buffer actually greeting the user is written to"
);
}
/// **P** — the constructors greet nothing on their own.
///
/// This is what makes the seam the only writer, and would catch a
/// greeting smuggled back into a constructor — including the daemon's.
/// Necessary but not sufficient: pin 1 is what proves the seam is
/// reached in production.
#[test]
fn journey_step4_preservation_constructors_never_greet() {
let bare = EditorState::new();
assert_eq!(scratch_text(&bare), "", "EditorState::new must not greet");
let td = project();
let file = EditorState::open(td.path().join("alpha.txt")).expect("open file");
assert_eq!(
scratch_text(&file),
"",
"EditorState::open(file) must not greet"
);
let mut dir = EditorState::open(td.path().to_path_buf()).expect("open dir");
pump(&mut dir);
assert_eq!(
scratch_text(&dir),
"",
"EditorState::open(dir) must not greet"
);
}