Drive the lore CLI: author, retrieve, and maintain OKF documentation bundles, including explicit multi-repository workspaces. Cut from the same lore-cli…

A thin, OKF-native documentation CLI that couples repo-resident docs to Backlog.md, Quest, or Jira tasks and serves them to coding agents and humans — CLI-first.
lore makes your repository's docs/ tree a first-class, agent-readable Open Knowledge Format bundle, couples that bundle to task-tracker records, and exposes it through a deterministic, non-interactive CLI. The repository is the single source of truth — the bundle is plain markdown with YAML frontmatter that renders on GitHub, in Obsidian, and under MkDocs/Docusaurus, with or without lore installed.
Tracker backend is a choice, not a dependency. lore init --tracker <quest|backlog|jira|none> selects it; quest and jira are just as first-class as the original backlog integration below, each with its own adapter and coupling contract. lore init's interactive wizard checks whichever binary is on PATH and offers to migrate an existing Backlog.md project into Quest (--migrate-backlog) when both are present. The rest of this README documents the original, most-detailed integration — Backlog.md — as a worked example of the coupling contract; see lore init --help for the Quest and Jira flags.
lore is thin and zero-config by design. It does not reimplement Backlog.md, Confluence, or the documentation consumers it scaffolds for. Its core is deterministic with no LLM dependency — every command is reproducible, idempotent, and CI/agent-safe (non-interactive by default, stable semantic exit codes, machine-readable --json).
@opum-ai/lore@0.13.0 (bin lore) with six exact-pinned platform packages, including Windows ARM64.<!--lore-version:published-bullet:end-->.claude/skills/lore/SKILL.md plus a tiny CLAUDE.md nudge and lore instructions. An MCP server is secondary and deferred to v2.Status:<!--lore-version:status:begin--> 0.13.0 released. Tag
v0.13.0, the qualified workflow artifacts, all seven public@opum-ai/lore*npm packages withlatestmoved on each, and a clean-registry install agree on0.13.0.<!--lore-version:status:end--> Noquestpairing is claimed for this version by its number.0.8.0was released as a pair withquest0.9.0 — deliberately NOT the same number; whether any lore version and any quest version form a qualified pair is measured byopum-cli-e2e, not implied by either version string. "The pair" means the two CLIs are released and qualified TOGETHER, not that they carry matching version strings. Exact-version lockstep was the earlier convention and it was retired by decision, not by drift: lore and quest have independent change sets, and a matched version string that is not a matched contract is worse than an honestly different one, because it invites callers to treat the number as a compatibility check. Detect a capability by presence, never by comparing versions — one version string has already been observed naming two different byte-sets. The pair is qualified end to end byopum-cli-e2e, and that qualification, not the numbering, is what binds them.Releases are currently manual, not a single dispatch. OIDC trusted publishing is configured on every package but cannot authenticate: GitHub issues immutable-format OIDC subject claims for this repository and npm matches the classic form, so
publish: truefails withE404(LCLI-482). Every release after0.6.0has therefore been published withscripts/publish-release.shand, unlike0.6.0, carries no provenance attestation — a manual publish cannot mint one, and provenance requires the CI OIDC path. An absent attestation here is expected and is not evidence of tampering. See Lore CLI release truth.
The section below documents lore's original tracker integration in full technical detail as a worked example; --tracker quest and --tracker jira have their own contracts, summarized above and covered in docs/reference/.
lore couples docs to tasks by reading Backlog.md's JSON output — not by scraping text and not by importing Backlog.md internals or hand-editing its task files. It parses a canonical {schemaVersion, kind, data} envelope from backlog task list --json, backlog task view --json, and backlog search --json. There is no --plain text-parser fallback — that is a deliberate decision to keep the coupling robust.
Backlog.md did not originally ship this JSON surface. It merged upstream in MrLesk/Backlog.md as PR #790 and shipped in the v1.49.0 tagged release (2026-08-02). lore has no package or git dependency on Backlog.md and invokes the user-installed backlog executable (>=1.49.0) on PATH. A capability probe enforces the JSON contract and fails loud when the installed binary cannot provide it.
See the runbook: Backlog.md --json patch.
Coexistence rules lore follows so it never fights Backlog.md:
backlog task create / backlog task edit — lore captures the new id from the Created task <ID> line and never writes backlog/tasks/*.md directly.doc:<conceptId> (Backlog drops unknown frontmatter on edit, so lore never stores its own metadata on tasks).auto_commit=false; lore is the sole committer of backlog/ (it does the git add/commit of task files itself), with check_active_branches=false and remote_operations=false.Full details: Backlog CLI contract and Backlog JSON schema.
The package and bin are @opum-ai/lore and lore:
# Node / npm
npx @opum-ai/lore --help
# Bun
bunx @opum-ai/lore --help
# Global npm install
npm install -g @opum-ai/lore
Starting with 0.2.0, the launcher installs only the matching script-free platform package, so a current install does not require an install-script approval exception. Qualified macOS/Linux executables embed LadybugDB's native addon at build time. Windows continues to use the reference backend and installs no LadybugDB package.
Or add it to a project:
bun add -d @opum-ai/lore # or: npm i -D @opum-ai/lore
The npm package is a dual artifact: a Node .cjs launcher plus a per-platform compiled binary delivered as optionalDependencies (built with bun build --compile, -baseline x64 targets). In 0.2.0, all JavaScript libraries became build-only and are not installed transitively with the launcher. You also need the CLI of the tracker you couple to on PATH: Quest for the default backend (npm install -g @opum-ai/quest, which the quickstart below uses), or a --json-capable Backlog.md (>=1.49.0) — e.g. npm install -g backlog.md; see the runbook.
Repositories inside the opum-ai organization can run strict Lore gates from the private source repository through the immutable composite action:
- uses: actions/checkout@v6
- uses: opum-ai/lore-cli/.github/actions/strict-check@<full-commit-sha>
The private composite action installs Bun 1.3.14 and this action revision's frozen dependencies, installs the published JSON-capable backlog.md version pinned by the Docker E2E harness, then runs lore validate --strict and lore check --strict against the caller workspace. Consumer workflows must replace the placeholder with the full immutable commit SHA. Private-action access remains limited to organization repositories.
Every command is idempotent and emits stable exit codes. All of them are non-interactive by default — the one exception is lore init, which runs a guided wizard on a bare, interactive-terminal invocation (detecting and offering Claude Code and Codex agent bridges, downstream doc-site scaffolds, and a backlog capability check); it is strictly TTY-gated, so a non-TTY stdin or stderr, --json, or any of its own flags runs it fully non-interactively too — see ADR-0017. Output has three modes with precedence --json > --plain > pretty:
NO_COLOR.--plain — ANSI-free, stable text; the automatic mode when stdout is not a TTY (pipes, CI, agents).--json — a {schemaVersion, kind, data} envelope on stdout; errors go to stderr as {error_type, message, hint, input}.<!-- quickstart:start -->
# 0. Start in a git repository (skip this inside an existing one). `lore sync`
# reads git history, and the default tracker's own `quest init` refuses a
# path that is not a worktree.
git init
# 1. Initialize the tracker lore couples to, BEFORE `lore init`: this
# quickstart assumes Quest, which `lore init` selects by default
# (`[tracker] backend = "quest"` in .lore/config.toml; Backlog.md and Jira
# are the alternatives), and the interactive wizard refuses a Quest backend
# whose workspace is not initialized yet. Quest writes, including the ones
# lore makes for you, need an explicit actor.
quest init --name "Orders" --task-id-prefix task --skill-source none
export LORE_QUEST_ACTOR=jdoe LORE_QUEST_ACTOR_KIND=human
quest task create "Bulk archive" --actor jdoe --actor-kind human # -> task-1
quest task create "Archive UI" --actor jdoe --actor-kind human # -> task-2
# 2. Scaffold the OKF bundle (docs/, .lore/). On a bare TTY invocation this
# runs a guided wizard (agent bridge, doc-site scaffolds, tracker check);
# off a TTY (CI, this snippet) it's exactly this — the bundle only,
# non-interactively. Add `--allow-no-git` for a docs-only bundle outside a
# repository.
lore init
# 3. Create typed concepts from frontmatter templates.
lore new story "Bulk archive completed orders"
lore new spec "Order archival" --summary "How completed orders are archived."
lore new adr "Use soft deletes"
# 4. Couple the story to those tasks (writes frontmatter + a doc:<id> label).
lore link stories/bulk-archive-completed-orders task-1 task-2
# 5. Reconcile status and rewrite the managed task block from live JSON.
lore sync
# 6. CI gate: report drift / broken links / portability issues (no writes).
lore check
# 7. Retrieve: full-text search and deterministic graph-context export.
lore query "archive" --type story
lore context stories/bulk-archive-completed-orders --max-tokens 4000
<!-- quickstart:end -->
--plain is stable, line-oriented text — ideal for pipes and grep:
<!-- quickstart:example -->
$ lore tasks stories/bulk-archive-completed-orders --plain
tasks: stories/bulk-archive-completed-orders — 2 tasks
task-1 To Do Bulk archive
task-2 To Do Archive UI
--json is the additive-only machine contract:
<!-- quickstart:example -->
$ lore check --json
{
"schemaVersion": 1,
"kind": "check.report",
"data": {
"findings": [],
"errorCount": 0,
"warningCount": 0,
"fileCount": 8,
"skippedOutOfBundleLinkCount": 0,
"complete": true
},
"principal": null
}
That is the quickstart repository above, checked clean. A problem appears as an entry in findings, is counted in errorCount or warningCount, and an error makes the command exit 6:
{ "severity": "error", "rule": "broken-link",
"file": "stories/bulk-archive-completed-orders.md",
"message": "link \"../nope/missing.md\" points at \"nope/missing.md\", which is not in the bundle" }
<!-- quickstart:example-skip -->
$ lore validate --json && echo "conformant" # exit 6 on validation/drift
Semantic exit codes (uniform across commands): 0 ok, 2 usage, 3 not-found, 4 denied, 5 conflict/exists, 6 validation-or-drift, 7 indeterminate (a gate cannot judge from here — never auto-repair). See the CLI contract for the full output and exit-code spec, and the CLI surface for every command and flag.
lore graph --json # cross-link graph + token estimates
lore graph --dot # Graphviz DOT
lore export > lore-projection.jsonl # full consumer-neutral OKF/task projection
lore orphans # tasks with no owning doc; docs whose tasks vanished
lore replace "OldName" "NewName" --in 'reference/**' --dry-run
lore rename reference/orders reference/order-lines # graph-aware: rewrites inbound links
lore supersede adr/0004-foo adr/0009-bar # sets superseded_by/supersedes/status
replace skips lore-managed regions; rename/supersede use the bundle graph to rewrite all inbound links and frontmatter refs.
lore is CLI-first for humans and agents. Its agent bridges are generated, not bespoke:
lore agents emits .claude/skills/lore/SKILL.md — a skill that teaches Claude Code when and how to drive lore (always with --json for structured results).lore init --codex emits .codex/skills/lore/SKILL.md; a managed block in AGENTS.md points Codex at that skill without overwriting repository guidance.CLAUDE.md points Claude Code at its skill.lore instructions prints task-shaped guidance on demand for any agent or human.An agent's typical loop: read lore context <id> --json to pull a concept plus 1-line neighbor summaries within a token budget, do the work, then run lore sync and lore check --json to keep docs coherent — all deterministic, all without an LLM in lore's core.
See Agent onboarding.
docs/ is a valid OKF v0.1 bundle on its own. To keep it portable across renderers, every cross-link is relative, URL-encoded, .md-suffixed, with no leading slash and no wikilinks — the only form that resolves identically on GitHub, in Obsidian (graph + backlinks), under MkDocs, and under Docusaurus. lore's portability lint warns on non-portable syntax.
lore scaffold writes consumer configs additively, outside docs/ so the bundle stays clean:
lore scaffold mkdocs # mkdocs.yml
lore scaffold docusaurus # docusaurus.config + markdown.format:'detect'
lore scaffold obsidian # .obsidian/ vault config
A one-way Confluence publish adapter (Cloud/ADF) is planned as an isolated module with zero core dependency, but its implementation is deferred (Server/DC is deferred-not-dropped). See Consumer compatibility and Portable Markdown.
Tracked as Backlog.md milestones, built in order:
| Milestone | Scope |
|---|---|
| BJP | Upstream stable JSON for Backlog.md reads (completed in PR #790; tagged-release adoption gates lore 0.1) |
| M0 | Foundations: repo, runtime pin, build/distribution skeleton |
| M1 | Core + scaffolding: init, new, validate, concept/frontmatter lib (gray-matter + Zod), bundle walk |
| M2 | Backlog coupling: link, sync, check, managed block (remark), status reconciliation |
| M3 | Navigability, search & refactoring: graph, orphans, query, context, replace, rename, supersede |
| M4 | Agent bridge: generated SKILL.md, CLAUDE.md nudge, lore instructions |
| M5 | Browsable + graph consumers: lore scaffold for MkDocs/Docusaurus/Obsidian |
| M6 (deferred) | MCP server — same core functions over a deferred transport |
| M7–M8 (deferred) | Confluence: one-way publish, then mirror |
The full design lives in this repo's OKF bundle under docs/:
This repository is public (main + dev; dev is the default branch). See CONTRIBUTING, the Code of Conduct, and SECURITY.
MIT © 2026 Opum AI.
hooks/register.tsx 2004 lines1// LCLI-664. The Lore pane: a Claude Code pane over the repository's
2// documentation bundle, driven entirely by the lore CLI (stdout only;
3// warnings on stderr are read solely to explain a failure). Browse, Read
4// (rendered and raw), Search and New mirror the design brief in opum-doc,
5// `docs/reference/lore-mod-design-brief.md`; the repository is the session's
6// own, resolved as the git toplevel, and a fleet/workspace view is
7// deliberately out of v1 (operator question Q2). Body editing ships whichever
8// arm the operator selects (Q1); until that is relayed the pane hands the
9// person Claude-assisted revision and the fields form.
10//
11// Every `$.noun.method(...)` call lives in this file: the engine's validator
12// refuses a noun of `$` passed across an import, so ./lore receives plain
13// values (an argv to run, a run's result) and answers with parsed shapes.
14
15import { atom, read, update } from "claude-code";
16import type { EngineInterface, Register, UiOpenResult, UiPane } from "claude-code";
17
18import type { Catalog, DocState, Edits, PaneMode, PaneState, View } from "../types";
19import type { Outcome, Run } from "./lore";
20import {
21 BODY_CAP,
22 actionArgv,
23 actionVerb,
24 bundleIdFor,
25 cap,
26 editorFits,
27 failure,
28 groupByType,
29 hasSection,
30 internalHrefs,
31 isCapped,
32 newArgv,
33 parseBrowse,
34 parseCreated,
35 parsePorcelain,
36 parseRead,
37 parseSearch,
38 parseTypes,
39 parseValidate,
40 patchFrontmatter,
41 queryArgv,
42 replaceBody,
43 requiredSectionsFor,
44 searchArgv,
45} from "./lore";
46
47// The pane's ID, which is not what opens it: the pane has no slash command (LCLI-667 --
48// the engine's `lore` skill owns that name, so a command of the same name is refused), and
49// `mcp__opum-lore__dashboard` below is its entry point. The id keeps its older spelling
50// because it is what `$.ui.open`, the store and `$.state` are keyed by: renaming it would
51// lose an open pane and a preference saved before the rename.
52const PANE = "lore-pane";
53const REFRESH_MS = 30_000;
54const TIMEOUT_MS = 30_000;
55const ROW_CAP = 120;
56
57// ── The full-screen toggle (LCLI-666) ─────────────────────────────────────────
58
59/** Where the surface seated the pane, as the `Pane` render props carry it. */
60type Placement = "dock" | "inline";
61
62/** The `$.store` key holding the remembered full-or-normal choice (a `PaneMode`). */
63const MODE_KEY = "pane-mode";
64
65/**
66 * Opens the pane the way every caller here must, and records what the engine answered.
67 *
68 * Unsized and without `focus`: "each open sets it anew" clears whatever size was standing,
69 * so an open of a full-mode pane raises the one restoration ask the next draw consumes
70 * (`pendingAsk`) -- DEC-154 rule 2 as amended puts the ask per toggle, or per open that
71 * cleared the size, and never per resize or redraw -- and no caller here may take the
72 * keyboard from a person who might be typing.
73 *
74 * The record it leaves is `pane.isWaiting`, and it is the BAND's reading wherever the
75 * engine's listing cannot be had (LCLI-672): a refused open is what the band exists to
76 * answer, and this is that refusal kept. It is only a record of the last open, so it is
77 * never the first choice -- `paneStanding` is, because a widened terminal seats the pane
78 * with no open to hear about it, which a record cannot notice and a listing can.
79 */
80async function openPane($: EngineInterface): Promise<UiOpenResult> {
81 const { mode } = await read($, pane);
82 // Raise the restoration ask a full-mode open owes -- and never LOWER one already
83 // standing. This read can be stale against a toggle whose write has not landed yet
84 // (measured, LCLI-675 review F1: a tool call racing the person's `z` read `normal`,
85 // nulled the ask, and left the pane full-mode and unsized), and a standing ask may
86 // carry the person's keyboard intent, which this open knows nothing about. Every mode
87 // change assigns its own ask and the draws that spend one are mode-guarded, so an ask
88 // this open does not need cannot outlive the next toggle.
89 if (pendingAsk === null && mode === "full") {
90 pendingAsk = { mode: "full", byPerson: false };
91 }
92 const asked = await $.ui.open({ id: PANE, title: "Lore" });
93 await update($, pane, (state) => ({ ...state, isWaiting: asked.isPlaced === false }));
94
95 return asked;
96}
97
98/**
99 * The columns the engine's own dock clamp keeps clear, from DEC-154 rule 2.
100 *
101 * A docked request is clamped to `[24, cols - 24]` (read from the 2.1.288 build,
102 * LCLI-674), so the full ask IS that clamp's ceiling: anything less asks for less
103 * than the surface allows, and the ceiling is what makes a short grant readable as
104 * a width the person holds rather than a clamp.
105 */
106const DOCK_FLOOR_COLUMNS = 24;
107
108/**
109 * The rows the engine keeps clear of an inline pane, from DEC-154 rule 1: 8 for the
110 * prompt and 3 of transcript, so an inline full ask is `rows - 11`.
111 *
112 * Measured, not estimated (LCLI-674): the engine caps an inline pane at
113 * `max(rows / 3, rows - 11)` on the main screen, and the first design's `rows - 6`
114 * asked for rows the engine never grants. The request stays a request, and a grant
115 * a few rows below the cap is accepted, because the frame and the pane's own
116 * controls take the difference.
117 */
118const PROMPT_FLOOR_ROWS = 8;
119const TRANSCRIPT_PEEK_ROWS = 3;
120
121/**
122 * The cells between the size a request asks for and the size the body measures.
123 *
124 * `columns` and `rows` are the pane's own size; `bodyColumns` is "cells across
125 * the body, inside the frame". One slack covers the frame and the chrome a
126 * surface draws around the body, so a pane that got what it asked for is not
127 * read as short of it -- and the tool's answer calls a size within the same
128 * slack "the full size" (DEC-154 rule 4).
129 */
130const SIZE_SLACK = 4;
131
132/**
133 * The line a full docked pane shows while it drew short of its ask.
134 *
135 * DEC-154 rule 3: a size the person dragged is theirs, the module never edits
136 * `~/.claude.json` or works around it, and the pane says so plainly rather than
137 * showing a generic held hint. The width named is the pane's own -- the body plus
138 * the frame column LCLI-674 measured (79 body cells under an 80-wide pane) -- which
139 * is the number the person set. The classification is the design's own and Quest's
140 * board's (seq 234): a docked full pane more than `SIZE_SLACK` short of its ask is at
141 * a kept width, whatever raised the ask.
142 */
143const keptWidthLine = (paneColumns: number) =>
144 `Width kept at ${paneColumns} (you set it): drag the pane edge to change`;
145
146// ── The dashboard tool (LCLI-668) ─────────────────────────────────────────────
147
148/** The tool's own name, which is what `$.tool.register` declares. */
149const TOOL_NAME = "dashboard";
150
151/**
152 * The tool as the engine lists it to the model: `ToolSpec` spells a declared name
153 * `mcp__<plugin>__<name>`, and the plugin's name is `opum-lore` in
154 * `.claude-plugin/plugin.json`. The hook that serves the tool is matched at register
155 * time, before any registration has returned a name, so the spelling lives here and
156 * both sides are built from the one pair of constants.
157 */
158const TOOL = `mcp__opum-lore__${TOOL_NAME}` as const;
159
160/**
161 * The tool's listed description.
162 *
163 * One or two sentences, and no more: the description is listed to Claude in every
164 * session that loads the mod, so it is a standing cost on every prompt (design of
165 * record, opum-doc `docs/reference/pane-dashboard-tool-design.md` at dfde45e).
166 */
167const TOOL_DESCRIPTION =
168 "Open the Lore pane in this session: browse, read, search and create this repository’s documentation. " +
169 "`doc` opens a concept on the Read tab, `query` searches on the Search tab, `full` asks for the full size, " +
170 "and the pane never takes the keyboard.";
171
172/** What the tool takes: every field optional, so a bare call opens the pane as it stands. */
173const TOOL_INPUT_SCHEMA: Record<string, unknown> = {
174 type: "object",
175 properties: {
176 doc: { type: "string", description: "A concept id to open on the Read tab." },
177 query: { type: "string", description: "Text to search the bundle for, on the Search tab." },
178 full: { type: "boolean", description: "Ask for the pane's full size." },
179 },
180 additionalProperties: false,
181};
182
183/**
184 * The body columns from which the pane draws its list in a left column and the
185 * document in the right one, instead of stacked (design: "at least 120 body
186 * columns", the same threshold the Quest board splits at). Below it -- and outside
187 * full mode, where the pane is whatever size the surface's share gave it -- both
188 * tabs keep the stacked layout.
189 */
190const SIDE_BY_SIDE_COLUMNS = 120;
191
192// ── The band above the prompt (LCLI-672) ──────────────────────────────────────
193
194/**
195 * The one line the band above the prompt shows while the pane is open and undrawn.
196 *
197 * It exists because of a rule measured rather than read (LCLI-672 note, Claude Code
198 * 2.1.288): an open nobody ASKED for by hand waits undrawn below the engine's floor -- 144
199 * terminal columns, or 110 for an id the person has opened before -- and a model's tool
200 * call is one of those. A press is not: the engine places an open asked by a Button at any
201 * width, which is the one door onto a pane the model opened on a narrow terminal. So the
202 * line says what is ready and the Button seats it, and both are gone once it is drawn.
203 *
204 * The line carries the focus step too (`BAND_HINT`), because the Button's letter hotkey is
205 * not reachable from an empty composer the way a digit is: `o` presses it only once ctrl+x
206 * tab has given the band the keys, where a click needs no focus at all. A digit would
207 * collide with other bands' Buttons and with the pane's own link-list hotkeys, so the
208 * keystroke is written down rather than changed (opum-doc seq 212, ODOC-OP-2026-10-03-43).
209 */
210const BAND_TEXT = "Lore pane ready";
211
212/** The focus step, as the band prints it after the Button -- see `BAND_TEXT`. */
213const BAND_HINT = "(ctrl+x tab, o)";
214
215/** One line of the bundle list: a type's heading, or a concept that opens. */
216type BrowseRow =
217 | { kind: "group"; key: string; type: string; count: number }
218 | { kind: "row"; key: string; id: string; title: string };
219
220const view = atom({ plugin: "opum-lore", key: "view" } as const, {
221 tab: "browse",
222 root: null,
223 query: "",
224 typeFilter: "",
225 tagFilter: "",
226 acrossRefs: false,
227 selectedId: null,
228 history: [],
229 isRaw: false,
230 action: null,
231 actionValue: "",
232 isLoading: false,
233 error: null,
234 notice: null,
235} satisfies View);
236
237const catalog = atom({ plugin: "opum-lore", key: "catalog" } as const, {
238 concepts: [],
239 types: [],
240 hits: [],
241} satisfies Catalog);
242
243const doc = atom({ plugin: "opum-lore", key: "doc" } as const, { concept: null } satisfies DocState);
244
245const edits = atom({ plugin: "opum-lore", key: "edits" } as const, {
246 isWriting: false,
247 draft: { type: "", title: "", summary: "", tags: "" },
248 fields: null,
249 uncommitted: [],
250 bodyEditing: false,
251 bodyDocId: null,
252 bodyText: "",
253 bodyRevision: 0,
254} satisfies Edits);
255
256const pane = atom({ plugin: "opum-lore", key: "pane" } as const, {
257 mode: "normal",
258 isWaiting: false,
259} satisfies PaneState);
260
261/**
262 * The one ask a toggle owes, waiting for a draw that can build it; null when none is owed.
263 *
264 * A draw is the only place that knows `e.viewport`, so a sized request is built there --
265 * but a draw is not an event, and DEC-154 rule 2 as amended makes the ask ONCE per toggle
266 * (a person's `z`, a tool call carrying `full`, or a stored full mode on its first render)
267 * and never on a resize or a redraw: re-deriving it from each render would chase the
268 * pane's own new width. An open that cleared the size (a tool call, the band's press, a
269 * session start) raises the same one ask, because "each open sets it anew" leaves a full
270 * pane at the surface's share until something asks again.
271 *
272 * `byPerson` rides with it because `focus` does: a person asking for a size gets a pane
273 * that may take the keyboard; a restored or tool-raised ask never does. The ask is
274 * carried as the MODE that was raised for rather than a bare flag (LCLI-668 review F5):
275 * the draw that spends it has to be the one applying that mode, and a draw with nothing
276 * to ask leaves the ask standing for the draw that does, instead of spending it on the
277 * way past.
278 */
279let pendingAsk: { mode: PaneMode; byPerson: boolean } | null = null;
280
281/**
282 * The numbers of the latest completed draw, for the dashboard tool's answer.
283 *
284 * DEC-154 rule 4: the tool reports the DRAWN size, never the asked one, and "the full
285 * size" only when the draw came within `SIZE_SLACK` of its ask. A tool call returns after
286 * an unsized open, often before the restore draw has run, so this is the most recent
287 * render as of the answer -- and a missing record, or one of another mode, is what the
288 * answer honestly calls "full requested".
289 */
290let lastDraw: {
291 mode: PaneMode;
292 placement: Placement;
293 wanted: number | null;
294 drawn: number;
295} | null = null;
296
297// ── The call sites ────────────────────────────────────────────────────────────
298
299/**
300 * Whether the run was still going when the engine's timeoutMs budget ran out.
301 *
302 * The engine enforces that budget by killing the child, and neither shape it can
303 * take says so: the declaration has the call reject, the review note has it read
304 * as exit 1, and the result carries no field either way. Elapsed time is the one
305 * signal both shapes carry, so the run is measured against its own budget on the
306 * way out of `runLore`, resolved or rejected (LCLI-664 review F8).
307 */
308async function timedOutMs($: EngineInterface, startedAt: number): Promise<number | null> {
309 const elapsed = (await $.clock.now()) - startedAt;
310
311 return elapsed >= TIMEOUT_MS ? TIMEOUT_MS : null;
312}
313
314/** Runs `lore <argv>` in the repository root; a command that cannot start resolves code -1. */
315async function runLore($: EngineInterface, root: string | null, argv: readonly string[]): Promise<Run> {
316 const startedAt = await $.clock.now();
317 try {
318 const result = await $.process.run(["lore", ...argv], {
319 ...(root ? { cwd: root } : {}),
320 timeoutMs: TIMEOUT_MS,
321 });
322 const timed = await timedOutMs($, startedAt);
323
324 return {
325 code: result.exitCode,
326 stdout: result.stdout,
327 stderr: result.stderr,
328 truncated: result.isStdoutTruncated || result.isStderrTruncated,
329 ...(timed === null ? {} : { timedOutMs: timed }),
330 };
331 } catch (error) {
332 const timed = await timedOutMs($, startedAt);
333
334 return {
335 code: -1,
336 stdout: "",
337 stderr: error instanceof Error ? error.message : String(error),
338 ...(timed === null ? {} : { timedOutMs: timed }),
339 };
340 }
341}
342
343/** The git toplevel of the session's cwd, or null outside a repository. */
344async function resolveRoot($: EngineInterface): Promise<string | null> {
345 try {
346 const result = await $.process.run(["git", "rev-parse", "--show-toplevel"], {
347 timeoutMs: TIMEOUT_MS,
348 });
349
350 return result.exitCode === 0 ? result.stdout.trim() || null : null;
351 } catch {
352 return null;
353 }
354}
355
356/** The view's root, resolved on first need (session.start may not have run). */
357async function ensureRoot($: EngineInterface): Promise<string | null> {
358 const current = (await read($, view)).root;
359 if (current) {
360 return current;
361 }
362 const root = await resolveRoot($);
363 if (root) {
364 await setView($, { root });
365 }
366
367 return root;
368}
369
370/** The file exactly as on disk, for the Raw view; null when it cannot be read. */
371async function readFileText($: EngineInterface, root: string | null, docPath: string): Promise<string | null> {
372 if (!root || !docPath) {
373 return null;
374 }
375 try {
376 return await $.fs.read(`${root}/${docPath}`);
377 } catch {
378 return null;
379 }
380}
381
382async function writeFileText($: EngineInterface, root: string | null, docPath: string, text: string): Promise<Outcome> {
383 if (!root || !docPath) {
384 return { ok: false, error: "No repository root to write into." };
385 }
386 try {
387 await $.fs.write(`${root}/${docPath}`, text);
388
389 return { ok: true };
390 } catch (error) {
391 return { ok: false, error: error instanceof Error ? error.message : String(error) };
392 }
393}
394
395/** Uncommitted Markdown paths in the repository, for the landing strip. */
396async function uncommittedPaths($: EngineInterface, root: string | null): Promise<string[]> {
397 if (!root) {
398 return [];
399 }
400 try {
401 // -c core.quotePath=false keeps a non-ASCII path raw instead of octal-escaping
402 // it, so parsePorcelain only has git's ASCII escapes left to unquote.
403 const result = await $.process.run(["git", "-c", "core.quotePath=false", "status", "--porcelain"], {
404 cwd: root,
405 timeoutMs: TIMEOUT_MS,
406 });
407
408 return result.exitCode === 0 ? parsePorcelain(result.stdout) : [];
409 } catch {
410 return [];
411 }
412}
413
414// ── The pane's size ───────────────────────────────────────────────────────────
415
416/**
417 * The size a full-screen request asks for, or null when there is nothing to ask from.
418 *
419 * `PaneOpenArgs` takes a size per placement: docked panes in `columns`, inline ones in
420 * `rows`. DEC-154 rule 1 (inline) asks `rows - 11`; rule 2 as amended (dock) recovers the
421 * TERMINAL width from one render: `viewport.columns` is the transcript column beside a
422 * docked pane, not the terminal, and the pane's `bodyColumns` are the cells inside its
423 * frame -- so terminal = transcript + drawn body + 1, and that last column is the pane's
424 * frame edge, the divider at the transcript's own (LCLI-674 measured 79 body cells under
425 * an 80-wide pane at a 160-column terminal) -- and asks for all of it less the engine's
426 * 24-column floor. A surface that was never measured, or one too small to hold the floor
427 * and a pane both, has no size to request.
428 */
429function wantedSize(placement: Placement, columns: number, rows: number, drawn: number): number | null {
430 if (placement === "dock") {
431 if (columns <= 0 || drawn <= 0) {
432 return null;
433 }
434 const size = columns + drawn + 1 - DOCK_FLOOR_COLUMNS;
435
436 return size > 0 ? size : null;
437 }
438 const size = rows - PROMPT_FLOOR_ROWS - TRANSCRIPT_PEEK_ROWS;
439
440 return size > 0 ? size : null;
441}
442
443/**
444 * Asks the surface for the pane, sized when there is a size to ask for.
445 *
446 * `focus` only when the person asked (a request, not a grant either way: the
447 * surface hands the pane the keyboard only over an empty composer), and
448 * `closeOnEscape` is never passed -- that pair is what would make the pane a
449 * dialog rather than a pane. The key is left OUT rather than set false, so what
450 * the module asked for is what the open carries.
451 */
452async function requestPane(
453 $: EngineInterface,
454 placement: Placement,
455 wanted: number | null,
456 focus: boolean,
457): Promise<void> {
458 const asked = focus ? { focus: true as const } : {};
459 if (wanted === null) {
460 await $.ui.open({ id: PANE, title: "Lore", ...asked });
461
462 return;
463 }
464 await $.ui.open({
465 id: PANE,
466 title: "Lore",
467 ...asked,
468 ...(placement === "dock" ? { columns: wanted } : { rows: wanted }),
469 });
470}
471
472/**
473 * Leaves the pane at `mode`, in `$.state` and in the store.
474 *
475 * `pendingAsk` is deliberately NOT set here: the person's own toggle raises it itself,
476 * and a size nobody asked for -- a restored one, or the dashboard tool asking for
477 * `full` together with the unsized open that follows -- must not take the keyboard from
478 * the prompt (the open raises the ask without `byPerson`, in `openPane`). The size
479 * itself is asked for by the next draw, which is the only place that knows `e.viewport`;
480 * this leaves the choice where a draw will find it. The store write is best-effort: a
481 * store that refuses loses the memory of the choice, which is not a reason to refuse
482 * the change.
483 */
484async function setPaneMode($: EngineInterface, mode: PaneMode): Promise<void> {
485 await update($, pane, (state) => ({ ...state, mode }));
486 try {
487 await $.store.set(MODE_KEY, mode);
488 } catch {
489 // The pane changes size either way; only the next session's memory of it is lost.
490 }
491}
492
493/**
494 * Flips the pane between its normal size and the largest the surface allows.
495 *
496 * `pendingAsk` leaves the person's intent beside the change -- they pressed the
497 * key, so the pane it produces may take the keyboard.
498 */
499async function togglePane($: EngineInterface): Promise<PaneMode> {
500 const next: PaneMode = (await read($, pane)).mode === "full" ? "normal" : "full";
501 pendingAsk = { mode: next, byPerson: true };
502 await setPaneMode($, next);
503
504 return next;
505}
506
507// ── Actions ───────────────────────────────────────────────────────────────────
508
509async function setView($: EngineInterface, patch: Partial<View>): Promise<void> {
510 await update($, view, (current) => ({ ...current, ...patch }));
511}
512
513/**
514 * The New tab's type picker draws its first option as the chosen one before
515 * anything has been chosen, so a draft left at "" blanks the type's
516 * required-sections hint and makes Create refuse a form that visibly names a
517 * type -- "A type and a title are required." with no `lore new` sent at all
518 * (LCLI-664 review F1). The vocabulary read is the first moment the default is
519 * knowable, so it is written into the draft here; a draft the person has
520 * already chosen in is left alone.
521 */
522async function seedDraftType($: EngineInterface, types: readonly { name: string }[]): Promise<void> {
523 const first = types[0]?.name ?? "";
524 if (!first) {
525 return;
526 }
527 await update($, edits, (e) => (e.draft.type ? e : { ...e, draft: { ...e.draft, type: first } }));
528}
529
530async function refresh($: EngineInterface): Promise<void> {
531 const root = await ensureRoot($);
532 const current = await read($, view);
533 await setView($, { isLoading: true });
534 if (current.tab === "search") {
535 const [foundRun, typesRun] = await Promise.all([
536 runLore($, root, searchArgv(current)),
537 runLore($, root, ["types", "--json"]),
538 ]);
539 const found = parseSearch(foundRun);
540 const types = parseTypes(typesRun);
541 if (found.ok) {
542 await update($, catalog, (c) => ({ ...c, hits: found.hits, types: types ?? c.types }));
543 await seedDraftType($, types ?? []);
544 await setView($, { isLoading: false, error: null });
545 } else {
546 await setView($, { isLoading: false, error: found.error });
547 }
548
549 return;
550 }
551 const [query, types] = await Promise.all([
552 runLore($, root, queryArgv(current)),
553 runLore($, root, ["types", "--json"]),
554 ]);
555 const browse = parseBrowse(query, types);
556 if (browse.ok) {
557 // A failed vocabulary read leaves the concepts browsable: the note says why
558 // the type list is missing and the vocabulary already read is kept (F8).
559 const vocabulary = browse.types ?? (await read($, catalog)).types;
560 await update($, catalog, (c) => ({ ...c, concepts: browse.concepts, types: vocabulary }));
561 await seedDraftType($, vocabulary);
562 await setView($, { isLoading: false, error: null, notice: browse.typesNote ?? null });
563 } else {
564 await setView($, { isLoading: false, error: browse.error });
565 }
566}
567
568/**
569 * Loads one concept into the pane and leaves it open on the Read tab.
570 *
571 * The outcome is returned rather than only drawn: the person's own presses read it
572 * as "nothing happened, the pane says why", but the dashboard tool has to report a
573 * bad id back to the model, and it can only do that if the read's own answer reaches
574 * its caller (LCLI-668).
575 */
576async function loadConcept($: EngineInterface, id: string, patch: Partial<View> = {}): Promise<Outcome> {
577 const root = await ensureRoot($);
578 await setView($, { isLoading: true });
579 const [readRun, tasksRun] = await Promise.all([
580 runLore($, root, ["read", id, "--json"]),
581 runLore($, root, ["tasks", id, "--json"]),
582 ]);
583 const result = parseRead(readRun, tasksRun, id);
584 if (!result.ok) {
585 await setView($, { isLoading: false, error: result.error });
586
587 return { ok: false, error: result.error };
588 }
589 const raw = await readFileText($, root, result.doc.repoPath);
590 await update($, doc, () => ({ concept: { ...result.doc, raw } }));
591 // The open body editor belongs to ONE document. Left open across a concept change,
592 // `bodyText` — a body — would sit beside the NEW document's file, and Save would
593 // write one document's text into the other's file. Closing it here covers every way
594 // the open document changes: Browse, Search, a link press, Back/forward, Refresh.
595 await update($, edits, (e) =>
596 e.bodyEditing && e.bodyDocId !== result.doc.id ? { ...e, bodyEditing: false, bodyDocId: null, bodyText: "" } : e,
597 );
598 await setView($, {
599 isLoading: false,
600 error: null,
601 selectedId: id,
602 isRaw: false,
603 action: null,
604 actionValue: "",
605 ...patch,
606 });
607
608 return { ok: true };
609}
610
611/** Opens one concept on the Read tab, carrying the back history; see `loadConcept`. */
612async function openConcept($: EngineInterface, id: string): Promise<Outcome> {
613 const current = await read($, view);
614 const history =
615 current.selectedId && current.selectedId !== id
616 ? [...current.history, current.selectedId].slice(-50)
617 : current.history;
618
619 return await loadConcept($, id, { tab: "read", history });
620}
621
622async function goBack($: EngineInterface): Promise<void> {
623 const current = await read($, view);
624 const previous = current.history[current.history.length - 1];
625 if (!previous) {
626 return;
627 }
628 await loadConcept($, previous, { tab: "read", history: current.history.slice(0, -1) });
629}
630
631async function showTab($: EngineInterface, tab: View["tab"]): Promise<void> {
632 await setView($, { tab, error: null, notice: null });
633 if (tab === "browse" || tab === "search" || tab === "new") {
634 await refresh($);
635 }
636}
637
638/** A submit that changes the view before it reads it, so the read is never stale. */
639async function submitWith($: EngineInterface, patch: Partial<View>): Promise<void> {
640 await setView($, patch);
641 await refresh($);
642}
643
644async function toggleAcross($: EngineInterface): Promise<void> {
645 const current = await read($, view);
646 await setView($, { acrossRefs: !current.acrossRefs });
647 await refresh($);
648}
649
650async function pickFilter($: EngineInterface, key: "typeFilter", value: string): Promise<void> {
651 await setView($, { [key]: value });
652 await refresh($);
653}
654
655async function countUncommitted($: EngineInterface): Promise<void> {
656 const root = await ensureRoot($);
657 const uncommitted = await uncommittedPaths($, root);
658 await update($, edits, (e) => ({ ...e, uncommitted }));
659}
660
661async function openFields($: EngineInterface): Promise<void> {
662 const { concept } = await read($, doc);
663 if (!concept) {
664 return;
665 }
666 await update($, edits, (e) => ({
667 ...e,
668 fields: {
669 type: concept.type,
670 title: concept.title,
671 summary: concept.summary ?? "",
672 tags: concept.tags.join(", "),
673 status: concept.status ?? "",
674 },
675 }));
676}
677
678async function saveFields($: EngineInterface): Promise<void> {
679 const current = await read($, view);
680 const { concept } = await read($, doc);
681 const { fields } = await read($, edits);
682 if (!concept || !fields || !current.root) {
683 return;
684 }
685 if (!concept.raw) {
686 await setView($, { error: "The file has not been read yet." });
687
688 return;
689 }
690 const tags = fields.tags
691 .split(",")
692 .map((tag) => tag.trim())
693 .filter(Boolean);
694 const next = patchFrontmatter(concept.raw, {
695 title: fields.title,
696 summary: fields.summary.trim() ? fields.summary.trim() : null,
697 tags,
698 status: fields.status.trim() ? fields.status.trim() : null,
699 });
700 if (next === null) {
701 await setView($, { error: "The file carries no frontmatter to edit." });
702
703 return;
704 }
705 await update($, edits, (e) => ({ ...e, isWriting: true }));
706 const wrote = await writeFileText($, current.root, concept.repoPath, next);
707 if (!wrote.ok) {
708 await update($, edits, (e) => ({ ...e, isWriting: false }));
709 await setView($, { error: `Could not write ${concept.repoPath}: ${wrote.error}` });
710
711 return;
712 }
713 const checked = parseValidate(await runLore($, current.root, ["validate", concept.repoPath, "--json"]));
714 if (!checked.ok) {
715 // A failed validation keeps the previous file: the bytes read before the
716 // edit go back, and lore's own message is what the person sees.
717 const restored = await writeFileText($, current.root, concept.repoPath, concept.raw);
718 await update($, edits, (e) => ({ ...e, isWriting: false }));
719 await setView($, {
720 error: restored.ok
721 ? checked.error
722 : `${checked.error} (and restoring the previous file failed: ${restored.error})`,
723 });
724
725 return;
726 }
727 await update($, edits, (e) => ({ ...e, isWriting: false, fields: null }));
728 await setView($, { notice: "Saved and validated.", error: null });
729 await loadConcept($, concept.id);
730 await countUncommitted($);
731}
732
733/**
734 * Opens the inline body editor on the open document's body. The revision bump makes
735 * the editor adopt `concept.body` as it stands — it is the pane saying "this is the
736 * text", as against the editor's own keystrokes, which the pane takes as given.
737 */
738async function beginBodyEdit($: EngineInterface): Promise<void> {
739 const { concept } = await read($, doc);
740 if (!concept) {
741 return;
742 }
743 // Refused BEFORE the editor opens, because the failure is not the editor's: the pane
744 // passes the whole body to the `Client` as props, and a body past the engine's bound
745 // makes the engine refuse the PANE's render ("opum-lore drew nothing on the terminal
746 // surface") rather than the editor's. `Open in editor` has no such bound.
747 if (!editorFits(concept.body)) {
748 await setView($, {
749 error: `${concept.path} is too large for the inline editor (${concept.body.length} characters). Use Open in editor, which has no such limit.`,
750 notice: null,
751 });
752
753 return;
754 }
755 await update($, edits, (e) => ({
756 ...e,
757 bodyEditing: true,
758 bodyDocId: concept.id,
759 bodyText: concept.body,
760 bodyRevision: e.bodyRevision + 1,
761 }));
762 await setView($, { error: null, notice: null });
763}
764
765async function cancelBodyEdit($: EngineInterface): Promise<void> {
766 await update($, edits, (e) => ({ ...e, bodyEditing: false, bodyDocId: null, bodyText: "" }));
767}
768
769/** The inline editor's Save: the same write, validate and restore path as the fields form. */
770async function saveBody($: EngineInterface): Promise<void> {
771 const current = await read($, view);
772 const { concept } = await read($, doc);
773 const { bodyText, bodyDocId } = await read($, edits);
774 if (!concept || !current.root) {
775 return;
776 }
777 // Defence in depth behind `loadConcept`: the editor holds a BODY and the write pairs
778 // it with the open document's FILE. When those are not the same document, writing
779 // would put one document's body into another document's file — so this refuses.
780 if (bodyDocId !== concept.id) {
781 await update($, edits, (e) => ({ ...e, bodyEditing: false, bodyDocId: null, bodyText: "" }));
782 await setView($, { error: "The editor was open on another document, so nothing was written. Open it again." });
783
784 return;
785 }
786 if (!concept.raw) {
787 await setView($, { error: "The file has not been read yet." });
788
789 return;
790 }
791 const next = replaceBody(concept.raw, bodyText);
792 if (next === null) {
793 await setView($, { error: "The file carries no frontmatter to keep." });
794
795 return;
796 }
797 await update($, edits, (e) => ({ ...e, isWriting: true }));
798 const wrote = await writeFileText($, current.root, concept.repoPath, next);
799 if (!wrote.ok) {
800 await update($, edits, (e) => ({ ...e, isWriting: false }));
801 await setView($, { error: `Could not write ${concept.repoPath}: ${wrote.error}` });
802
803 return;
804 }
805 const checked = parseValidate(await runLore($, current.root, ["validate", concept.repoPath, "--json"]));
806 if (!checked.ok) {
807 const restored = await writeFileText($, current.root, concept.repoPath, concept.raw);
808 // No revision bump here, deliberately. `bodyText` still holds the person's own
809 // text and the editor's state is already that text, so there is nothing to adopt —
810 // and adopting would re-create the instance, parking the cursor at the end and
811 // dropping the redo ring, exactly while they are fixing what validation flagged.
812 await update($, edits, (e) => ({ ...e, isWriting: false }));
813 await setView($, {
814 error: restored.ok
815 ? checked.error
816 : `${checked.error} (and restoring the previous file failed: ${restored.error})`,
817 });
818
819 return;
820 }
821 await update($, edits, (e) => ({ ...e, isWriting: false, bodyEditing: false, bodyDocId: null, bodyText: "" }));
822 await setView($, { notice: "Saved and validated.", error: null });
823 await loadConcept($, concept.id);
824 await countUncommitted($);
825}
826
827/**
828 * The desktop-editor action: the other half of DEC-132. Nothing of the editor is
829 * reimplemented here — the document is handed to the person's own tool, and the pane
830 * re-reads and validates when it comes back (the Refresh button, or the 30-second
831 * refresh). On the terminal that is the session's own shell escape to `$EDITOR`,
832 * filled into the prompt rather than run, so the person sends it; on the desktop
833 * surface it is the platform's file opener.
834 */
835async function openInEditor($: EngineInterface): Promise<void> {
836 const current = await read($, view);
837 const { concept } = await read($, doc);
838 if (!concept || !current.root) {
839 return;
840 }
841 const path = `${current.root}/${concept.repoPath}`;
842 // The shell escape on every surface, rather than a platform opener chosen in code:
843 // the runtime has no Node (measured — `process` is undefined), so there is no
844 // `process.platform` to branch on, and `$EDITOR` resolves on the person's own
845 // machine to the editor they actually use. It is filled, not submitted: the person
846 // sends it, so the harness's own shell-escape rules and permissions apply.
847 const editor = "${EDITOR:-vi}";
848 await $.prompt.fill({ text: `!${editor} "${path}"`, mode: "replace" });
849 await setView($, { notice: `Sent ${concept.repoPath} to your editor; refresh when you are done.` });
850}
851
852async function createNew($: EngineInterface): Promise<void> {
853 const current = await read($, view);
854 const { draft } = await read($, edits);
855 if (!draft.type.trim() || !draft.title.trim()) {
856 await setView($, { error: "A type and a title are required." });
857
858 return;
859 }
860 await update($, edits, (e) => ({ ...e, isWriting: true }));
861 const result = parseCreated(await runLore($, current.root, newArgv(draft)));
862 await update($, edits, (e) => ({ ...e, isWriting: false }));
863 if (!result.ok) {
864 await setView($, { error: result.error });
865
866 return;
867 }
868 await setView($, { notice: `Created ${result.id ?? draft.title}.`, error: null });
869 await update($, edits, (e) => ({
870 ...e,
871 draft: { type: draft.type, title: "", summary: "", tags: "" },
872 }));
873 if (result.id) {
874 await openConcept($, result.id);
875 }
876 await refresh($);
877 await countUncommitted($);
878}
879
880async function submitActionWith($: EngineInterface, value: string): Promise<void> {
881 await setView($, { actionValue: value });
882 await submitAction($);
883}
884
885async function submitAction($: EngineInterface): Promise<void> {
886 const current = await read($, view);
887 if (!current.selectedId || !current.action) {
888 return;
889 }
890 const built = actionArgv(current.action, current.selectedId, current.actionValue);
891 if ("error" in built) {
892 await setView($, { error: built.error });
893
894 return;
895 }
896 await update($, edits, (e) => ({ ...e, isWriting: true }));
897 const verb = actionVerb(current.action);
898 const run = await runLore($, current.root, built.argv);
899 if (run.code !== 0) {
900 await update($, edits, (e) => ({ ...e, isWriting: false }));
901 await setView($, { error: failure(run, verb) });
902
903 return;
904 }
905 const sync = await runLore($, current.root, ["sync", "--json"]);
906 await update($, edits, (e) => ({ ...e, isWriting: false }));
907 if (sync.code !== 0) {
908 await setView($, { error: `${verb}, but ${failure(sync, "lore sync")}` });
909
910 return;
911 }
912 const nextId =
913 current.action === "rename"
914 ? (current.actionValue.trim().split(/\s+/)[0] ?? current.selectedId)
915 : current.selectedId;
916 await setView($, { action: null, actionValue: "", notice: `${verb}; lore sync ran.`, error: null });
917 await openConcept($, nextId);
918 await refresh($);
919 await countUncommitted($);
920}
921
922// ── The dashboard tool (LCLI-668) ─────────────────────────────────────────────
923
924/** One text argument of the call, read as input rather than trusted. */
925type TextArg = { ok: true; text: string | null } | { ok: false; why: string };
926
927/**
928 * Reads one text argument.
929 *
930 * Everything that crosses `tool.call` is input to validate, never a fact: the model
931 * sends it, and `$.tool.call` lets any plugin send it too (the rule `ui.message`
932 * already follows for its posted data). Absent, and blank -- which is the same ask,
933 * a model that meant "nothing here" -- leave nothing to do. Present and not text is
934 * refused by naming what it was: opening something in its place would be guessing at
935 * what was meant.
936 */
937function textArg(value: unknown): TextArg {
938 if (value === undefined || value === null) {
939 return { ok: true, text: null };
940 }
941 if (typeof value !== "string") {
942 return { ok: false, why: `must be a string, and this was ${describe(value)}` };
943 }
944
945 return { ok: true, text: value.trim() || null };
946}
947
948/** `full` as the tool reads it: absent, true, or false; anything else is refused. */
949function boolArg(value: unknown): { ok: true; value: boolean | null } | { ok: false; why: string } {
950 if (value === undefined || value === null) {
951 return { ok: true, value: null };
952 }
953 if (typeof value !== "boolean") {
954 return { ok: false, why: `must be true or false, and this was ${describe(value)}` };
955 }
956
957 return { ok: true, value };
958}
959
960/**
961 * What the engine says about our pane right now: its own record, NOT OPEN when the listing
962 * carries no pane of ours, or UNKNOWN when this engine cannot be asked at all.
963 *
964 * The engine's record rather than this module's, and the only reading that separates the
965 * three states an open can leave the pane in: drawn, open-but-undrawn, and drawn behind
966 * another pane's tab. `$.ui.open`'s own answer gives the first two and cannot give the
967 * third, because the tab in front is not the open's business.
968 *
969 * UNKNOWN is a real answer and not a failure to report, and the catch below carries BOTH
970 * ways it happens. The engine's test kit has no `$.ui.panes` at all -- measured there:
971 * `$.ui.panes is not a function` -- and a listing that refuses can say nothing either. A
972 * caller then falls back to the answer the open itself gave rather than to a state nobody
973 * measured. `typeof $.ui.panes !== "function"` is NOT how this is asked: the mod validator
974 * refuses `$` read as a value at all ("$.ui.panes is used as a value ... instead of
975 * called"), so the call is made and its absence is one of the things the catch catches.
976 *
977 * The call itself is on every engine this mod supports (checked in the 2.1.287 declaration,
978 * the mod's declared floor), so this fallback is what a test drives and what a refused or
979 * absent listing leaves -- not the path a real session takes.
980 */
981type PaneStanding = { known: true; pane: UiPane | null } | { known: false };
982
983async function paneStanding($: EngineInterface): Promise<PaneStanding> {
984 try {
985 const panes = await $.ui.panes();
986
987 return { known: true, pane: panes.find((listed) => listed.id === PANE) ?? null };
988 } catch {
989 return { known: false };
990 }
991}
992
993/** What an argument that should have been text or a boolean is called in the refusal. */
994function describe(value: unknown): string {
995 if (Array.isArray(value)) {
996 return "an array";
997 }
998 if (typeof value === "object") {
999 return "an object";
1000 }
1001
1002 return `the ${typeof value} ${String(value)}`;
1003}
1004
1005/**
1006 * The dashboard tool's one line about the size, from the latest draw.
1007 *
1008 * DEC-154 rule 4: report the DRAWN size, never the asked one -- "the full size" only
1009 * when the latest draw came within `SIZE_SLACK` of its ask; "full requested" when no
1010 * draw of the mode the call asked for has completed (a fresh toggle's restore draw
1011 * still in flight, say); otherwise the drawn size and why. The dock's short case IS
1012 * the kept width: a docked full pane more than the slack short of its ask is at a
1013 * width the surface kept -- the person's `pluginPanes.dockColumns` wins over every
1014 * request (rule 3) -- so the line says so, classified the way the pane classifies it
1015 * (the design's own rule: within 4 cells granted, anything further off the person's
1016 * own; seq 234, both panes alike). The inline short case is the surface keeping room
1017 * for the prompt above the block.
1018 *
1019 * The head is the caller's ("Opened the Lore pane", plus whatever else the call
1020 * opened), and the clause is joined the way Quest's board joins it (seq 243): the kept
1021 * width follows the head directly and the two granted shapes are comma-joined, so no
1022 * shape repeats the word "opened" and the two panes word the same state alike.
1023 */
1024function fullSizeAnswer(head: string): string {
1025 const draw = lastDraw;
1026 if (draw === null || draw.mode !== "full" || draw.wanted === null) {
1027 return `${head}, full requested`;
1028 }
1029 if (draw.drawn >= draw.wanted - SIZE_SLACK) {
1030 return `${head}, the full size`;
1031 }
1032
1033 return draw.placement === "dock"
1034 ? `${head} at ${draw.drawn + 1} columns; the width is kept`
1035 : `${head} at ${draw.drawn} rows; the screen keeps room for the prompt`;
1036}
1037
1038/**
1039 * Opens the pane for the model, and answers with what it opened.
1040 *
1041 * Every open is made WITHOUT `focus`, and the mode is set without raising the person's
1042 * ask: Claude may call this while the person is typing, so the tool never takes the
1043 * keyboard from the prompt (design of record, opum-doc
1044 * `docs/reference/pane-dashboard-tool-design.md` at dfde45e). `full` is a state
1045 * change rather than an open carrying a size, because the draw that follows is the
1046 * only place that knows the viewport -- exactly how the person's own toggle is
1047 * answered. What the answer may claim about the size is the latest draw's, never the
1048 * ask's (DEC-154 rule 4, `fullSizeAnswer`).
1049 *
1050 * A `doc` the bundle does not have opens nothing else in its place: it is read FIRST, and
1051 * the refusal returns before any other argument is applied -- no pane open, no tab switch,
1052 * no search. The failed read is not without trace: it leaves the pane's own status line
1053 * carrying lore's message, which is what the person sees if the pane is already up. That
1054 * line is a report of the failure, not something opened in the document's place.
1055 */
1056async function openDashboard(
1057 $: EngineInterface,
1058 args: Readonly<Record<string, unknown>>,
1059): Promise<{ result: string } | { deny: string }> {
1060 const doc = textArg(args.doc);
1061 if (!doc.ok) {
1062 return { deny: `The dashboard tool's "doc" ${doc.why}.` };
1063 }
1064 const query = textArg(args.query);
1065 if (!query.ok) {
1066 return { deny: `The dashboard tool's "query" ${query.why}.` };
1067 }
1068 const full = boolArg(args.full);
1069 if (!full.ok) {
1070 return { deny: `The dashboard tool's "full" ${full.why}.` };
1071 }
1072
1073 const opened: string[] = [];
1074 // The one line the answer below is built from. The `full` arm sets it, joining the
1075 // size clause to the head the way Quest's board joins it (seq 243): the kept width
1076 // follows the head directly -- "Opened the Lore pane at 80 columns; the width is
1077 // kept." -- rather than comma-joined like the other parts, so no shape repeats the
1078 // word "opened". The clause is read as the mode is set, before the open below, so the
1079 // answer reports the draw the call found rather than one it caused.
1080 let line: string | null = null;
1081 if (doc.text !== null) {
1082 const outcome = await openConcept($, doc.text);
1083 if (!outcome.ok) {
1084 return { deny: `No document "${doc.text}" in this bundle: ${outcome.error}` };
1085 }
1086 opened.push(`${doc.text} on Read`);
1087 }
1088 if (query.text !== null) {
1089 await setView($, { query: query.text });
1090 if (doc.text === null) {
1091 await showTab($, "search");
1092 opened.push(`Search for "${query.text}"`);
1093 } else {
1094 // The Read tab holds the pane, so the search is loaded rather than shown; the
1095 // line says which of the two the person sees.
1096 opened.push(`"${query.text}" waiting in Search`);
1097 }
1098 }
1099 if (full.value !== null) {
1100 await setPaneMode($, full.value ? "full" : "normal");
1101 const head = `Opened the Lore pane${opened.length > 0 ? `, ${opened.join(", ")}` : ""}`;
1102 line = full.value ? fullSizeAnswer(head) : `${head}, its normal size`;
1103 }
1104 // The pane is refreshed on every call, as it was on every invocation of the slash command
1105 // this tool replaced: only the `query` arm refreshes on its own -- through `showTab` -- so
1106 // without this a bare call or a `full`-only one would leave the catalogue as stale as the
1107 // 30-second timer allows. Fired after the state above is applied, so it reads what the call
1108 // leaves behind, and skipped in the one case that has already refreshed that same state
1109 // (the query arm with no `doc`, where the tab switch is the refresh), so no call runs
1110 // `lore query` twice for one ask.
1111 const refreshedByTab = doc.text === null && query.text !== null;
1112 if (!refreshedByTab) {
1113 void refresh($);
1114 }
1115 // This open is unsized, and "each open sets it anew": a size asked for earlier is cleared
1116 // by it, not left standing. `openPane` raises the one restoration ask for that (DEC-154
1117 // rule 2 as amended: an ask is raised by a toggle, or by an open that cleared the size,
1118 // and never by a resize or a redraw), so the next draw re-asks the size the mode implies.
1119 // Without the restoration, a call that did NOT change the mode (an already-full pane,
1120 // which is the steady state of a remembered full mode, or a `full: true` call on one)
1121 // would leave the surface at its share and nothing to re-ask (LCLI-668 review F2).
1122 const asked = await openPane($);
1123 // What actually happened, from the engine rather than from the ask: the listing says
1124 // whether the pane is drawn and whether it is the tab on top, and the open's own answer
1125 // stands where the engine cannot be asked or lists nothing of ours.
1126 const standing = await paneStanding($);
1127 const listed = standing.known ? standing.pane : null;
1128 const isPlaced = listed?.isPlaced ?? asked.isPlaced;
1129 const isShown = listed?.isShown ?? isPlaced;
1130 const detail = opened.length > 0 ? `, ${opened.join(", ")}` : "";
1131 const answer = line ?? `Opened the Lore pane${detail}`;
1132
1133 if (!isPlaced) {
1134 // The open is UNASKED -- nobody's command, prompt or press is behind a tool call --
1135 // so below the engine's floor it WAITS UNDRAWN, and the answer has to say that rather
1136 // than report the ask (opum-doc seq 182 item 3). Measured on Claude Code 2.1.288
1137 // (LCLI-672): a model's tool call at 100 columns answered `{ isPlaced: false }` and
1138 // drew nothing, where the same pane opened from a band Button press drew at once.
1139 //
1140 // The reason is the ENGINE'S own, because the floor is not a constant: it is 144
1141 // columns for a pane nobody has opened, 110 for one the person has opened before (in
1142 // this session or an earlier one), and a surface that places no panes has no floor at
1143 // all. A number written here would be wrong in exactly the case the person is asking
1144 // about, so the engine's sentence -- which names the floor that applies and the width
1145 // now -- is carried instead of one composed here.
1146 const reason = asked.isPlaced === false ? asked.reason : "it waits undrawn at this surface's size";
1147
1148 return { result: `The Lore pane is open but not drawn${detail}: ${reason}` };
1149 }
1150 if (!isShown) {
1151 // Drawn, and behind another pane's tab: open, and not the one in front. "Opened" alone
1152 // would be as wrong as "not drawn", so the answer says which it is.
1153 return { result: `${answer}, behind the pane in front.` };
1154 }
1155
1156 return { result: `${answer}.` };
1157}
1158
1159// ── The module ────────────────────────────────────────────────────────────────
1160
1161export const register: Register = (on, _options) => {
1162 on("session.start", async ($, e, next) => {
1163 await setView($, { root: await resolveRoot($) });
1164 // The pane's ONLY entry point (LCLI-667/668, opum-doc design of record): the tool the
1165 // `lore` skill routes `dashboard` to. No slash command is registered -- the engine's
1166 // `lore` skill owns that name, so a command of the same name is refused and takes this
1167 // whole hook down with it (measured on Claude Code 2.1.288). Awaited, because the first
1168 // `session.start` is awaited before the first prompt and a registration not awaited
1169 // there is not listed by turn one; its description stays short for the same reason it
1170 // exists at all -- it is listed to the model in every session that loads this mod.
1171 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_INPUT_SCHEMA });
1172 // The remembered size is restored here and ASKED for by the first draw: the
1173 // session's own surface has not been measured yet (`SessionStartInput` carries
1174 // no viewport), and a draw is the first moment `e.viewport` exists. The open
1175 // below raises the one restoration ask a full mode owes (DEC-154 rule 2 as
1176 // amended: a stored full mode asks once, on its first render).
1177 const remembered: PaneMode = (await $.store.get(MODE_KEY)) === "full" ? "full" : "normal";
1178 await update($, pane, (state) => ({ ...state, mode: remembered }));
1179 // Raised synchronously, so the first draw cannot beat the ask to the state: the
1180 // open below raises the same one again for its own callers. The drawings of a
1181 // previous session in this process are dropped with it -- the tool's answer and
1182 // the held-width reading both describe the pane THIS session draws.
1183 pendingAsk = remembered === "full" ? { mode: "full", byPerson: false } : null;
1184 lastDraw = null;
1185 void refresh($);
1186 void countUncommitted($);
1187 // The session's own open, which nobody asked for by hand: on a terminal under the
1188 // engine's floor it is refused, and the refused answer is what the band then offers.
1189 void openPane($);
1190 $.clock.every(REFRESH_MS, () => {
1191 void (async () => {
1192 const panes = await $.ui.panes();
1193 if (panes.some((pane) => pane.id === PANE && pane.isShown)) {
1194 await refresh($);
1195 await countUncommitted($);
1196 }
1197 })();
1198 });
1199
1200 return next(e);hooks/lore.ts 644 lines1// LCLI-664. The Lore pane's non-drawing half: how the pane reads what the
2// `lore` CLI says.
3//
4// Nothing in this file receives `$`: the engine's validator refuses a noun of
5// `$` passed across an import ("$ is followed only into a function declared in
6// this same file, never across an import"), so every `$.noun.method(...)` call
7// lives in register.tsx and what crosses this boundary is plain values -- an
8// argv to run, the run's own result. Parse stdout alone: `lore` prints lint
9// warnings to stderr even under --json, so stderr is read only to explain a
10// non-zero exit. Nothing here reimplements a lore behaviour: creation,
11// renaming, superseding, linking and validation run the commands themselves,
12// because a hand-rolled rename would miss inbound links and a hand-rolled
13// validation would disagree with the one CI runs.
14
15import type { ConceptDoc, ConceptSummary, LinkedTask, SearchHit, TypeInfo } from "../types";
16
17// `truncated` is set by the caller from the engine's own isStdoutTruncated /
18// isStderrTruncated: the engine caps each stream at its first 4 MiB, and a
19// truncated answer is not a parse failure to be reported as one -- it is an
20// incomplete answer, and it says so (LCLI-664 review F7).
21//
22// `timedOutMs` is set by the caller when the run was still going at the engine's
23// timeoutMs budget, which the engine answers by killing the child: neither a
24// killed child nor a rejected call says it was a timeout -- one reads as exit 1,
25// the other as any other failure -- so without this the pane blames lore or PATH
26// for a run it killed itself (LCLI-664 review F8).
27export type Run = {
28 code: number;
29 stdout: string;
30 stderr: string;
31 truncated?: boolean;
32 timedOutMs?: number;
33};
34
35type Envelope = { kind?: unknown; data?: unknown };
36
37const DETAIL_CAP = 300;
38
39function firstLine(text: string): string {
40 const line = text.split("\n").find((one) => one.trim().length > 0);
41
42 return line ? line.trim().slice(0, DETAIL_CAP) : "";
43}
44
45/** A short, readable failure: lore's own words when it gave any. */
46export function failure(run: Run, verb: string): string {
47 if (run.truncated) {
48 return `${verb}: lore's output was cut off at the engine's 4 MiB cap, so this answer is incomplete; narrow the query`;
49 }
50 if (run.timedOutMs !== undefined) {
51 const seconds = Math.round(run.timedOutMs / 1000);
52
53 return `${verb}: lore did not answer within ${seconds} seconds and was killed; run it in the terminal to see why`;
54 }
55 const detail = firstLine(run.stderr) || firstLine(run.stdout);
56 if (run.code === -1) {
57 return `${verb}: ${detail || "lore could not run (is it on PATH?)"}`;
58 }
59
60 return detail ? `${verb} failed (lore exited ${run.code}): ${detail}` : `${verb} failed (lore exited ${run.code})`;
61}
62
63/** lore's {schemaVersion, kind, data} envelope, or a readable failure. */
64function envelope(run: Run, want: string, verb: string): { data: unknown } | { error: string } {
65 if (run.truncated) {
66 return { error: failure(run, verb) };
67 }
68 if (run.code !== 0) {
69 return { error: failure(run, verb) };
70 }
71
72 let parsed: Envelope;
73 try {
74 parsed = JSON.parse(run.stdout) as Envelope;
75 } catch {
76 return { error: `${verb}: could not parse lore's JSON output` };
77 }
78 if (parsed.kind !== want) {
79 return { error: `${verb}: expected ${want}, lore answered ${String(parsed.kind)}` };
80 }
81
82 return { data: parsed.data };
83}
84
85const asString = (value: unknown): string | null => (typeof value === "string" ? value : null);
86const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
87const asRecord = (value: unknown): Record<string, unknown> =>
88 typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
89
90function strings(value: unknown): string[] {
91 return asArray(value).flatMap((one) => {
92 const text = asString(one);
93
94 return text === null ? [] : [text];
95 });
96}
97
98// ── Argv builders ─────────────────────────────────────────────────────────────
99
100type FilterView = { typeFilter: string; tagFilter: string; acrossRefs: boolean };
101
102/** The frontmatter filters the pane's chrome adds to either query. */
103function filters(view: FilterView): string[] {
104 const argv: string[] = [];
105 if (view.typeFilter) {
106 argv.push("--type", view.typeFilter);
107 }
108 if (view.tagFilter) {
109 argv.push("--tag", view.tagFilter);
110 }
111 if (view.acrossRefs) {
112 // The pane is a view, not a gate: partial cross-ref coverage reports
113 // rather than refusing with exit 6.
114 argv.push("--across-refs", "--allow-partial");
115 }
116
117 return argv;
118}
119
120/** `lore query`, no text: the whole bundle under the chrome's filters. */
121export function queryArgv(view: FilterView): string[] {
122 return ["query", "--json", ...filters(view)];
123}
124
125/** `lore query "<text>"` under the chrome's filters. */
126export function searchArgv(view: FilterView & { query: string }): string[] {
127 // The filters come first and `--` separates them from the text: everything
128 // after `--` is positional, so a search term that begins with `-` (or is
129 // `--anything`) is a term rather than an unknown option -- measured on lore
130 // 0.12.0, `query --json "-foo"` exits 2 with `unknown option "-foo"`, while
131 // `query --json -- "-foo"` answers with a query.results envelope (LCLI-664
132 // review F5).
133 const argv = ["query", "--json", ...filters(view)];
134 const text = view.query.trim();
135 if (text) {
136 argv.push("--", text);
137 }
138
139 return argv;
140}
141
142/** `lore new <type> "<title>"` with the draft's summary and tags. */
143export function newArgv(draft: { type: string; title: string; summary: string; tags: string }): string[] {
144 const argv = ["new", draft.type.trim(), draft.title.trim()];
145 if (draft.summary.trim()) {
146 argv.push("--summary", draft.summary.trim());
147 }
148 const tags = draft.tags
149 .split(",")
150 .map((tag) => tag.trim())
151 .filter(Boolean);
152 if (tags.length > 0) {
153 argv.push("--tags", tags.join(","));
154 }
155 argv.push("--json");
156
157 return argv;
158}
159
160const ACTION_VERBS: Record<string, string> = {
161 rename: "Renamed",
162 supersede: "Superseded",
163 link: "Linked",
164 unlink: "Unlinked",
165};
166
167export function actionVerb(action: string): string {
168 return ACTION_VERBS[action] ?? action;
169}
170
171/** The argv for one structural operation; an empty value is refused here. */
172export function actionArgv(
173 action: "rename" | "supersede" | "link" | "unlink",
174 id: string,
175 value: string,
176): { argv: string[] } | { error: string } {
177 const ids = value.split(/\s+/).filter(Boolean);
178 if (ids.length === 0) {
179 return { error: "Nothing to apply." };
180 }
181 const argv =
182 action === "rename" || action === "supersede"
183 ? [action, id, ids[0] ?? "", "--json"]
184 : [action, id, ...ids, "--json"];
185
186 return { argv };
187}
188
189// ── Parsers ───────────────────────────────────────────────────────────────────
190
191/**
192 * The two reads Browse needs: the query hits and the type vocabulary.
193 *
194 * `types` is null when only the vocabulary read failed: the concepts still
195 * browse, the caller keeps the vocabulary it already had, and `typesNote` carries
196 * lore's own words for why it is missing (LCLI-664 review F8).
197 */
198export type Browse =
199 | { ok: true; concepts: ConceptSummary[]; types: TypeInfo[] | null; typesNote?: string }
200 | { ok: false; error: string };
201
202export type Found = { ok: true; hits: SearchHit[] } | { ok: false; error: string };
203
204export type ReadResult = { ok: true; doc: ConceptDoc } | { ok: false; error: string };
205
206export type Created = { ok: true; id: string | null } | { ok: false; error: string };
207
208export type Outcome = { ok: true } | { ok: false; error: string };
209
210function conceptsFrom(data: unknown): ConceptSummary[] {
211 return asArray(asRecord(data).hits).flatMap((hit) => {
212 const one = asRecord(hit);
213 const id = asString(one.id);
214 if (id === null) {
215 return [];
216 }
217
218 return [{ id, type: asString(one.type) ?? "Concept", title: asString(one.title) ?? id }];
219 });
220}
221
222function hitsFrom(data: unknown): SearchHit[] {
223 return asArray(asRecord(data).hits).flatMap((hit) => {
224 const one = asRecord(hit);
225 const id = asString(one.id);
226 if (id === null) {
227 return [];
228 }
229
230 return [
231 {
232 id,
233 type: asString(one.type) ?? "Concept",
234 title: asString(one.title) ?? id,
235 snippet: asString(one.snippet) ?? "",
236 },
237 ];
238 });
239}
240
241function typeInfos(data: unknown): TypeInfo[] {
242 return asArray(asRecord(data).types).flatMap((entry) => {
243 const one = asRecord(entry);
244 const name = asString(one.name);
245 if (name === null) {
246 return [];
247 }
248
249 return [{ name, requiredSections: strings(one.requiredSections) }];
250 });
251}
252
253export function parseBrowse(query: Run, types: Run): Browse {
254 const found = envelope(query, "query.results", "Browse");
255 if ("error" in found) {
256 return { ok: false, error: found.error };
257 }
258 const report = envelope(types, "types.report", "Types");
259 if ("error" in report) {
260 // The concepts are what Browse is for; the vocabulary failing leaves the list
261 // browsable and says so, rather than failing the whole surface (F8).
262 return { ok: true, concepts: conceptsFrom(found.data), types: null, typesNote: report.error };
263 }
264
265 return { ok: true, concepts: conceptsFrom(found.data), types: typeInfos(report.data) };
266}
267
268export function parseSearch(run: Run): Found {
269 const found = envelope(run, "query.results", "Search");
270 if ("error" in found) {
271 return { ok: false, error: found.error };
272 }
273
274 return { ok: true, hits: hitsFrom(found.data) };
275}
276
277/** The type vocabulary, or null when the run could not be read. */
278export function parseTypes(run: Run): TypeInfo[] | null {
279 const report = envelope(run, "types.report", "Types");
280
281 return "error" in report ? null : typeInfos(report.data);
282}
283
284function rollupTasks(run: Run): LinkedTask[] {
285 const found = envelope(run, "tasks.rollup", "Linked tasks");
286 if ("error" in found) {
287 // A rollup that cannot be read leaves the strip empty rather than failing
288 // the read; the document itself is the point.
289 return [];
290 }
291
292 return asArray(asRecord(found.data).tasks).flatMap((entry) => {
293 const one = asRecord(entry);
294 const id = asString(one.id);
295 if (id === null) {
296 return [];
297 }
298
299 return [{ id, title: asString(one.title) ?? "", status: asString(one.status) ?? "" }];
300 });
301}
302
303export function parseRead(read: Run, tasks: Run, id: string): ReadResult {
304 const found = envelope(read, "read.concept", `Read ${id}`);
305 if ("error" in found) {
306 return { ok: false, error: found.error };
307 }
308 const data = asRecord(found.data);
309 const frontmatter = asRecord(data.frontmatter);
310 const doc: ConceptDoc = {
311 id: asString(data.id) ?? id,
312 path: asString(data.path) ?? "",
313 repoPath: repoPathFor(asString(data.path) ?? ""),
314 type: asString(data.type) ?? asString(frontmatter.type) ?? "Concept",
315 title: asString(frontmatter.title) ?? id,
316 summary: asString(frontmatter.summary),
317 status: asString(frontmatter.status),
318 tags: strings(frontmatter.tags),
319 body: asString(data.body) ?? "",
320 raw: null,
321 tasks: rollupTasks(tasks),
322 links: null,
323 };
324
325 return { ok: true, doc };
326}
327
328/**
329 * The repository-relative path of a concept file.
330 *
331 * `lore read` reports `path` relative to the bundle, and the bundle directory
332 * is lore's own constant (`docs/`, src/core/scaffold.ts DOCS_DIR); no CLI
333 * surface reports the repository-relative path, so the prefix is applied once
334 * here. `lore validate` and `$.fs` both address files from the repository
335 * root, which is why the pane carries this as `repoPath`.
336 */
337export function repoPathFor(bundlePath: string): string {
338 // The prefix is unconditional, never guarded on `startsWith("docs/")`: the
339 // only producer is `lore read`'s own `path`, which is bundle-relative by
340 // contract, so a bundle path that itself begins with `docs/` is a concept
341 // inside the bundle's own `docs/` folder (id `docs/x`, file `docs/docs/x.md`)
342 // rather than an already-prefixed path. Guarding on the string made that case
343 // resolve to `docs/x.md` -- a different file, which the write path would have
344 // created and reported as saved (LCLI-664 review F4).
345 return `docs/${bundlePath}`;
346}
347
348export function parseCreated(run: Run): Created {
349 const found = envelope(run, "new.result", "New");
350 if ("error" in found) {
351 return { ok: false, error: found.error };
352 }
353 const data = asRecord(found.data);
354
355 return { ok: true, id: asString(data.id) };
356}
357
358/**
359 * lore emits the full `validate.report` on stdout regardless of outcome and
360 * then returns exit 6, so stdout is parsed whatever the exit code; the first
361 * error-severity finding's own words come back, never a reimplementation's.
362 */
363export function parseValidate(run: Run): Outcome {
364 if (run.code === 0) {
365 return { ok: true };
366 }
367
368 let parsed: Envelope;
369 try {
370 parsed = JSON.parse(run.stdout) as Envelope;
371 } catch {
372 return { ok: false, error: failure(run, "Validate") };
373 }
374 if (parsed.kind !== "validate.report") {
375 return { ok: false, error: failure(run, "Validate") };
376 }
377 const report = asRecord(parsed.data);
378 const firstError = asArray(report.files)
379 .flatMap((file) => asArray(asRecord(file).findings))
380 .map(asRecord)
381 .find((finding) => finding.severity === "error");
382 const message = firstError ? asString(firstError.message) : null;
383
384 return {
385 ok: false,
386 error: message ? `Validation failed: ${message}` : failure(run, "Validate"),
387 };
388}
389
390// The escapes git leaves in a C-quoted path once the caller passes
391// `-c core.quotePath=false`: with that setting only a backslash, a double quote
392// or a control byte is quoted at all, so every escape is ASCII (LCLI-664 review
393// F8, measured against git on this machine).
394const C_ESCAPES: Record<string, string> = {
395 a: "\x07",
396 b: "\b",
397 f: "\f",
398 n: "\n",
399 r: "\r",
400 t: "\t",
401 v: "\v",
402 '"': '"',
403 "\\": "\\",
404};
405
406/**
407 * One path from a `git status --porcelain` line, C-quoted or raw, and where the
408 * text after it starts. Null when the line carries no path at all -- an
409 * unterminated quote is nothing trustworthy to read (LCLI-664 review F8).
410 */
411function takePorcelainPath(text: string, from: number): { path: string; next: number } | null {
412 if (text[from] !== '"') {
413 const path = text.slice(from).trim();
414
415 return path.length === 0 ? null : { path, next: text.length };
416 }
417
418 let path = "";
419 for (let at = from + 1; at < text.length; at += 1) {
420 const char = text[at] ?? "";
421 if (char === '"') {
422 return { path, next: at + 1 };
423 }
424 if (char !== "\\") {
425 path += char;
426 continue;
427 }
428 const octal = /^[0-7]{1,3}/.exec(text.slice(at + 1))?.[0];
429 if (octal !== undefined) {
430 path += String.fromCharCode(Number.parseInt(octal, 8));
431 at += octal.length;
432 continue;
433 }
434 path += C_ESCAPES[text[at + 1] ?? ""] ?? (text[at + 1] ?? "");
435 at += 1;
436 }
437
438 return null;
439}
440
441/** Uncommitted Markdown paths from `git status --porcelain`, for the landing strip. */
442export function parsePorcelain(stdout: string): string[] {
443 return stdout.split("\n").flatMap((line: string) => {
444 const rest = line.slice(3).trim();
445 if (rest.length === 0) {
446 return [];
447 }
448 const first = takePorcelainPath(rest, 0);
449 if (first === null) {
450 return [];
451 }
452 // A rename is `old -> new`, each side quoted or not; the strip counts the
453 // new name, which is the one that exists.
454 const after = rest.slice(first.next).trim();
455 const renamed = after.startsWith("->") ? takePorcelainPath(after.slice(2).trim(), 0) : null;
456 const path = renamed?.path ?? first.path;
457
458 return path.endsWith(".md") ? [path] : [];
459 });
460}
461
462// ── Frontmatter editing ───────────────────────────────────────────────────────
463
464/** Renders one value as a YAML double-quoted scalar, valid for any single-line string. */
465function yamlScalar(value: string): string {
466 return JSON.stringify(value);
467}
468
469function yamlValue(value: string | string[]): string {
470 return Array.isArray(value) ? `[${value.map(yamlScalar).join(", ")}]` : yamlScalar(value);
471}
472
473/**
474 * Rewrites the named frontmatter keys in `raw`, touching nothing else: an
475 * existing top-level line is replaced in place (its indented continuation and
476 * block-list lines go with it), a missing key is inserted before the closing
477 * fence, and a null removes the line. Values are written as YAML double-quoted
478 * scalars, tags as a flow list, so any single-line value survives. The body
479 * passes through byte for byte. Returns null when the file carries no
480 * frontmatter to edit.
481 */
482export function patchFrontmatter(raw: string, patch: Record<string, string | string[] | null>): string | null {
483 const match = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n?)/u.exec(raw);
484 const head = match?.[1];
485 if (match === null || head === undefined) {
486 return null;
487 }
488 const pending = new Map(Object.entries(patch));
489 const next: string[] = [];
490 const lines = head.split("\n");
491 for (let at = 0; at < lines.length; at += 1) {
492 const line = lines[at] ?? "";
493 const key = /^([A-Za-z0-9_-]+):/u.exec(line)?.[1];
494 if (key !== undefined && pending.has(key)) {
495 const value = pending.get(key) ?? null;
496 pending.delete(key);
497 // Continuation lines of the replaced value go with it.
498 while (at + 1 < lines.length && /^[ \t]|^- /u.test(lines[at + 1] ?? "")) {
499 at += 1;
500 }
501 if (value !== null) {
502 next.push(`${key}: ${yamlValue(value)}`);
503 }
504 continue;
505 }
506 next.push(line);
507 }
508 for (const [key, value] of pending) {
509 if (value !== null) {
510 next.push(`${key}: ${yamlValue(value)}`);
511 }
512 }
513
514 return `---\n${next.join("\n")}\n---${match[2] ?? "\n"}${raw.slice(match[0].length)}`;
515}
516
517/**
518 * The file's own frontmatter, byte for byte, with everything after it replaced by
519 * `body`. `patchFrontmatter` owns the frontmatter; this owns the rest, so the inline
520 * editor writes a body and touches nothing above the closing `---`.
521 *
522 * Returns null for a file with no frontmatter, exactly as `patchFrontmatter` refuses
523 * one: a body rewrite that silently dropped a (possibly malformed) frontmatter block
524 * would be `lore validate`'s problem to catch afterwards, not this function's to hide.
525 * The body is normalised the way the file format expects it — no leading blank lines
526 * (the closing delimiter's newline is the separator) and exactly one trailing newline.
527 */
528export function replaceBody(raw: string, body: string): string | null {
529 const match = /^---\r?\n[\s\S]*?\r?\n---(\r?\n?)/u.exec(raw);
530 if (match === null) {
531 return null;
532 }
533 const head = raw.slice(0, match[0].length);
534 const text = body.replace(/^\n+/u, "").replace(/\n*$/u, "");
535
536 return text === "" ? head : `${head}${text}\n`;
537}
538
539// ── Pure helpers ──────────────────────────────────────────────────────────────
540
541/** Bodies are long; the pane draws a first slice and says so. */
542export const BODY_CAP = 9_500;
543
544export function cap(text: string): string {
545 return text.length > BODY_CAP ? text.slice(0, BODY_CAP) : text;
546}
547
548export function isCapped(text: string): boolean {
549 return text.length > BODY_CAP;
550}
551
552/**
553 * The largest body the inline editor will open on, and the longest line within it.
554 *
555 * The engine bounds what a `Client` may be handed and draw: its props and the tree it
556 * returns serialize to 100,000 characters, and one `Text` child to 10,000 — "or the
557 * instance unmounts". The pane hands the editor the WHOLE body as props, so a body past
558 * those bounds does not fail the editor alone: the engine refuses the PANE's render and
559 * reports `opum-lore drew nothing on the terminal surface`. Both caps therefore sit
560 * under the engine's bounds with room for JSON escaping, which inflates a body full of
561 * quotes and newlines past its own length. Measured on Claude Code 2.1.287: a
562 * 108,718-character body (`docs/runbooks/release-publishing.md` in this repository)
563 * refuses the pane, and 99,000 characters opens cleanly. `Open in editor` has no such
564 * bound, and is where a refusal sends the person.
565 */
566export const EDITOR_BODY_CAP = 90_000;
567export const EDITOR_LINE_CAP = 9_000;
568
569/** Whether the inline body editor can open on this body without refusing the pane. */
570export function editorFits(body: string): boolean {
571 if (body.length > EDITOR_BODY_CAP) {
572 return false;
573 }
574
575 return !body.split("\n").some((line) => line.length > EDITOR_LINE_CAP);
576}
577
578function escapeRegExp(text: string): string {
579 return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
580}
581
582/** Whether the body carries the section as a heading. */
583export function hasSection(body: string, name: string): boolean {
584 return new RegExp(`^#{1,6}\\s+${escapeRegExp(name)}\\s*$`, "imu").test(body);
585}
586
587export function requiredSectionsFor(types: TypeInfo[], type: string): string[] {
588 return types.find((one) => one.name === type)?.requiredSections ?? [];
589}
590
591/** The raw hrefs in the body that look internal, so only those press. */
592export function internalHrefs(body: string): string[] {
593 const found = new Set<string>();
594 const pattern = /\]\(([^)\s]+)\)/gu;
595 let match: RegExpExecArray | null = pattern.exec(body);
596 while (match !== null && found.size < 256) {
597 const href = match[1] ?? "";
598 if (href && !/^[a-z][a-z0-9+.-]*:/iu.test(href)) {
599 found.add(href.slice(0, 2048));
600 }
601 match = pattern.exec(body);
602 }
603
604 return [...found];
605}
606
607/** The bundle id an internal href points at, resolved against the open concept. */
608export function bundleIdFor(href: string, fromId: string): string | null {
609 const clean = href.split("#")[0] ?? "";
610 if (!clean || clean.startsWith("/") || /^[a-z][a-z0-9+.-]*:/iu.test(clean)) {
611 return null;
612 }
613 const parts = [...fromId.split("/").slice(0, -1), ...clean.split("/")];
614 const out: string[] = [];
615 for (const part of parts) {
616 if (part === "" || part === ".") {
617 continue;
618 }
619 if (part === "..") {
620 out.pop();
621 continue;
622 }
623 out.push(part);
624 }
625 let id = out.join("/");
626 if (id.endsWith(".md")) {
627 id = id.slice(0, -3);
628 }
629
630 return id || null;
631}
632
633/** The concepts grouped by type, types in name order. */
634export function groupByType(concepts: ConceptSummary[]): { type: string; rows: ConceptSummary[] }[] {
635 const groups = new Map<string, ConceptSummary[]>();
636 for (const concept of concepts) {
637 const rows = groups.get(concept.type) ?? [];
638 rows.push(concept);
639 groups.set(concept.type, rows);
640 }
641
642 return [...groups.entries()].map(([type, rows]) => ({ type, rows })).sort((a, b) => a.type.localeCompare(b.type));
643}
644hooks/editor.tsx 86 lines1// LCLI-664. The pane's lightweight inline body editor, as a `Client` surface module:
2// it draws the window of lines and the cursor cell, keys them through
3// `hooks/editor-ops`, and posts the text to the pane so Save writes what is on screen.
4//
5// A Client surface module runs on the drawing thread and receives no `$` — `post` is
6// how it reaches the hooks module, and the props the pane sends back are how the pane
7// reaches it. The instance's own state survives the pane's redraws, so the text being
8// edited is not round-tripped through `$.state` on every keystroke.
9//
10// The props carry a `revision`: the pane bumps it when it wants the editor to adopt
11// the text in the props — opening the editor, or re-opening it on text that changed
12// underneath. Equal revisions mean the person is typing and their text wins. A failed
13// save does NOT bump: its text is already what the editor holds, so adopting would only
14// re-create the instance — parking the cursor at the end and dropping the redo ring.
15
16import type { ClientModule } from "claude-code";
17import { editorCreate, editorKey, editorText, editorView, splitGrapheme, type EditorLocal } from "./editor-ops";
18
19type BodyEditorProps = { text: string; revision: number };
20
21/** The instance's local state: the editor, and the props revision it was built from. */
22type BodyEditorCell = { editor: EditorLocal; revision: number };
23
24const BodyEditor: ClientModule<BodyEditorProps, BodyEditorCell> = (props, surface) => {
25 const { Box, Text } = surface.elements;
26 const held = surface.state;
27 const listed = typeof props.text === "string" ? props.text : "";
28 const revision = typeof props.revision === "number" ? props.revision : 0;
29 const cell: BodyEditorCell =
30 held && held.revision === revision ? held : { editor: editorCreate(listed), revision };
31 if (!held || held.revision !== revision) {
32 // One call per props change, never on the draws in between: a `setState` on three
33 // draws in a row with nothing between unmounts the instance.
34 surface.setState(cell);
35 }
36
37 if (!held) {
38 // The key listener is set once, while the instance has no state yet; a later call
39 // would replace it. The handler reads `surface.state` at key time, so it always
40 // acts on the text as it stands.
41 surface.onKey((event) => {
42 const current = surface.state;
43 if (!current) {
44 return;
45 }
46 const next = editorKey(current.editor, event.key, event);
47 if (next !== current.editor) {
48 surface.setState({ editor: next, revision: current.revision });
49 surface.post({ kind: "text", text: editorText(next) });
50 }
51 });
52 }
53
54 // One row for the key hint, and the rest for the document.
55 const view = editorView(cell.editor, Math.max(1, surface.rows - 1));
56 const lines = view.rows.map((row, index) => {
57 if (index !== view.cursorRow) {
58 return Box({
59 key: `line-${row.number}`,
60 children: Text({ children: row.text === "" ? " " : row.text, wrap: "truncate" }),
61 });
62 }
63 const { before, cell: at, after } = splitGrapheme(row.text, view.cursorColumn);
64
65 return Box({
66 key: `line-${row.number}`,
67 flexDirection: "row",
68 children: [
69 Text({ children: before }),
70 Text({ children: at === "" ? " " : at, inverse: true }),
71 Text({ children: after }),
72 ],
73 });
74 });
75
76 return Box({
77 flexDirection: "column",
78 children: [
79 ...lines,
80 Text({ children: "Ctrl/⌘+Z undo · Ctrl/⌘+Shift+Z redo", dimColor: true, wrap: "truncate" }),
81 ],
82 });
83};
84
85export default BodyEditor;
86hooks/editor-ops.ts 263 lines1// LCLI-664. The pane's lightweight body editor: every editing operation, as a pure
2// function over an immutable `@codemirror/state` state — so the client surface module
3// that draws and keys it stays a drawing, and every operation is unit-testable with no
4// engine present.
5//
6// What is the library's and what is this file's: the document, the transactions, the
7// line index and grapheme-cluster boundaries are `@codemirror/state`'s (vendored, see
8// `hooks/vendor/README.md`); this file is the key mapping and the undo ring over its
9// states. Nothing here re-implements text handling — cursor motion goes through
10// `findClusterBreak`, and every edit goes through `state.update`, so combining marks,
11// emoji and surrogate pairs move and delete the way the library moves them.
12
13import { EditorState, findClusterBreak } from "./vendor/codemirror-state.js";
14
15/** The editor's whole local state: the CodeMirror state, plus an undo ring over it. */
16export type EditorLocal = {
17 readonly state: EditorState;
18 readonly past: readonly EditorState[];
19 readonly future: readonly EditorState[];
20};
21
22/** Steps kept in each direction. A document body is small; the ring is capped anyway. */
23const HISTORY_CAP = 100;
24
25export function editorCreate(text: string): EditorLocal {
26 // The cursor starts at the end of the body: the editor is opened to add to a
27 // document, and `EditorState.create` would otherwise put it at position 0.
28 const state = EditorState.create({ doc: text });
29
30 return { state: state.update({ selection: { anchor: text.length } }).state, past: [], future: [] };
31}
32
33export function editorText(local: EditorLocal): string {
34 return local.state.doc.toString();
35}
36
37export function editorCursor(local: EditorLocal): number {
38 return local.state.selection.main.head;
39}
40
41/**
42 * One edit: the previous state joins the undo ring and the redo ring is dropped.
43 *
44 * An update that leaves the document alone is not an edit. `state.update` returns a
45 * NEW state for a transaction whose change range is empty, and adopting it would put
46 * a step on the undo ring that undoes nothing — a Ctrl+Z that appears dead — and drop
47 * the redo ring along with it.
48 */
49function edited(local: EditorLocal, next: EditorState): EditorLocal {
50 if (next === local.state || next.doc.eq(local.state.doc)) {
51 return local;
52 }
53
54 return { state: next, past: [...local.past, local.state].slice(-HISTORY_CAP), future: [] };
55}
56
57/** A cursor move: `state.update` returns the same state when nothing changed. */
58function moved(local: EditorLocal, anchor: number, head = anchor): EditorLocal {
59 const next = local.state.update({ selection: { anchor, head } }).state;
60
61 return next === local.state ? local : { ...local, state: next };
62}
63
64export function insertText(local: EditorLocal, text: string): EditorLocal {
65 if (text === "") {
66 return local;
67 }
68 const { from, to } = local.state.selection.main;
69
70 return edited(
71 local,
72 local.state.update({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } }).state,
73 );
74}
75
76export function deleteBackward(local: EditorLocal): EditorLocal {
77 const { from, to } = local.state.selection.main;
78 if (from !== to) {
79 return edited(local, local.state.update({ changes: { from, to, insert: "" }, selection: { anchor: from } }).state);
80 }
81 if (from === 0) {
82 return local;
83 }
84 // The line, not the document: the boundary is a property of the text around the
85 // cursor, and a body can be long enough that copying it per keystroke shows.
86 const line = local.state.doc.lineAt(from);
87 // At a line's start the character behind the cursor is the line break, which is not
88 // part of `line.text`, so the cluster walk has nothing to step over and the range
89 // would come out empty. Backspace there joins the line with the one above it.
90 const back = from === line.from ? from - 1 : findClusterBreak(line.text, from - line.from, false) + line.from;
91
92 return edited(
93 local,
94 local.state.update({ changes: { from: back, to: from, insert: "" }, selection: { anchor: back } }).state,
95 );
96}
97
98export function deleteForward(local: EditorLocal): EditorLocal {
99 const { from, to } = local.state.selection.main;
100 if (from !== to) {
101 return edited(local, local.state.update({ changes: { from, to, insert: "" }, selection: { anchor: from } }).state);
102 }
103 const line = local.state.doc.lineAt(from);
104 if (from === line.to) {
105 // At a line's end the character ahead of the cursor is the line break, which is
106 // not part of `line.text`. Only the last line has nothing after it; anywhere else
107 // the delete removes the break and joins the next line up.
108 if (line.number === local.state.doc.lines) {
109 return local;
110 }
111
112 return edited(
113 local,
114 local.state.update({ changes: { from, to: from + 1, insert: "" }, selection: { anchor: from } }).state,
115 );
116 }
117 const forward = findClusterBreak(line.text, from - line.from, true) + line.from;
118
119 return edited(local, local.state.update({ changes: { from, to: forward, insert: "" } }).state);
120}
121
122export type MoveKey = "left" | "right" | "up" | "down" | "home" | "end";
123
124export function moveCursor(local: EditorLocal, key: MoveKey): EditorLocal {
125 const from = local.state.selection.main.head;
126 const line = local.state.doc.lineAt(from);
127 if (key === "left") {
128 return moved(local, from === line.from ? Math.max(0, line.from - 1) : findClusterBreak(line.text, from - line.from, false) + line.from);
129 }
130 if (key === "right") {
131 return moved(
132 local,
133 from === line.to && line.number < local.state.doc.lines
134 ? line.to + 1
135 : findClusterBreak(line.text, from - line.from, true) + line.from,
136 );
137 }
138 if (key === "home") {
139 return moved(local, line.from);
140 }
141 if (key === "end") {
142 return moved(local, line.to);
143 }
144 const number = line.number + (key === "up" ? -1 : 1);
145 // Off either end of the document there is no line to move to, so the key does
146 // nothing. `Text.line` THROWS on an out-of-range number rather than returning
147 // nothing, and `editorCreate` opens with the cursor on the last line — so an
148 // unguarded +1 here is a throw on the first Down press of a freshly opened editor.
149 if (number < 1 || number > local.state.doc.lines) {
150 return local;
151 }
152 const target = local.state.doc.line(number);
153 // Character column, deliberately: a visual column would need the surface's width
154 // and the line's tab stops, which is more than "lightweight" pays for.
155 return moved(local, Math.min(target.from + (from - line.from), target.to));
156}
157
158export function undo(local: EditorLocal): EditorLocal {
159 const previous = local.past[local.past.length - 1];
160 if (!previous) {
161 return local;
162 }
163
164 return { state: previous, past: local.past.slice(0, -1), future: [local.state, ...local.future].slice(0, HISTORY_CAP) };
165}
166
167export function redo(local: EditorLocal): EditorLocal {
168 const next = local.future[0];
169 if (!next) {
170 return local;
171 }
172
173 return { state: next, past: [...local.past, local.state].slice(-HISTORY_CAP), future: local.future.slice(1) };
174}
175
176/** One key, as the client surface reports it (`ClientKeyEvent`). */
177export function editorKey(
178 local: EditorLocal,
179 key: string,
180 modifiers: { ctrl?: true; shift?: true; meta?: true } = {},
181): EditorLocal {
182 const chord = modifiers.ctrl === true || modifiers.meta === true;
183 if (chord) {
184 const lower = key.toLowerCase();
185 if (lower === "z") {
186 return modifiers.shift === true ? redo(local) : undo(local);
187 }
188 if (lower === "y") {
189 return redo(local);
190 }
191
192 return local;
193 }
194 if (key === "backspace") {
195 return deleteBackward(local);
196 }
197 if (key === "delete") {
198 return deleteForward(local);
199 }
200 if (key === "return" || key === "enter") {
201 return insertText(local, "\n");
202 }
203 if (key === "left" || key === "right" || key === "up" || key === "down" || key === "home" || key === "end") {
204 return moveCursor(local, key);
205 }
206 if (key === "space") {
207 // The space bar is the ONE key the engine delivers as a name rather than as the
208 // character typed. Measured on Claude Code 2.1.287, in the dispatch that builds a
209 // Client's key event: `Jr.find(([m]) => i[m])?.[1] ?? (o === " " ? "space" : o)` —
210 // every other key is its table name (`up`, `return`, `backspace`, …) or the
211 // character itself. Inserting the name would type the word "space" into the body.
212 return insertText(local, " ");
213 }
214 if (key === "tab" || key === "escape" || key.startsWith("page")) {
215 return local;
216 }
217 // A printable key is "the character typed", and nothing else is: one character is
218 // text, and anything longer is a key's NAME this editor does not act on. An earlier
219 // heuristic here accepted any name up to 8 characters that was not in a hand-kept
220 // list, which let "space" through — and would have let any name the engine adds.
221 if ([...key].length === 1) {
222 return insertText(local, key);
223 }
224
225 return local;
226}
227
228/**
229 * One line split so its middle is a whole grapheme — the cursor's cell. The surface
230 * draws the middle inverted; slicing by code unit instead would tear a surrogate pair
231 * or a combining sequence in half at the cursor.
232 */
233export function splitGrapheme(text: string, at: number): { before: string; cell: string; after: string } {
234 const end = findClusterBreak(text, at, true);
235
236 return { before: text.slice(0, at), cell: text.slice(at, end), after: text.slice(end) };
237}
238
239/** What the drawing draws: the rows in view, and where the cursor cell is. */
240export type EditorView = {
241 readonly rows: { number: number; text: string }[];
242 readonly cursorRow: number;
243 readonly cursorColumn: number;
244};
245
246/**
247 * The window of lines to draw, scrolled so the cursor is in it. `rows` is the
248 * region's height; when the cursor sits below the window the window follows it.
249 */
250export function editorView(local: EditorLocal, rows: number): EditorView {
251 const cursor = local.state.selection.main.head;
252 const line = local.state.doc.lineAt(cursor);
253 const height = Math.max(1, rows);
254 const last = Math.min(local.state.doc.lines, Math.max(height, line.number));
255 const first = Math.max(1, last - height + 1);
256 const window: { number: number; text: string }[] = [];
257 for (let at = first; at <= last; at += 1) {
258 window.push({ number: at, text: local.state.doc.line(at).text });
259 }
260
261 return { rows: window, cursorRow: line.number - first, cursorColumn: cursor - line.from };
262}
263hooks/vendor/codemirror-state.js 3950 lines1import { findClusterBreak as findClusterBreak$1 } from './find-cluster-break.js';
2
3/**
4The data structure for documents. @nonabstract
5*/
6class Text {
7 /**
8 Get the line description around the given position.
9 */
10 lineAt(pos) {
11 if (pos < 0 || pos > this.length)
12 throw new RangeError(`Invalid position ${pos} in document of length ${this.length}`);
13 return this.lineInner(pos, false, 1, 0);
14 }
15 /**
16 Get the description for the given (1-based) line number.
17 */
18 line(n) {
19 if (n < 1 || n > this.lines)
20 throw new RangeError(`Invalid line number ${n} in ${this.lines}-line document`);
21 return this.lineInner(n, true, 1, 0);
22 }
23 /**
24 Replace a range of the text with the given content.
25 */
26 replace(from, to, text) {
27 [from, to] = clip(this, from, to);
28 let parts = [];
29 this.decompose(0, from, parts, 2 /* Open.To */);
30 if (text.length)
31 text.decompose(0, text.length, parts, 1 /* Open.From */ | 2 /* Open.To */);
32 this.decompose(to, this.length, parts, 1 /* Open.From */);
33 return TextNode.from(parts, this.length - (to - from) + text.length);
34 }
35 /**
36 Append another document to this one.
37 */
38 append(other) {
39 return this.replace(this.length, this.length, other);
40 }
41 /**
42 Retrieve the text between the given points.
43 */
44 slice(from, to = this.length) {
45 [from, to] = clip(this, from, to);
46 let parts = [];
47 this.decompose(from, to, parts, 0);
48 return TextNode.from(parts, to - from);
49 }
50 /**
51 Test whether this text is equal to another instance.
52 */
53 eq(other) {
54 if (other == this)
55 return true;
56 if (other.length != this.length || other.lines != this.lines)
57 return false;
58 let start = this.scanIdentical(other, 1), end = this.length - this.scanIdentical(other, -1);
59 let a = new RawTextCursor(this), b = new RawTextCursor(other);
60 for (let skip = start, pos = start;;) {
61 a.next(skip);
62 b.next(skip);
63 skip = 0;
64 if (a.lineBreak != b.lineBreak || a.done != b.done || a.value != b.value)
65 return false;
66 pos += a.value.length;
67 if (a.done || pos >= end)
68 return true;
69 }
70 }
71 /**
72 Iterate over the text. When `dir` is `-1`, iteration happens
73 from end to start. This will return lines and the breaks between
74 them as separate strings.
75 */
76 iter(dir = 1) { return new RawTextCursor(this, dir); }
77 /**
78 Iterate over a range of the text. When `from` > `to`, the
79 iterator will run in reverse.
80 */
81 iterRange(from, to = this.length) { return new PartialTextCursor(this, from, to); }
82 /**
83 Return a cursor that iterates over the given range of lines,
84 _without_ returning the line breaks between, and yielding empty
85 strings for empty lines.
86
87 When `from` and `to` are given, they should be 1-based line numbers.
88 */
89 iterLines(from, to) {
90 let inner;
91 if (from == null) {
92 inner = this.iter();
93 }
94 else {
95 if (to == null)
96 to = this.lines + 1;
97 let start = this.line(from).from;
98 inner = this.iterRange(start, Math.max(start, to == this.lines + 1 ? this.length : to <= 1 ? 0 : this.line(to - 1).to));
99 }
100 return new LineCursor(inner);
101 }
102 /**
103 Return the document as a string, using newline characters to
104 separate lines.
105 */
106 toString() { return this.sliceString(0); }
107 /**
108 Convert the document to an array of lines (which can be
109 deserialized again via [`Text.of`](https://codemirror.net/6/docs/ref/#state.Text^of)).
110 */
111 toJSON() {
112 let lines = [];
113 this.flatten(lines);
114 return lines;
115 }
116 /**
117 @internal
118 */
119 constructor() { }
120 /**
121 Create a `Text` instance for the given array of lines.
122 */
123 static of(text) {
124 if (text.length == 0)
125 throw new RangeError("A document must have at least one line");
126 if (text.length == 1 && !text[0])
127 return Text.empty;
128 return text.length <= 32 /* Tree.Branch */ ? new TextLeaf(text) : TextNode.from(TextLeaf.split(text, []));
129 }
130}
131// Leaves store an array of line strings. There are always line breaks
132// between these strings. Leaves are limited in size and have to be
133// contained in TextNode instances for bigger documents.
134class TextLeaf extends Text {
135 constructor(text, length = textLength(text)) {
136 super();
137 this.text = text;
138 this.length = length;
139 }
140 get lines() { return this.text.length; }
141 get children() { return null; }
142 lineInner(target, isLine, line, offset) {
143 for (let i = 0;; i++) {
144 let string = this.text[i], end = offset + string.length;
145 if ((isLine ? line : end) >= target)
146 return new Line(offset, end, line, string);
147 offset = end + 1;
148 line++;
149 }
150 }
151 decompose(from, to, target, open) {
152 let text = from <= 0 && to >= this.length ? this
153 : new TextLeaf(sliceText(this.text, from, to), Math.min(to, this.length) - Math.max(0, from));
154 if (open & 1 /* Open.From */) {
155 let prev = target.pop();
156 let joined = appendText(text.text, prev.text.slice(), 0, text.length);
157 if (joined.length <= 32 /* Tree.Branch */) {
158 target.push(new TextLeaf(joined, prev.length + text.length));
159 }
160 else {
161 let mid = joined.length >> 1;
162 target.push(new TextLeaf(joined.slice(0, mid)), new TextLeaf(joined.slice(mid)));
163 }
164 }
165 else {
166 target.push(text);
167 }
168 }
169 replace(from, to, text) {
170 if (!(text instanceof TextLeaf))
171 return super.replace(from, to, text);
172 [from, to] = clip(this, from, to);
173 let lines = appendText(this.text, appendText(text.text, sliceText(this.text, 0, from)), to);
174 let newLen = this.length + text.length - (to - from);
175 if (lines.length <= 32 /* Tree.Branch */)
176 return new TextLeaf(lines, newLen);
177 return TextNode.from(TextLeaf.split(lines, []), newLen);
178 }
179 sliceString(from, to = this.length, lineSep = "\n") {
180 [from, to] = clip(this, from, to);
181 let result = "";
182 for (let pos = 0, i = 0; pos <= to && i < this.text.length; i++) {
183 let line = this.text[i], end = pos + line.length;
184 if (pos > from && i)
185 result += lineSep;
186 if (from < end && to > pos)
187 result += line.slice(Math.max(0, from - pos), to - pos);
188 pos = end + 1;
189 }
190 return result;
191 }
192 flatten(target) {
193 for (let line of this.text)
194 target.push(line);
195 }
196 scanIdentical() { return 0; }
197 static split(text, target) {
198 let part = [], len = -1;
199 for (let line of text) {
200 part.push(line);
201 len += line.length + 1;
202 if (part.length == 32 /* Tree.Branch */) {
203 target.push(new TextLeaf(part, len));
204 part = [];
205 len = -1;
206 }
207 }
208 if (len > -1)
209 target.push(new TextLeaf(part, len));
210 return target;
211 }
212}
213// Nodes provide the tree structure of the `Text` type. They store a
214// number of other nodes or leaves, taking care to balance themselves
215// on changes. There are implied line breaks _between_ the children of
216// a node (but not before the first or after the last child).
217class TextNode extends Text {
218 constructor(children, length) {
219 super();
220 this.children = children;
221 this.length = length;
222 this.lines = 0;
223 for (let child of children)
224 this.lines += child.lines;
225 }
226 lineInner(target, isLine, line, offset) {
227 for (let i = 0;; i++) {
228 let child = this.children[i], end = offset + child.length, endLine = line + child.lines - 1;
229 if ((isLine ? endLine : end) >= target)
230 return child.lineInner(target, isLine, line, offset);
231 offset = end + 1;
232 line = endLine + 1;
233 }
234 }
235 decompose(from, to, target, open) {
236 for (let i = 0, pos = 0; pos <= to && i < this.children.length; i++) {
237 let child = this.children[i], end = pos + child.length;
238 if (from <= end && to >= pos) {
239 let childOpen = open & ((pos <= from ? 1 /* Open.From */ : 0) | (end >= to ? 2 /* Open.To */ : 0));
240 if (pos >= from && end <= to && !childOpen)
241 target.push(child);
242 else
243 child.decompose(from - pos, to - pos, target, childOpen);
244 }
245 pos = end + 1;
246 }
247 }
248 replace(from, to, text) {
249 [from, to] = clip(this, from, to);
250 if (text.lines < this.lines)
251 for (let i = 0, pos = 0; i < this.children.length; i++) {
252 let child = this.children[i], end = pos + child.length;
253 // Fast path: if the change only affects one child and the
254 // child's size remains in the acceptable range, only update
255 // that child
256 if (from >= pos && to <= end) {
257 let updated = child.replace(from - pos, to - pos, text);
258 let totalLines = this.lines - child.lines + updated.lines;
259 if (updated.lines < (totalLines >> (5 /* Tree.BranchShift */ - 1)) &&
260 updated.lines > (totalLines >> (5 /* Tree.BranchShift */ + 1))) {
261 let copy = this.children.slice();
262 copy[i] = updated;
263 return new TextNode(copy, this.length - (to - from) + text.length);
264 }
265 return super.replace(pos, end, updated);
266 }
267 pos = end + 1;
268 }
269 return super.replace(from, to, text);
270 }
271 sliceString(from, to = this.length, lineSep = "\n") {
272 [from, to] = clip(this, from, to);
273 let result = "";
274 for (let i = 0, pos = 0; i < this.children.length && pos <= to; i++) {
275 let child = this.children[i], end = pos + child.length;
276 if (pos > from && i)
277 result += lineSep;
278 if (from < end && to > pos)
279 result += child.sliceString(from - pos, to - pos, lineSep);
280 pos = end + 1;
281 }
282 return result;
283 }
284 flatten(target) {
285 for (let child of this.children)
286 child.flatten(target);
287 }
288 scanIdentical(other, dir) {
289 if (!(other instanceof TextNode))
290 return 0;
291 let length = 0;
292 let [iA, iB, eA, eB] = dir > 0 ? [0, 0, this.children.length, other.children.length]
293 : [this.children.length - 1, other.children.length - 1, -1, -1];
294 for (;; iA += dir, iB += dir) {
295 if (iA == eA || iB == eB)
296 return length;
297 let chA = this.children[iA], chB = other.children[iB];
298 if (chA != chB)
299 return length + chA.scanIdentical(chB, dir);
300 length += chA.length + 1;
301 }
302 }
303 static from(children, length = children.reduce((l, ch) => l + ch.length + 1, -1)) {
304 let lines = 0;
305 for (let ch of children)
306 lines += ch.lines;
307 if (lines < 32 /* Tree.Branch */) {
308 let flat = [];
309 for (let ch of children)
310 ch.flatten(flat);
311 return new TextLeaf(flat, length);
312 }
313 let chunk = Math.max(32 /* Tree.Branch */, lines >> 5 /* Tree.BranchShift */), maxChunk = chunk << 1, minChunk = chunk >> 1;
314 let chunked = [], currentLines = 0, currentLen = -1, currentChunk = [];
315 function add(child) {
316 let last;
317 if (child.lines > maxChunk && child instanceof TextNode) {
318 for (let node of child.children)
319 add(node);
320 }
321 else if (child.lines > minChunk && (currentLines > minChunk || !currentLines)) {
322 flush();
323 chunked.push(child);
324 }
325 else if (child instanceof TextLeaf && currentLines &&
326 (last = currentChunk[currentChunk.length - 1]) instanceof TextLeaf &&
327 child.lines + last.lines <= 32 /* Tree.Branch */) {
328 currentLines += child.lines;
329 currentLen += child.length + 1;
330 currentChunk[currentChunk.length - 1] = new TextLeaf(last.text.concat(child.text), last.length + 1 + child.length);
331 }
332 else {
333 if (currentLines + child.lines > chunk)
334 flush();
335 currentLines += child.lines;
336 currentLen += child.length + 1;
337 currentChunk.push(child);
338 }
339 }
340 function flush() {
341 if (currentLines == 0)
342 return;
343 chunked.push(currentChunk.length == 1 ? currentChunk[0] : TextNode.from(currentChunk, currentLen));
344 currentLen = -1;
345 currentLines = currentChunk.length = 0;
346 }
347 for (let child of children)
348 add(child);
349 flush();
350 return chunked.length == 1 ? chunked[0] : new TextNode(chunked, length);
351 }
352}
353Text.empty = /*@__PURE__*/new TextLeaf([""], 0);
354function textLength(text) {
355 let length = -1;
356 for (let line of text)
357 length += line.length + 1;
358 return length;
359}
360function appendText(text, target, from = 0, to = 1e9) {
361 for (let pos = 0, i = 0, first = true; i < text.length && pos <= to; i++) {
362 let line = text[i], end = pos + line.length;
363 if (end >= from) {
364 if (end > to)
365 line = line.slice(0, to - pos);
366 if (pos < from)
367 line = line.slice(from - pos);
368 if (first) {
369 target[target.length - 1] += line;
370 first = false;
371 }
372 else
373 target.push(line);
374 }
375 pos = end + 1;
376 }
377 return target;
378}
379function sliceText(text, from, to) {
380 return appendText(text, [""], from, to);
381}
382class RawTextCursor {
383 constructor(text, dir = 1) {
384 this.dir = dir;
385 this.done = false;
386 this.lineBreak = false;
387 this.value = "";
388 this.nodes = [text];
389 this.offsets = [dir > 0 ? 1 : (text instanceof TextLeaf ? text.text.length : text.children.length) << 1];
390 }
391 nextInner(skip, dir) {
392 this.done = this.lineBreak = false;
393 for (;;) {
394 let last = this.nodes.length - 1;
395 let top = this.nodes[last], offsetValue = this.offsets[last], offset = offsetValue >> 1;
396 let size = top instanceof TextLeaf ? top.text.length : top.children.length;
397 if (offset == (dir > 0 ? size : 0)) {
398 if (last == 0) {
399 this.done = true;
400 this.value = "";
401 return this;
402 }
403 if (dir > 0)
404 this.offsets[last - 1]++;
405 this.nodes.pop();
406 this.offsets.pop();
407 }
408 else if ((offsetValue & 1) == (dir > 0 ? 0 : 1)) {
409 this.offsets[last] += dir;
410 if (skip == 0) {
411 this.lineBreak = true;
412 this.value = "\n";
413 return this;
414 }
415 skip--;
416 }
417 else if (top instanceof TextLeaf) {
418 // Move to the next string
419 let next = top.text[offset + (dir < 0 ? -1 : 0)];
420 this.offsets[last] += dir;
421 if (next.length > Math.max(0, skip)) {
422 this.value = skip == 0 ? next : dir > 0 ? next.slice(skip) : next.slice(0, next.length - skip);
423 return this;
424 }
425 skip -= next.length;
426 }
427 else {
428 let next = top.children[offset + (dir < 0 ? -1 : 0)];
429 if (skip > next.length) {
430 skip -= next.length;
431 this.offsets[last] += dir;
432 }
433 else {
434 if (dir < 0)
435 this.offsets[last]--;
436 this.nodes.push(next);
437 this.offsets.push(dir > 0 ? 1 : (next instanceof TextLeaf ? next.text.length : next.children.length) << 1);
438 }
439 }
440 }
441 }
442 next(skip = 0) {
443 if (skip < 0) {
444 this.nextInner(-skip, (-this.dir));
445 skip = this.value.length;
446 }
447 return this.nextInner(skip, this.dir);
448 }
449}
450class PartialTextCursor {
451 constructor(text, start, end) {
452 this.value = "";
453 this.done = false;
454 this.cursor = new RawTextCursor(text, start > end ? -1 : 1);
455 this.pos = start > end ? text.length : 0;
456 this.from = Math.min(start, end);
457 this.to = Math.max(start, end);
458 }
459 nextInner(skip, dir) {
460 if (dir < 0 ? this.pos <= this.from : this.pos >= this.to) {
461 this.value = "";
462 this.done = true;
463 return this;
464 }
465 skip += Math.max(0, dir < 0 ? this.pos - this.to : this.from - this.pos);
466 let limit = dir < 0 ? this.pos - this.from : this.to - this.pos;
467 if (skip > limit)
468 skip = limit;
469 limit -= skip;
470 let { value } = this.cursor.next(skip);
471 this.pos += (value.length + skip) * dir;
472 this.value = value.length <= limit ? value : dir < 0 ? value.slice(value.length - limit) : value.slice(0, limit);
473 this.done = !this.value;
474 return this;
475 }
476 next(skip = 0) {
477 if (skip < 0)
478 skip = Math.max(skip, this.from - this.pos);
479 else if (skip > 0)
480 skip = Math.min(skip, this.to - this.pos);
481 return this.nextInner(skip, this.cursor.dir);
482 }
483 get lineBreak() { return this.cursor.lineBreak && this.value != ""; }
484}
485class LineCursor {
486 constructor(inner) {
487 this.inner = inner;
488 this.afterBreak = true;
489 this.value = "";
490 this.done = false;
491 }
492 next(skip = 0) {
493 let { done, lineBreak, value } = this.inner.next(skip);
494 if (done && this.afterBreak) {
495 this.value = "";
496 this.afterBreak = false;
497 }
498 else if (done) {
499 this.done = true;
500 this.value = "";
501 }
502 else if (lineBreak) {
503 if (this.afterBreak) {
504 this.value = "";
505 }
506 else {
507 this.afterBreak = true;
508 this.next();
509 }
510 }
511 else {
512 this.value = value;
513 this.afterBreak = false;
514 }
515 return this;
516 }
517 get lineBreak() { return false; }
518}
519if (typeof Symbol != "undefined") {
520 Text.prototype[Symbol.iterator] = function () { return this.iter(); };
521 RawTextCursor.prototype[Symbol.iterator] = PartialTextCursor.prototype[Symbol.iterator] =
522 LineCursor.prototype[Symbol.iterator] = function () { return this; };
523}
524/**
525This type describes a line in the document. It is created
526on-demand when lines are [queried](https://codemirror.net/6/docs/ref/#state.Text.lineAt).
527*/
528class Line {
529 /**
530 @internal
531 */
532 constructor(
533 /**
534 The position of the start of the line.
535 */
536 from,
537 /**
538 The position at the end of the line (_before_ the line break,
539 or at the end of document for the last line).
540 */
541 to,
542 /**
543 This line's line number (1-based).
544 */
545 number,
546 /**
547 The line's content.
548 */
549 text) {
550 this.from = from;
551 this.to = to;
552 this.number = number;
553 this.text = text;
554 }
555 /**
556 The length of the line (not including any line break after it).
557 */
558 get length() { return this.to - this.from; }
559}
560function clip(text, from, to) {
561 from = Math.max(0, Math.min(text.length, from));
562 return [from, Math.max(from, Math.min(text.length, to))];
563}
564
565/**
566Returns a next grapheme cluster break _after_ (not equal to)
567`pos`, if `forward` is true, or before otherwise. Returns `pos`
568itself if no further cluster break is available in the string.
569Moves across surrogate pairs, extending characters (when
570`includeExtending` is true), characters joined with zero-width
571joiners, and flag emoji.
572*/
573function findClusterBreak(str, pos, forward = true, includeExtending = true) {
574 return findClusterBreak$1(str, pos, forward, includeExtending);
575}
576function surrogateLow(ch) { return ch >= 0xDC00 && ch < 0xE000; }
577function surrogateHigh(ch) { return ch >= 0xD800 && ch < 0xDC00; }
578/**
579Find the code point at the given position in a string (like the
580[`codePointAt`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/codePointAt)
581string method).
582*/
583function codePointAt(str, pos) {
584 let code0 = str.charCodeAt(pos);
585 if (!surrogateHigh(code0) || pos + 1 == str.length)
586 return code0;
587 let code1 = str.charCodeAt(pos + 1);
588 if (!surrogateLow(code1))
589 return code0;
590 return ((code0 - 0xd800) << 10) + (code1 - 0xdc00) + 0x10000;
591}
592/**
593Given a Unicode codepoint, return the JavaScript string that
594respresents it (like
595[`String.fromCodePoint`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/fromCodePoint)).
596*/
597function fromCodePoint(code) {
598 if (code <= 0xffff)
599 return String.fromCharCode(code);
600 code -= 0x10000;
601 return String.fromCharCode((code >> 10) + 0xd800, (code & 1023) + 0xdc00);
602}
603/**
604The amount of positions a character takes up in a JavaScript string.
605*/
606function codePointSize(code) { return code < 0x10000 ? 1 : 2; }
607
608const DefaultSplit = /\r\n?|\n/;
609/**
610Distinguishes different ways in which positions can be mapped.
611*/
612var MapMode = /*@__PURE__*/(function (MapMode) {
613 /**
614 Map a position to a valid new position, even when its context
615 was deleted.
616 */
617 MapMode[MapMode["Simple"] = 0] = "Simple";
618 /**
619 Return null if deletion happens across the position.
620 */
621 MapMode[MapMode["TrackDel"] = 1] = "TrackDel";
622 /**
623 Return null if the character _before_ the position is deleted.
624 */
625 MapMode[MapMode["TrackBefore"] = 2] = "TrackBefore";
626 /**
627 Return null if the character _after_ the position is deleted.
628 */
629 MapMode[MapMode["TrackAfter"] = 3] = "TrackAfter";
630return MapMode})(MapMode || (MapMode = {}));
631/**
632A change description is a variant of [change set](https://codemirror.net/6/docs/ref/#state.ChangeSet)
633that doesn't store the inserted text. As such, it can't be
634applied, but is cheaper to store and manipulate.
635*/
636class ChangeDesc {
637 // Sections are encoded as pairs of integers. The first is the
638 // length in the current document, and the second is -1 for
639 // unaffected sections, and the length of the replacement content
640 // otherwise. So an insertion would be (0, n>0), a deletion (n>0,
641 // 0), and a replacement two positive numbers.
642 /**
643 @internal
644 */
645 constructor(
646 /**
647 @internal
648 */
649 sections) {
650 this.sections = sections;
651 }
652 /**
653 The length of the document before the change.
654 */
655 get length() {
656 let result = 0;
657 for (let i = 0; i < this.sections.length; i += 2)
658 result += this.sections[i];
659 return result;
660 }
661 /**
662 The length of the document after the change.
663 */
664 get newLength() {
665 let result = 0;
666 for (let i = 0; i < this.sections.length; i += 2) {
667 let ins = this.sections[i + 1];
668 result += ins < 0 ? this.sections[i] : ins;
669 }
670 return result;
671 }
672 /**
673 False when there are actual changes in this set.
674 */
675 get empty() { return this.sections.length == 0 || this.sections.length == 2 && this.sections[1] < 0; }
676 /**
677 Iterate over the unchanged parts left by these changes. `posA`
678 provides the position of the range in the old document, `posB`
679 the new position in the changed document.
680 */
681 iterGaps(f) {
682 for (let i = 0, posA = 0, posB = 0; i < this.sections.length;) {
683 let len = this.sections[i++], ins = this.sections[i++];
684 if (ins < 0) {
685 f(posA, posB, len);
686 posB += len;
687 }
688 else {
689 posB += ins;
690 }
691 posA += len;
692 }
693 }
694 /**
695 Iterate over the ranges changed by these changes. (See
696 [`ChangeSet.iterChanges`](https://codemirror.net/6/docs/ref/#state.ChangeSet.iterChanges) for a
697 variant that also provides you with the inserted text.)
698 `fromA`/`toA` provides the extent of the change in the starting
699 document, `fromB`/`toB` the extent of the replacement in the
700 changed document.
701
702 When `individual` is true, adjacent changes (which are kept
703 separate for [position mapping](https://codemirror.net/6/docs/ref/#state.ChangeDesc.mapPos)) are
704 reported separately.
705 */
706 iterChangedRanges(f, individual = false) {
707 iterChanges(this, f, individual);
708 }
709 /**
710 Get a description of the inverted form of these changes.
711 */
712 get invertedDesc() {
713 let sections = [];
714 for (let i = 0; i < this.sections.length;) {
715 let len = this.sections[i++], ins = this.sections[i++];
716 if (ins < 0)
717 sections.push(len, ins);
718 else
719 sections.push(ins, len);
720 }
721 return new ChangeDesc(sections);
722 }
723 /**
724 Compute the combined effect of applying another set of changes
725 after this one. The length of the document after this set should
726 match the length before `other`.
727 */
728 composeDesc(other) { return this.empty ? other : other.empty ? this : composeSets(this, other); }
729 /**
730 Map this description, which should start with the same document
731 as `other`, over another set of changes, so that it can be
732 applied after it. When `before` is true, map as if the changes
733 in `this` happened before the ones in `other`.
734 */
735 mapDesc(other, before = false) { return other.empty ? this : mapSet(this, other, before); }
736 mapPos(pos, assoc = -1, mode = MapMode.Simple) {
737 let posA = 0, posB = 0;
738 for (let i = 0; i < this.sections.length;) {
739 let len = this.sections[i++], ins = this.sections[i++], endA = posA + len;
740 if (ins < 0) {
741 if (endA > pos)
742 return posB + (pos - posA);
743 posB += len;
744 }
745 else {
746 if (mode != MapMode.Simple && endA >= pos &&
747 (mode == MapMode.TrackDel && posA < pos && endA > pos ||
748 mode == MapMode.TrackBefore && posA < pos ||
749 mode == MapMode.TrackAfter && endA > pos))
750 return null;
751 if (endA > pos || endA == pos && assoc < 0 && !len)
752 return pos == posA || assoc < 0 ? posB : posB + ins;
753 posB += ins;
754 }
755 posA = endA;
756 }
757 if (pos > posA)
758 throw new RangeError(`Position ${pos} is out of range for changeset of length ${posA}`);
759 return posB;
760 }
761 /**
762 Check whether these changes touch a given range. When one of the
763 changes entirely covers the range, the string `"cover"` is
764 returned.
765 */
766 touchesRange(from, to = from) {
767 for (let i = 0, pos = 0; i < this.sections.length && pos <= to;) {
768 let len = this.sections[i++], ins = this.sections[i++], end = pos + len;
769 if (ins >= 0 && pos <= to && end >= from)
770 return pos < from && end > to ? "cover" : true;
771 pos = end;
772 }
773 return false;
774 }
775 /**
776 @internal
777 */
778 toString() {
779 let result = "";
780 for (let i = 0; i < this.sections.length;) {
781 let len = this.sections[i++], ins = this.sections[i++];
782 result += (result ? " " : "") + len + (ins >= 0 ? ":" + ins : "");
783 }
784 return result;
785 }
786 /**
787 Serialize this change desc to a JSON-representable value.
788 */
789 toJSON() { return this.sections; }
790 /**
791 Create a change desc from its JSON representation (as produced
792 by [`toJSON`](https://codemirror.net/6/docs/ref/#state.ChangeDesc.toJSON).
793 */
794 static fromJSON(json) {
795 if (!Array.isArray(json) || json.length % 2 || json.some(a => typeof a != "number"))
796 throw new RangeError("Invalid JSON representation of ChangeDesc");
797 return new ChangeDesc(json);
798 }
799 /**
800 @internal
801 */
802 static create(sections) { return new ChangeDesc(sections); }
803}
804/**
805A change set represents a group of modifications to a document. It
806stores the document length, and can only be applied to documents
807with exactly that length.
808*/
809class ChangeSet extends ChangeDesc {
810 constructor(sections,
811 /**
812 @internal
813 */
814 inserted) {
815 super(sections);
816 this.inserted = inserted;
817 }
818 /**
819 Apply the changes to a document, returning the modified
820 document.
821 */
822 apply(doc) {
823 if (this.length != doc.length)
824 throw new RangeError("Applying change set to a document with the wrong length");
825 iterChanges(this, (fromA, toA, fromB, _toB, text) => doc = doc.replace(fromB, fromB + (toA - fromA), text), false);
826 return doc;
827 }
828 mapDesc(other, before = false) { return mapSet(this, other, before, true); }
829 /**
830 Given the document as it existed _before_ the changes, return a
831 change set that represents the inverse of this set, which could
832 be used to go from the document created by the changes back to
833 the document as it existed before the changes.
834 */
835 invert(doc) {
836 let sections = this.sections.slice(), inserted = [];
837 for (let i = 0, pos = 0; i < sections.length; i += 2) {
838 let len = sections[i], ins = sections[i + 1];
839 if (ins >= 0) {
840 sections[i] = ins;
841 sections[i + 1] = len;
842 let index = i >> 1;
843 while (inserted.length < index)
844 inserted.push(Text.empty);
845 inserted.push(len ? doc.slice(pos, pos + len) : Text.empty);
846 }
847 pos += len;
848 }
849 return new ChangeSet(sections, inserted);
850 }
851 /**
852 Combine two subsequent change sets into a single set. `other`
853 must start in the document produced by `this`. If `this` goes
854 `docA` → `docB` and `other` represents `docB` → `docC`, the
855 returned value will represent the change `docA` → `docC`.
856 */
857 compose(other) { return this.empty ? other : other.empty ? this : composeSets(this, other, true); }
858 /**
859 Given another change set starting in the same document, maps this
860 change set over the other, producing a new change set that can be
861 applied to the document produced by applying `other`. When
862 `before` is `true`, order changes as if `this` comes before
863 `other`, otherwise (the default) treat `other` as coming first.
864
865 Given two changes `A` and `B`, `A.compose(B.map(A))` and
866 `B.compose(A.map(B, true))` will produce the same document. This
867 provides a basic form of [operational
868 transformation](https://en.wikipedia.org/wiki/Operational_transformation),
869 and can be used for collaborative editing.
870 */
871 map(other, before = false) { return other.empty ? this : mapSet(this, other, before, true); }
872 /**
873 Iterate over the changed ranges in the document, calling `f` for
874 each, with the range in the original document (`fromA`-`toA`)
875 and the range that replaces it in the new document
876 (`fromB`-`toB`).
877
878 When `individual` is true, adjacent changes are reported
879 separately.
880 */
881 iterChanges(f, individual = false) {
882 iterChanges(this, f, individual);
883 }
884 /**
885 Get a [change description](https://codemirror.net/6/docs/ref/#state.ChangeDesc) for this change
886 set.
887 */
888 get desc() { return ChangeDesc.create(this.sections); }
889 /**
890 @internal
891 */
892 filter(ranges) {
893 let resultSections = [], resultInserted = [], filteredSections = [];
894 let iter = new SectionIter(this);
895 done: for (let i = 0, pos = 0;;) {
896 let next = i == ranges.length ? 1e9 : ranges[i++];
897 while (pos < next || pos == next && iter.len == 0) {
898 if (iter.done)
899 break done;
900 let len = Math.min(iter.len, next - pos);
901 addSection(filteredSections, len, -1);
902 let ins = iter.ins == -1 ? -1 : iter.off == 0 ? iter.ins : 0;
903 addSection(resultSections, len, ins);
904 if (ins > 0)
905 addInsert(resultInserted, resultSections, iter.text);
906 iter.forward(len);
907 pos += len;
908 }
909 let end = ranges[i++];
910 while (pos < end) {
911 if (iter.done)
912 break done;
913 let len = Math.min(iter.len, end - pos);
914 addSection(resultSections, len, -1);
915 addSection(filteredSections, len, iter.ins == -1 ? -1 : iter.off == 0 ? iter.ins : 0);
916 iter.forward(len);
917 pos += len;
918 }
919 }
920 return { changes: new ChangeSet(resultSections, resultInserted),
921 filtered: ChangeDesc.create(filteredSections) };
922 }
923 /**
924 Serialize this change set to a JSON-representable value.
925 */
926 toJSON() {
927 let parts = [];
928 for (let i = 0; i < this.sections.length; i += 2) {
929 let len = this.sections[i], ins = this.sections[i + 1];
930 if (ins < 0)
931 parts.push(len);
932 else if (ins == 0)
933 parts.push([len]);
934 else
935 parts.push([len].concat(this.inserted[i >> 1].toJSON()));
936 }
937 return parts;
938 }
939 /**
940 Create a change set for the given changes, for a document of the
941 given length, using `lineSep` as line separator.
942 */
943 static of(changes, length, lineSep) {
944 let sections = [], inserted = [], pos = 0;
945 let total = null;
946 function flush(force = false) {
947 if (!force && !sections.length)
948 return;
949 if (pos < length)
950 addSection(sections, length - pos, -1);
951 let set = new ChangeSet(sections, inserted);
952 total = total ? total.compose(set.map(total)) : set;
953 sections = [];
954 inserted = [];
955 pos = 0;
956 }
957 function process(spec) {
958 if (Array.isArray(spec)) {
959 for (let sub of spec)
960 process(sub);
961 }
962 else if (spec instanceof ChangeSet) {
963 if (spec.length != length)
964 throw new RangeError(`Mismatched change set length (got ${spec.length}, expected ${length})`);
965 flush();
966 total = total ? total.compose(spec.map(total)) : spec;
967 }
968 else {
969 let { from, to = from, insert } = spec;
970 if (from > to || from < 0 || to > length)
971 throw new RangeError(`Invalid change range ${from} to ${to} (in doc of length ${length})`);
972 let insText = !insert ? Text.empty : typeof insert == "string" ? Text.of(insert.split(lineSep || DefaultSplit)) : insert;
973 let insLen = insText.length;
974 if (from == to && insLen == 0)
975 return;
976 if (from < pos)
977 flush();
978 if (from > pos)
979 addSection(sections, from - pos, -1);
980 addSection(sections, to - from, insLen);
981 addInsert(inserted, sections, insText);
982 pos = to;
983 }
984 }
985 process(changes);
986 flush(!total);
987 return total;
988 }
989 /**
990 Create an empty changeset of the given length.
991 */
992 static empty(length) {
993 return new ChangeSet(length ? [length, -1] : [], []);
994 }
995 /**
996 Create a changeset from its JSON representation (as produced by
997 [`toJSON`](https://codemirror.net/6/docs/ref/#state.ChangeSet.toJSON).
998 */
999 static fromJSON(json) {
1000 if (!Array.isArray(json))
1001 throw new RangeError("Invalid JSON representation of ChangeSet");
1002 let sections = [], inserted = [];
1003 for (let i = 0; i < json.length; i++) {
1004 let part = json[i];
1005 if (typeof part == "number") {
1006 sections.push(part, -1);
1007 }
1008 else if (!Array.isArray(part) || typeof part[0] != "number" || part.some((e, i) => i && typeof e != "string")) {
1009 throw new RangeError("Invalid JSON representation of ChangeSet");
1010 }
1011 else if (part.length == 1) {
1012 sections.push(part[0], 0);
1013 }
1014 else {
1015 while (inserted.length < i)
1016 inserted.push(Text.empty);
1017 inserted[i] = Text.of(part.slice(1));
1018 sections.push(part[0], inserted[i].length);
1019 }
1020 }
1021 return new ChangeSet(sections, inserted);
1022 }
1023 /**
1024 @internal
1025 */
1026 static createSet(sections, inserted) {
1027 return new ChangeSet(sections, inserted);
1028 }
1029}
1030function addSection(sections, len, ins, forceJoin = false) {
1031 if (len == 0 && ins <= 0)
1032 return;
1033 let last = sections.length - 2;
1034 if (last >= 0 && ins <= 0 && ins == sections[last + 1])
1035 sections[last] += len;
1036 else if (last >= 0 && len == 0 && sections[last] == 0)
1037 sections[last + 1] += ins;
1038 else if (forceJoin) {
1039 sections[last] += len;
1040 sections[last + 1] += ins;
1041 }
1042 else
1043 sections.push(len, ins);
1044}
1045function addInsert(values, sections, value) {
1046 if (value.length == 0)
1047 return;
1048 let index = (sections.length - 2) >> 1;
1049 if (index < values.length) {
1050 values[values.length - 1] = values[values.length - 1].append(value);
1051 }
1052 else {
1053 while (values.length < index)
1054 values.push(Text.empty);
1055 values.push(value);
1056 }
1057}
1058function iterChanges(desc, f, individual) {
1059 let inserted = desc.inserted;
1060 for (let posA = 0, posB = 0, i = 0; i < desc.sections.length;) {
1061 let len = desc.sections[i++], ins = desc.sections[i++];
1062 if (ins < 0) {
1063 posA += len;
1064 posB += len;
1065 }
1066 else {
1067 let endA = posA, endB = posB, text = Text.empty;
1068 for (;;) {
1069 endA += len;
1070 endB += ins;
1071 if (ins && inserted)
1072 text = text.append(inserted[(i - 2) >> 1]);
1073 if (individual || i == desc.sections.length || desc.sections[i + 1] < 0)
1074 break;
1075 len = desc.sections[i++];
1076 ins = desc.sections[i++];
1077 }
1078 f(posA, endA, posB, endB, text);
1079 posA = endA;
1080 posB = endB;
1081 }
1082 }
1083}
1084function mapSet(setA, setB, before, mkSet = false) {
1085 // Produce a copy of setA that applies to the document after setB
1086 // has been applied (assuming both start at the same document).
1087 let sections = [], insert = mkSet ? [] : null;
1088 let a = new SectionIter(setA), b = new SectionIter(setB);
1089 // Iterate over both sets in parallel. inserted tracks, for changes
1090 // in A that have to be processed piece-by-piece, whether their
1091 // content has been inserted already, and refers to the section
1092 // index.
1093 for (let inserted = -1;;) {
1094 if (a.done && b.len || b.done && a.len) {
1095 throw new Error("Mismatched change set lengths");
1096 }
1097 else if (a.ins == -1 && b.ins == -1) {
1098 // Move across ranges skipped by both sets.
1099 let len = Math.min(a.len, b.len);
1100 addSection(sections, len, -1);
1101 a.forward(len);
1102 b.forward(len);
1103 }
1104 else if (b.ins >= 0 && (a.ins < 0 || inserted == a.i || a.off == 0 && (b.len < a.len || b.len == a.len && !before))) {
1105 // If there's a change in B that comes before the next change in
1106 // A (ordered by start pos, then len, then before flag), skip
1107 // that (and process any changes in A it covers).
1108 let len = b.len;
1109 addSection(sections, b.ins, -1);
1110 while (len) {
1111 let piece = Math.min(a.len, len);
1112 if (a.ins >= 0 && inserted < a.i && a.len <= piece) {
1113 addSection(sections, 0, a.ins);
1114 if (insert)
1115 addInsert(insert, sections, a.text);
1116 inserted = a.i;
1117 }
1118 a.forward(piece);
1119 len -= piece;
1120 }
1121 b.next();
1122 }
1123 else if (a.ins >= 0) {
1124 // Process the part of a change in A up to the start of the next
1125 // non-deletion change in B (if overlapping).
1126 let len = 0, left = a.len;
1127 while (left) {
1128 if (b.ins == -1) {
1129 let piece = Math.min(left, b.len);
1130 len += piece;
1131 left -= piece;
1132 b.forward(piece);
1133 }
1134 else if (b.ins == 0 && b.len < left) {
1135 left -= b.len;
1136 b.next();
1137 }
1138 else {
1139 break;
1140 }
1141 }
1142 addSection(sections, len, inserted < a.i ? a.ins : 0);
1143 if (insert && inserted < a.i)
1144 addInsert(insert, sections, a.text);
1145 inserted = a.i;
1146 a.forward(a.len - left);
1147 }
1148 else if (a.done && b.done) {
1149 return insert ? ChangeSet.createSet(sections, insert) : ChangeDesc.create(sections);
1150 }
1151 else {
1152 throw new Error("Mismatched change set lengths");
1153 }
1154 }
1155}
1156function composeSets(setA, setB, mkSet = false) {
1157 let sections = [];
1158 let insert = mkSet ? [] : null;
1159 let a = new SectionIter(setA), b = new SectionIter(setB);
1160 for (let open = false;;) {
1161 if (a.done && b.done) {
1162 return insert ? ChangeSet.createSet(sections, insert) : ChangeDesc.create(sections);
1163 }
1164 else if (a.ins == 0) { // Deletion in A
1165 addSection(sections, a.len, 0, open);
1166 a.next();
1167 }
1168 else if (b.len == 0 && !b.done) { // Insertion in B
1169 addSection(sections, 0, b.ins, open);
1170 if (insert)
1171 addInsert(insert, sections, b.text);
1172 b.next();
1173 }
1174 else if (a.done || b.done) {
1175 throw new Error("Mismatched change set lengths");
1176 }
1177 else {
1178 let len = Math.min(a.len2, b.len), sectionLen = sections.length;
1179 if (a.ins == -1) {
1180 let insB = b.ins == -1 ? -1 : b.off ? 0 : b.ins;
1181 addSection(sections, len, insB, open);
1182 if (insert && insB)
1183 addInsert(insert, sections, b.text);
1184 }
1185 else if (b.ins == -1) {
1186 addSection(sections, a.off ? 0 : a.len, len, open);
1187 if (insert)
1188 addInsert(insert, sections, a.textBit(len));
1189 }
1190 else {
1191 addSection(sections, a.off ? 0 : a.len, b.off ? 0 : b.ins, open);
1192 if (insert && !b.off)
1193 addInsert(insert, sections, b.text);
1194 }
1195 open = (a.ins > len || b.ins >= 0 && b.len > len) && (open || sections.length > sectionLen);
1196 a.forward2(len);
1197 b.forward(len);
1198 }
1199 }
1200}hooks/vendor/find-cluster-break.js 88 lines1// These are filled with ranges (rangeFrom[i] up to but not including
2// rangeTo[i]) of code points that count as extending characters.
3let rangeFrom = [], rangeTo = []
4
5;(() => {
6 // Compressed representation of the Grapheme_Cluster_Break=Extend
7 // information from
8 // http://www.unicode.org/Public/17.0.0/ucd/auxiliary/GraphemeBreakProperty.txt.
9 // Each pair of elements represents a range, as an offet from the
10 // previous range and a length. Numbers are in base-36, with the empty
11 // string being a shorthand for 1. See bin/build-extenders.js.
12 let numbers = "lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,1n,9,16,o,,x,1i,3,,i,,7,a,2,t,3,1k,,,7,2,2,2,3,9,,a,2,q,,2,3,1k,,,5,4,2,2,3,3,,u,2,3,,b,3,1k,,,8,,3,,3,k,2,m,6,,3,1k,,,7,2,2,2,3,7,3,a,2,u,,1n,5,3,3,,4,9,,14,5,1j,,,7,,3,,4,7,2,b,2,t,3,1k,,,7,,3,,4,7,2,b,2,f,,c,4,1j,2,,7,,3,,4,9,,a,2,t,3,1y,,4,6,,,,8,i,2,1p,,,8,c,8,2q,,,a,b,7,21,2,r,,,,,,4,2,1d,k,,2,5,b,,10,9,,2u,b,,6,n,4,4,3,g,4,d,,,3,6,,f,,jj,3,qa,4,s,3,t,2,u,2,1s,w,9,,19,3,,,39,2,y,,3a,c,4,c,63,5,1l,a,,,,,2,o,2,,1c,1a,2,c,k,5,1b,h,12,9,c,3,u,d,1k,e,1c,k,48,3,,l,4,,6,,2,3,5i,1s,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,n,5,4,,2b,2,1e,i,q,i,d,,12,8,p,d,18,4,1b,e,10,,1v,e,c,,8,2,1a,,1f,,,3,2,2,5,2,,,15,5,5,2,6k,8,,2,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,1t,5,8t,2,25,6,1y,b,1d,4,3e,3,1h,f,15,,2,2,a,4,19,b,7,,1p,3,10,e,g,2,18,,c,3,1c,e,8,4,,2,2k,c,6,,2,,4d,c,l,4,1j,2,,7,2,2,2,3,9,,a,2,2,7,3,5,1v,9,,,2,,,4,,5,,,e,2,2a,i,n,,29,k,6j,7,2,9,r,2,2a,h,2y,d,2t,3,2,a,74,f,6t,6,,2,2,4,,,,2,3x,7,2,7,3,,s,a,14,7,,4,8,,9,b,1a,g,5i,8,5j,8,,8,2a,m,,e,3e,6,3,,,2,,7,,,1u,5,,2,,5,9n,4,9,2,,,1c,7,3,5,n,,44l,,6,f,8ug,i,1xc,5,1n,7,t4,,,1j,7,4,29,,b,2,f57,2,3mp,1a,2,n,f2,5,3,6,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,2s,,4g,7,af,,1p,4,e4,4,72,2,6r,,2,,7,2,5,,d6,7,31,7,240,5".split(",").map(s => s ? parseInt(s, 36) : 1)
13 for (let i = 0, n = 0; i < numbers.length; i++)
14 (i % 2 ? rangeTo : rangeFrom).push(n = n + numbers[i])
15})()
16
17export function isExtendingChar(code) {
18 if (code < 768) return false
19 for (let from = 0, to = rangeFrom.length;;) {
20 let mid = (from + to) >> 1
21 if (code < rangeFrom[mid]) to = mid
22 else if (code >= rangeTo[mid]) from = mid + 1
23 else return true
24 if (from == to) return false
25 }
26}
27
28function isRegionalIndicator(code) {
29 return code >= 0x1F1E6 && code <= 0x1F1FF
30}
31
32function check(code) {
33 for (let i = 0; i < rangeFrom.length; i++) {
34 if (rangeTo[i] > code) return rangeFrom[i] <= code
35 }
36 return false
37}
38
39const ZWJ = 0x200d
40
41export function findClusterBreak(str, pos, forward = true, includeExtending = true) {
42 return (forward ? nextClusterBreak : prevClusterBreak)(str, pos, includeExtending)
43}
44
45function nextClusterBreak(str, pos, includeExtending) {
46 if (pos == str.length) return pos
47 // If pos is in the middle of a surrogate pair, move to its start
48 if (pos && surrogateLow(str.charCodeAt(pos)) && surrogateHigh(str.charCodeAt(pos - 1))) pos--
49 let prev = codePointAt(str, pos)
50 pos += codePointSize(prev)
51 while (pos < str.length) {
52 let next = codePointAt(str, pos)
53 if (prev == ZWJ || next == ZWJ || includeExtending && isExtendingChar(next)) {
54 pos += codePointSize(next)
55 prev = next
56 } else if (isRegionalIndicator(next)) {
57 let countBefore = 0, i = pos - 2
58 while (i >= 0 && isRegionalIndicator(codePointAt(str, i))) { countBefore++; i -= 2 }
59 if (countBefore % 2 == 0) break
60 else pos += 2
61 } else {
62 break
63 }
64 }
65 return pos
66}
67
68function prevClusterBreak(str, pos, includeExtending) {
69 while (pos > 1) {
70 let found = nextClusterBreak(str, pos - 2, includeExtending)
71 if (found < pos) return found
72 pos--
73 }
74 return 0
75}
76
77function codePointAt(str, pos) {
78 let code0 = str.charCodeAt(pos)
79 if (!surrogateHigh(code0) || pos + 1 == str.length) return code0
80 let code1 = str.charCodeAt(pos + 1)
81 if (!surrogateLow(code1)) return code0
82 return ((code0 - 0xd800) << 10) + (code1 - 0xdc00) + 0x10000
83}
84
85function surrogateLow(ch) { return ch >= 0xDC00 && ch < 0xE000 }
86function surrogateHigh(ch) { return ch >= 0xD800 && ch < 0xDC00 }
87function codePointSize(code) { return code < 0x10000 ? 1 : 2 }
88types/index.d.ts 170 lines1// The Lore pane's state contract and the shapes the pane draws from.
2//
3// `.claude-plugin/plugin.json` names this file as the plugin's `types`, and
4// `claude plugin validate` holds every `$.state` key the hooks module names to
5// what is declared under `PluginState` here. The hooks module imports these
6// types with `import type`, so this file carries no runtime code.
7
8/** One concept as Browse lists it. */
9export type ConceptSummary = {
10 id: string;
11 type: string;
12 title: string;
13};
14
15/** One `lore query` hit as Search draws it. */
16export type SearchHit = {
17 id: string;
18 type: string;
19 title: string;
20 snippet: string;
21};
22
23/** One concept type of the bundle's vocabulary, with its required sections. */
24export type TypeInfo = {
25 name: string;
26 requiredSections: string[];
27};
28
29/** One tracker task linked to the open concept, from `lore tasks`. */
30export type LinkedTask = {
31 id: string;
32 title: string;
33 status: string;
34};
35
36/** The open concept, as Read draws it. */
37export type ConceptDoc = {
38 id: string;
39 /** Bundle-relative path, exactly as `lore read` reports it (`adr/x.md`). */
40 path: string;
41 /** Repository-relative path (`docs/adr/x.md`), what `lore validate` and `$.fs` address. */
42 repoPath: string;
43 type: string;
44 title: string;
45 summary: string | null;
46 status: string | null;
47 tags: string[];
48 /** The authored body (frontmatter stripped). */
49 body: string;
50 /** The file exactly as on disk, for the Raw view; null until read. */
51 raw: string | null;
52 tasks: LinkedTask[];
53 /** Authored link ids, relative to the bundle; null until fetched. */
54 links: { inbound: string[]; outbound: string[] } | null;
55};
56
57export type Tab = "browse" | "read" | "search" | "new";
58
59/** The pane's size: its normal size, or the largest the surface allows. */
60export type PaneMode = "normal" | "full";
61
62/**
63 * The pane's size, as the pane's `z` key and the dashboard tool's `full` leave it.
64 *
65 * Held in `$.state` so a change redraws the pane, and mirrored into `$.store`
66 * under `pane-mode` so the next session opens where this one left off.
67 */
68export type PaneState = {
69 mode: PaneMode;
70 /**
71 * Whether the last open the module made left the pane OPEN AND UNDRAWN (LCLI-672).
72 *
73 * The engine places an open nobody asked for by hand only from a floor of terminal
74 * columns, so a model's call can leave the pane waiting rather than drawn. This is the
75 * module's record of that answer, and it is the band's reading wherever the engine's own
76 * listing cannot be had: `$.ui.panes` is where the live answer comes from -- it is what
77 * notices the pane being placed by a widened terminal, with no open to ask -- but the
78 * engine's test kit carries no such call at all (measured: `$.ui.panes is not a
79 * function`), and a listing that fails leaves nothing to read either.
80 */
81 isWaiting: boolean;
82};
83
84/** Which structural action form the Read tab is showing, if any. */
85export type Action = "rename" | "supersede" | "link" | "unlink" | null;
86
87export type View = {
88 tab: Tab;
89 /** The repository root the pane runs lore in (git toplevel of the session cwd). */
90 root: string | null;
91 /** Search text. */
92 query: string;
93 /** Type filter; the empty string means every type. */
94 typeFilter: string;
95 /** Tag filter, comma-free; the empty string means every tag. */
96 tagFilter: string;
97 /** Read across refs (`lore query --across-refs`) instead of the working tree. */
98 acrossRefs: boolean;
99 /** The open concept's id, or null. */
100 selectedId: string | null;
101 /** Back history of previously opened ids, most recent last. */
102 history: string[];
103 isRaw: boolean;
104 action: Action;
105 actionValue: string;
106 isLoading: boolean;
107 error: string | null;
108 notice: string | null;
109};
110
111export type Catalog = {
112 concepts: ConceptSummary[];
113 types: TypeInfo[];
114 hits: SearchHit[];
115};
116
117export type DocState = {
118 concept: ConceptDoc | null;
119};
120
121/** The New tab's form, and the Fields editor's, as one draft. */
122export type Draft = {
123 type: string;
124 title: string;
125 summary: string;
126 tags: string;
127};
128
129export type Edits = {
130 isWriting: boolean;
131 /** The New tab's draft. */
132 draft: Draft;
133 /** The Fields editor's draft while it is open; null when closed. */
134 fields: (Draft & { status: string }) | null;
135 /** Bundle-relative paths with uncommitted changes, for the landing strip. */
136 uncommitted: string[];
137 /** Whether the inline body editor is open on the Read tab. */
138 bodyEditing: boolean;
139 /**
140 * The concept the open body editor belongs to, or null when it is closed.
141 *
142 * `bodyText` is a BODY and `saveBody` pairs it with the open concept's file, so the
143 * two have to be bound together: without this, opening a document, editing it and
144 * then opening another one would leave the first document's text paired with the
145 * second document's file, and Save would write it there.
146 */
147 bodyDocId: string | null;
148 /**
149 * The body as the editor last posted it. The live text is the editor instance's
150 * own state; this is the pane's copy of it, which Save writes.
151 */
152 bodyText: string;
153 /**
154 * Bumped when the pane wants the editor to adopt `bodyText` — opening the editor, or
155 * re-opening it on text that changed underneath. The editor adopts on a change and
156 * keeps the person's own keystrokes otherwise, so a bump is NOT free: adopting
157 * re-creates the instance, which parks the cursor at the end and drops the redo ring.
158 * A failed save therefore does not bump: `bodyText` still holds the person's text,
159 * the editor's own state is already that text, and re-creating it would only lose
160 * their place while they fix what validation complained about.
161 */
162 bodyRevision: number;
163};
164
165declare module "claude-code" {
166 interface PluginState {
167 "opum-lore": { view: View; catalog: Catalog; doc: DocState; edits: Edits; pane: PaneState };
168 }
169}
170