npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@openthink/pablo-tui

v0.4.0

Published

pablo's full-terminal screen (Ink), a third front end beside the CLI and MCP.

Readme

@openthink/pablo-tui

pablo's full-terminal screen, on Ink: a third front end on packages/cli/src/verbs.ts beside the CLI and MCP. Bare pablo in a project opens it. The design is the screen doc of the ai-terminal project in the maintainer's private tracker (pm project show ai-terminal --doc screen, if you have pm access; otherwise this README, the code and its tests are the reference). The layout, keys and panel machinery are copied and adapted from prview, never shared with it.

src/state.ts     the state model: modes, focus, cursors, scroll, the pending prefix; `reduce(state, action)`
src/keys.ts      the key rows (data): states, primary and secondary keys, prefixes, what each does; overrides and conflicts
src/chord.ts     a keypress, in a state with a prefix pending, to actions; Ink's keys to tokens
src/panel.ts     the key panel's entries and layout, from the rows
src/key-panel.tsx  the key panel as a component the layout places
src/key-config.ts  `keys` in $XDG_CONFIG_HOME/pablo/config.json (default ~/.config/pablo/config.json), laid over the defaults
src/settings.ts  the settings screen's rules (`\`): capture a key, refuse a conflict, type the editor command, save the config
src/settings-view.tsx  the settings screen as a component
src/revise.ts    `a r`: the `Reviser` seam the cli passes in, and a keypress while a revise is open as an action
src/compose.ts   the compose view's pure parts: the `Composer` seam, the conversation as lines, geometry, its keys
src/compose-view.tsx  the compose view as a component (full screen)
src/app.tsx      the Ink root: reads the state, draws it, dispatches actions from keys
src/screen.tsx   mounts the app in the alternate screen and restores the terminal
src/layout.ts    the layout geometry, pure: region sizes at a terminal size, text wrapping, the `measured` payload
src/status.ts    the status area's fields (format, progress, branch, comment counts) and which drop when narrow
src/resize.ts    the terminal size as state (below the model: plumbing, not where the author is)
src/sanitize.ts  strips control characters from text that did not come from the keyboard
src/buffer.ts    the editor's own text buffer (D-23), pure: grapheme cursor, cell-width soft-wrap, word-grouped undo with undo groups
src/edit-pane.ts `v e` in place, pure: the `PaneSession` seam, the file to the buffer and back (`saveText`), rows, keys
src/edit-modes.ts      focus and typewriter modes of the editing pane: paragraph and scroll rules, the toggles' config entry
src/edit-pane-view.tsx  the editing pane as a component, drawn where the main pane is
src/modal.ts     the editing pane's Helix-style modes (normal, insert, select) and motions, pure
src/inline-diff.ts  AI revisions inline in the editing pane (AGT-2177), pure: a revise branch's edits placed, accepted, rejected
src/operators.ts the editing pane's operators (`d c y p P x r`), registers and `u`/`U`, pure
src/word-count.ts the editor's live word count and word targets (AGT-2175): pure parts and `useWordTargets`, the status line's `words` field
src/document.ts  the main pane's text, pure: row id -> file, frontmatter hidden, sentence lines joined and wrapped
src/source.ts    reads the file behind a rail row id from the project (the loader `App` is given as `load`)
src/index.ts     the package's public surface (`runScreen`); the CLI imports it lazily, only for bare `pablo`
src/book.ts      book mode's table of contents: the CLI's stage results as rail rows (plain data, no CLI import)
src/selection.ts the sentence selection as highlight pieces and the stored lines `a r` / `a v` act on
src/hits.ts      `check` hits in the main pane: each hit's box under its line, and the `g f` / `g F` walk
src/comment-box.ts  a comment as a three-row box under its line (check hits and review comments share it)
src/comment-input.ts  `c` in a review: a keypress while a comment is being typed, as an action
src/voice-input.ts  `a v r`: a keypress while a voice rule is being typed, as an action
src/activity.ts  a tool call and a duration as short phrases for the busy line and the compose view
src/review.ts    review mode's data: a branch's diff (handed over by the CLI) as rail rows and pane lines
src/stitch.ts    re-exports core's stitcher (`packages/core/src/diff/stitch.ts`) so review mode has one import path

The layout

Top to bottom: the status area (title, then fields), the middle (the rail and the main pane), the bottom panel (the content area beside the key panel) and a one-row footer. layout.ts computes every region's size from the terminal's columns and rows (layoutOf), tested at 60x20 to 200x60 with no terminal. Under 100 columns the rail is a narrow strip; view.zen hides it; view.full gives the bottom panel the screen. app.tsx reads the layout and, whenever the size or the content changes, dispatches measured so the model keeps every cursor in view; a resize relayouts without a restart. The key panel (AGT-1524) sits in the bottom panel at the width and height the layout gives it.

The main pane

The main pane shows the file behind the rail's selected row (document.ts maps the id: premise, bible, acts and beats, chapter:N, or a project-relative path), frontmatter hidden and control characters stripped. Chapters are stored one sentence per line; the pane joins each run of non-blank lines into a paragraph (core joinManuscript) and wraps it to the pane, so a chapter reads the same before and after the split. main.loaded carries the document's id: a different document starts at its top, the same one keeps the line. g g / g e and the arrows move the cursor and the view follows it. A missing file shows as a notice ("Chapter 3 has no draft yet."), not an error.

Selecting sentences

⇧↓ / ⇧↑ in the main pane select by whole sentence: the first press takes the sentence under the cursor, each further one moves the selection's head by a sentence (back toward the anchor shrinks it), and the cursor follows the head. The sentences are the core splitter's over each prose paragraph, found once, in document.ts displayDoc (which also does the wrapping and the check anchors, so there is one display mapping): each sentence carries the display lines it covers and the stored lines it came from, and each display line its sentence marks. Headings, lists, tables and fences are drawn but never selected, and a check box is never a sentence (hits.ts mainPane re-spans the sentences into rows, so a box inside a wrapped sentence is within its span but not part of the selection's text). The model holds only selection (anchor and head, as indexes) and each sentence's row span (main.loaded carries them); selection.ts turns that into the highlighted pieces of each line (blue behind the words, a ▌ in the margin) and into what a command receives: onCommand(command, selected) gets the sentences' text and the stored lines they span (0-based, inclusive, counted in the file as stored), which is what a r (revise, below) and a v (voice) act on. Plain moves keep the selection; Esc clears it before it backs out of anything else; a different document, or one whose sentence count changes, drops it. In a review the pane shows changes, not sentences, so nothing is selectable there.

a v: the voice

a v puts the selected sentences up in the content area (none selected is fine: see r) with the choices in keys.ts VOICE_CHOICES, also listed in the key panel while the offer is up: f flags them (a rejected tell, written as a Flagged: line), e keeps them (an exemplar), r types a rule. f and e need a selection and do nothing without one. Esc withdraws the offer before it clears the selection.

r (AGT-1594) opens a one-line input in the content area, like a review comment: typing fills it, Backspace deletes, Enter writes, Esc cancels. Tab changes where the rule goes, the two VOICE_TARGETS in keys.ts (the default first): the voice's own rules file (the vault's style/prose.md for fiction, else the voice's voice.md) or this work's STYLE.md. Either way it is a bullet under a ## Rules section (a work's ## Ground rules section is used when it has one), created if missing, and committed. With sentences selected the bullet is followed by Flagged: "<selection>", so pablo check catches the line (check reads Flagged: lines from style/prose.md, not from STYLE.md: under the work target the line documents the example for the writer and the author). State: voice (the offered sentences, possibly none) and voiceRule (sentences, the targets, which one, the text); the choices and targets reach the model as data in voice.offer / voice.rule, so state.ts imports nothing.

The screen does not write the voice: ScreenOptions.voicer is the CLI's screenVoicer (screen-voice.ts), which calls flagLine / addExemplar / addRule, the functions behind voice flag / voice exemplar / voice rule, on the voice the project's pablo.json names (fiction, the vault's style/, unless it points into voices/<name>); a rule is voicer("rule", sentences, { text, target }). The content area then says where it was written. A fiction voice has no exemplars/, so e there is refused with that reason. Actions: the voice.* ones.

Check hits and comment boxes

Opening a chapter (a MainDoc with a file) runs the checks the CLI passes in (screenChecks, core-free checkFile with the vault's rules) over its raw text. hits.ts mainRows lays each hit's box under the display line its file line ends on (document.ts displayDoc anchors: sentence lines are joined and wrapped, so the file's line numbers are mapped, not assumed). A box is three ordinary rows of the pane (comment-box.ts commentBox), so the pane windows and moves over them like any line; the cursor can rest on them. → on a hit's line or box opens its rule and flagged pattern in the content area (hitDetail); g f / g F jump to the next / previous box, wrapping. Those keys are commands the app handles itself (check.open, check.next, check.prev) and use the model's existing main.goto, so the model gained no action. comment-box.ts knows nothing about checks: review mode shows a comment from any source (critic, reader, author; the cli's comment store .pablo/comments/<branch>.json, AGT-1580) through it, the body wrapped (maxLines).

The compose view

a c opens the compose view (AGT-1566, the harness's front end; design in the maintainer's pm project show ai-terminal --doc harness, if you have pm access): the whole screen under the status area is the conversation with pablo, an activity line and an input box. Typing fills the input (q and the prefixes are text here), Enter sends, the arrows and PgUp/PgDn scroll back, Esc returns to the mode it was opened from. The conversation is state (compose in state.ts: entries, input, busy, activity, session id), so leaving the view loses nothing: a reply still streaming in lands in it, the footer says pablo is working, and a c comes back to it.

The session itself is behind a seam. This package does not depend on the Agent SDK; runScreen({ composer }) takes a Composer (compose.ts): send(message) yields ComposeEvents up to the turn's result, close() ends the session. The cli builds it (packages/cli/src/harness/compose.ts, one SDK query fed by a streaming prompt). Tests hand the screen a fake Composer; nothing starts Claude. Entries are data (ComposeEntry): an author line, pablo's text, a tool call with its result, an error, and the question card for ask_author (AGT-1560). The card arrives as a question event, is drawn in place, and the author's next line answers it (a number picks an option): the model hands the answer to Composer.answer, and the turn's stream carries on. A new entry kind is a variant and a case in composeLines; compose.add appends one from outside the stream.

Links to review (AGT-1569)

Compose produces branches; review accepts them. The branches a tool result names (plan/, draft/ or revise/ names, read off successful tool_result events in the reducer: Compose.branches, each once, in order) are listed under the conversation, up to three rows (branchesH takes them from the conversation's rows). Tab moves the keys into the list (compose.pick, the newest picked; nothing is typed there), the arrows move, Enter (compose.open_branch) opens the branch in review mode, and Tab or Esc return to the input. The review it opens carries back (the place compose was opened from), so Esc there (review.close) returns to compose with its conversation, session and branch list as they were, and a second Esc goes on to the book. review.open itself stays a no-op while compose is open: compose hands off through compose.open_branch.

The state model

state.ts is pure: no Ink, no React, no imports at all. It holds where the author is and what is open, and nothing else: the rail's rows are an index (an id, a depth, whether the row folds), the main pane knows how many lines it has, the content area what it shows. The documents are props of the Ink layer. Every change is a named action through reduce, so each one is tested without a terminal (test/state.test.ts), and test/boundary.test.ts holds the Ink layer to one useReducer over the model with no useState or useRef of its own: a component that needs to remember something dispatches an action instead.

mode      { kind: "book" } | { kind: "review", branch }   each mode keeps its own rail and main pane
          { kind: "settings", from }                      over a book or review; its model is `settings`, no rail or main pane
saved     the bindings and editor a save put in force, over what the screen opened with
pane      "rail" | "main"                                   where the cursor is
focus     the pane, or "content" while Tab has moved focus into the content area
content   the one thing the bottom panel shows, with its scroll
pending   a prefix waiting for its second key, with the digits of a `g <n>` line number
zen       the rail hidden;  full   the content area taking the screen

Actions are <region>.<verb>: rail.down, main.goto, content.show, focus.back, prefix.press, view.zen, review.open, escape, measured. The layout tells the model how many rows each region shows through measured, and the model keeps every cursor in view; it never wraps or measures text itself.

Adding an action

  1. Add its shape to the Action union in state.ts and its case to reduce (the switch is exhaustive: TypeScript refuses a missing case). An action that cannot apply where the screen is returns the state unchanged.
  2. Test it in test/state.test.ts, applied to a state built by earlier actions, and add it to the every table there so it is known to apply anywhere.
  3. Bind a key to it: a row in DEFAULT_ACTIONS in keys.ts (see "Adding a key"), and draw whatever it changes from the state in app.tsx. Nothing about it lives in the component.

Adding a mode

A mode is a variant of Mode with its own View (a rail and a main pane) on State, the way book and review are. Add the variant, the view field, the actions that enter and leave it (review.open / review.close are the pattern: entering resets the view, leaving restores the pane and focus), extend viewOf and withView in reduce, and give escape its place in the backing-out order. The rail, main pane, content and prefix actions then work in it unchanged, because they act on viewOf(state).

Review mode

Book mode lists the branches waiting for review (draft/, revise/, edit/, reader/ with commits main lacks) as rail rows branch:<name> under a branches to review group. Enter (rail.open, or → on the row) opens one: review.open. The CLI owns git (branch.ts: waitingBranches, branchDiff) and passes the screen branches and diffOf; this package only parses and lays out (review.ts). The rail then lists the changes grouped by file (file:<path>, edit:<n>), and the main pane shows the edit under the cursor: removed and added sentences with the differing words marked, one line of context either side. The edits come from core's stitcher (packages/core/src/diff/stitch.ts, re-exported by stitch.ts): rules behind one Stitcher interface, so a learned grouping can replace them later. Adjacent changed sentences are one edit, a changed edit gets word marks (a mostly new sentence is shown whole), a moved paragraph is one move (touched on the way, its changed sentences marked), and every changed line belongs to exactly one edit (removedLines/addedLines), which is what accepting or rejecting an edit acts on. Esc closes the review back to the book where it was left. y accepts and n rejects the change under the rail's cursor (review.mark, a model action: state.marks, by change id, fresh on each open); the same key again clears the mark, the other one changes it. The rail shows ✓ or ✗ before a decided change and the status area counts accepted, rejected and pending (reviewCounts). s finishes (review.finish, handled in app.tsx): it refuses while a change has no decision (finish.failed, "n changes have no decision yet"), otherwise calls the finisher prop with the branch and the lines the rejected edits own (removed by old line number, added by new: the stitcher's removedLines/addedLines, never edit ids). cli.ts builds the finisher (review-finish.ts: a commit on the branch reverts the rejected lines, then the branch merges through mergeChanges, the after-write steps run, the branch and its worktree are deleted); everything rejected discards the branch with no merge. finish.start shows "Finishing…", finish.done closes the review with the merge and each step in the content area and drops the branch from the book, finish.failed stays in the review with the reason. Edit is a command the layer above has yet to handle. Every string from a branch (its name, the diff text) passes clean() before it is shown.

The author's own comments (AGT-1581)

c (review.comment, a command app.tsx handles; a row in keys.ts like any other, so the key panel lists it and the settings rebind it) opens a one-line input in the content area for the change under the rail's cursor: comment.open puts state.commenting (the branch, the change's path and first line, the buffer) up and, while it is, keys are text (comment-input.ts) and Esc cancels. Enter saves through the commentSaver prop (cli.ts passes screenCommenter, which stores a source: author entry in the branch's comment store, .pablo/comments/<branch>.json); comment.saved bumps commentSeq, which makes the review read commentsOf again, so the box is in the pane at once. Finishing the review (s) appends the branch's author comments with the text of their lines to the chapter's dated note (notes/<date>-chapter-NN.md, the file the note step writes, so they are committed on main); a branch discarded by rejecting everything writes them nowhere. The branch's store goes with the branch either way.

Writing from the screen

a w on a chapter:N row (ai.write, book mode only) calls the writer prop, which cli.ts builds from runWrite (screen-write.ts) and passes through runScreen's options; this package never imports the CLI. write.start puts a "Writing chapter N" panel in the content area, each progress line the writer reports is write.progress (the panel keeps the latest few), and the end is write.done (the branch and the receipt lines: the review opens on that draft/ branch, the receipt stays up until Esc, and the branch is added to the branches waiting) or write.failed (the refusal and its missing reasons, in book mode). One write runs at a time; off a chapter row a w says to select one.

Revising from the screen

a r (ai.revise, handled in app.tsx) revises the sentences ⇧↓ selected (AGT-1544). It needs a chapter open, a selection and the reviser prop (cli.ts builds it from screen-revise.ts; this package never imports the CLI); otherwise the content area says what is missing. The flow is a revise slice of the model with four phases:

  1. ask: the content area asks what should change and the typed text is the instruction (keys are text while a revise is open: q and the prefixes are letters; ← → move the cursor, Backspace deletes). Enter is revise.run; Esc cancels.
  2. running: the reviser.revise call runs the CLI's reviseCore over the selected stored lines and the candidate so far streams in (revise.partial). A failure returns to ask with the instruction kept and the reason shown.
  3. edit: the candidate (revise.done) fills the content area, which takes the whole screen, with a cursor; typing, Backspace and ← → edit it, Ctrl-N puts in a paragraph break, and the original and the instruction show below it. Esc discards it. Nothing is written yet.
  4. Enter is Take (revise.take): reviser.take makes revise/<short-id> (the first seven characters of the receipt hash; -v2 on a clash), replaces the selected lines in the chapter with the candidate split one sentence per line, and commits as the model with its receipt (as the project's author, with "(edited)" in the subject, when the candidate was changed). revise.taken opens review mode on the branch, as a write does, and lists it as waiting.

A reply that arrives for a cancelled revise is ignored (each one has an id). One revise or write runs at a time. The CLI strips a model's whole-answer quote wrapping (normalizeCandidate, core) and joins sentence lines before the model sees them; a selection that stops mid-line keeps the rest of that line around the candidate.

Keys

Keys are data (keys.ts), copied from prview's key map and free to drift from it. A row has an id, the states it acts in (rail, main, content, narrowed by needs: in a review, outside one (book), or with the content area open), a primary key, an optional secondary key, a label and a do: a model action, or a Command for the layer above (quit is the app's; the rest reach onCommand). a, f, v, g, r, o are prefixes (o, AGT-2179: o a add output, o c critique, o k check, o p publish, o e/x/u voice edit/remove/use, o s / o S config show/set; each runs the CLI verb through verbRunner, asking for its arguments in the same one-line input as r): a row with prefix is that prefix's second key, and the key panel shows the second keys while the prefix is pending. Esc is not a row: it is always the model's escape. Tab is fixed.

r runs the planning and research verbs (AGT-2178), each its own row in keys.ts: r c save a core doc, r p research plan, r s research save, r a research add, r w research waive, r l research link, r d research distill, r f fact record. A verb that takes args asks for one line in the content area (the hint says its shape; Enter runs it, Esc cancels); r p and r d run at once. The planner prop (cli.ts builds it from the same in-project functions the verbs call, screen-planning.ts) runs them, each on a plan/ branch for review; without one a key says it is not available. The panel still lists exactly what acts. The panel lists exactly the rows that act in the state, because it reads the same rows the key handler resolves through (chord.ts).

Overrides live in $XDG_CONFIG_HOME/pablo/config.json (default ~/.config/pablo/config.json) as "keys": { "rail.down": "n", "main.page_down": { "primary": "pgdn", "secondary": "" } }. A conflict in one state, an unknown or fixed action, or Esc/Tab as a binding is refused when the screen opens, with the reason on stderr; the screen does not start on a bad config.

Settings

\ opens the settings over the book or review (settings.open) and Esc closes it again (settings.close). While it is open the screen takes every key itself (settings.ts, not the chord), so a key pressed to rebind an action does not also run it. Every action's keys are listed; Enter on one captures the next key as its primary (→ first for the secondary, Backspace clears a secondary), and a key that clashes in one of the action's states is refused on the spot, with the same check that refuses a bad keys at startup. The last line is the editor command ("editor" in the config, what e in a review runs; empty means $EDITOR, else hx). Esc with changes asks to save: y writes keys and editor into $XDG_CONFIG_HOME/pablo/config.json (default ~/.config/pablo/config.json; every other entry is kept, a default key's entry is removed), and the new keys act at once; n discards. The file is re-parsed before it is written, and a save that would not read back writes nothing.

The model is data in state.ts (SettingsModel) and reaches it as whole values (settings.set); the rules that produce the next value are in settings.ts, so state.ts stays import-free.

Adding a key

Add a row to DEFAULT_ACTIONS with its do; the panel, the chord and the overrides pick it up. Add its action first if it is a model action (above). A row that needs a command gets one in the layer that owns the work, handled from onCommand; until it is built the key is bound and does nothing. Keep a test in test/keys.test.ts and test/chord.test.ts.

Editing (v e) and saving (v s)

v e in the main pane edits the file behind the pane in place (AGT-2167, D-18/D-23): the editing pane takes the main pane's place, on pablo's own buffer (buffer.ts), with the cursor on the paragraph or line under the main pane's cursor (fileLineAt in document.ts). No editor process runs. The screen cannot import the CLI, so runScreen takes a paneSession (the CLI's screenEditPane, packages/cli/src/edit-session.ts): open makes (or reuses: one per work) an edit/<short-id> branch with its own worktree and reads the file there, save writes and commits it as the author, close deletes a branch it made that never got a commit. The model holds the pane (state.editPane) with its text opaque: a key is a pane.edit whose function (edit-pane.ts's paneKey) copies the buffer and edits the copy, so the state is never mutated. The app's own keys are off while the pane is open; edit-pane-view.tsx reads them and hands them to modal.ts (AGT-2169, D-18): the pane is Helix-style modal and opens in normal mode, its mode shown at the start of the title (NOR, INS, SEL) and as the key panel's title. Normal mode moves: h j k l (and the arrows) by character and by display row, so j walks a wrapped paragraph a row at a time; w b e by word; 0 $ (Home/End) to the start and last character of the whole paragraph; g g and G to the first and last line. i inserts before the cursor, a after it, o on a new line below; v toggles select mode, where the same motions extend a selection from where it started (drawn shaded). These are editor.* rows in keys.ts in the normal and select key states, resolved through chord.ts like every other key, so the key panel lists them and keys in the config rebinds them; while the pane is open keyStateOf is its mode. A motion only moves the buffer's cursor (MOTIONS in modal.ts), so operators and text objects can take the range it covers. In insert mode keys type (paneKey): text, Enter, Backspace, arrows, Home/End, PgUp/PgDn. The operators (operators.ts, AGT-2171) act on select mode's selection, or the character under the cursor in normal mode: d and x delete, c changes (deletes, then inserts), y yanks, p/P paste after/before, r then a key replaces every character with it. Deletes and yanks go to the default register " unless " then a name picks another for the next one; the registers live with the pane's text. u/U undo and redo in normal and select mode, and in every mode Ctrl+Z / Ctrl+Y do the same and Ctrl+S saves (a commit on the branch). An insert session (i to Esc, o's new line and c's deletion included) and each operator are one undo step: the buffer's beginGroup/endGroup/group (a save inside a group starts a new step, so undo always reaches the saved text). Esc backs out a mode at a time: insert or select to normal; in normal it closes (twice with unsaved changes; while the pane is still opening it cancels, and the branch the open made goes). Closing is the model's paneEsc, which the view also reads. A dirty marker ([+]) shows in the pane's title until a save. Edits also autosave (AGT-2173, autosave.ts): once the text has sat dirty for AUTOSAVE_MS the pane dispatches pane.save with auto, the same save as Ctrl+S (a commit on the edit/ branch, never main); a blank body is never autosaved. The wait runs on an injectable clock (App's autosave prop) so tests fire it by hand. main changes only when v s merges the branch. An edit branch an earlier session left with commits main lacks is resumed on the next v e (its worktree added back if it went), and the pane says so; save refuses to write through a symlink. The frontmatter is never in the buffer: a sentences unit is shown one paragraph per line with a blank line between paragraphs, a lines or screenplay unit line for line, and both are loaded and saved through edit-save.ts (AGT-2168: only changed paragraphs re-split, the rest byte for byte); saveText is the pane's one call into it. Ctrl+S on an emptied pane asks first (it would leave the file only its frontmatter); Ctrl+S again saves it. check hits are re-run on the text as it would save and drawn as boxes under their paragraph. An essay unit (AGT-2134) is a section of the one shared essay file, and its rail row names that file: v e on it edits the whole essay, the cursor on the section's line. The saved branch then waits in the book like any other. e in a review (AGT-1591) still opens the configured editor (editSession, screenEditor) on the review branch, leaving the alternate screen while it runs. Two modes make long sessions comfortable (AGT-2176, edit-modes.ts). Ctrl+F is focus mode: every row outside the cursor's paragraph (and its check boxes) is dimmed. Ctrl+T is typewriter mode: the cursor's row stays in the middle of the pane, with blank rows above the first line and below the last. The keys are the edit.focus and edit.typewriter rows (rebindable in the settings view, listed in the key panel while the pane is open); the pane's title names the modes that are on, and each toggle is written to editModes in the config file at once, so the next pane opens the same way. The scroll is the model's (editPane.top, set by pane.scroll), not the buffer's: paneView computes it without writing anything.

The status line shows a words field while the pane is open (word-count.ts, AGT-2175): the live count of the buffer, core's countWords, the counter pablo status uses for a unit file (frontmatter left out), so the number for a saved file is status's words. Ctrl+G (the rebindable edit.target row, in every mode) opens a target prompt in that field (600 is this unit's target, w 80000 the output's (all its units together), 0 clears; Enter sets, Esc cancels) and the field then shows the progress (412 words · unit 412/500 · 82% · novel 9000/80000 · 11%). An output's total is the other units' saved words (the rail's words) plus the live count, over the edited unit's output. Targets persist through the targetStore the CLI passes in: <work>/.pablo/targets.json (gitignored, so a target never rides an edit/ branch). AI revisions show inline (AGT-2177, D-18, inline-diff.ts): once a unit is open, PaneSession.revisions (the CLI's revisionsForPane) hands over every revise/ branch with commits main lacks that changes the file, as git's diff of the file since the branch left main. Each edit core's stitcher finds in it is drawn under the paragraph it changes (a header, the sentence out in red and in in green, the differing words marked) and the title counts them (AI 2). g f / g F go to the next or previous one; g a accepts the one at or below the cursor and g r rejects it (editor.ai_* rows). Accepting finds the diff hunk as the text holds it now (the branch's accepted edits applied; failing that, just the edit's own lines) and swaps in the hunk with this edit taken, as one undo step (TextBuffer.group), then asks for the pane's save, so it reaches the edit/ branch through saveText like any edit. Rejecting changes nothing, so rejecting every edit leaves the edit branch as it was. An edit whose text the author has since changed "no longer matches" and can only be rejected; one an earlier session accepted and saved is found already applied and not offered again. The list is per unit: g n / g p open a new text and its own revisions are fetched. The revise branch is never written. v s saves it: the same finisher a review's s uses, with nothing rejected, so the merge into main and the after-write steps are the ones every branch gets. A document that is not one file (the bible, a missing chapter) has no editable path and v e says so.