SLOPSHOPPER

opum-lore

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

newpanebandguardtoolprocess
v0.13.0MITupdated 2026-10-08opum-ai/lore-cli
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opum-lore
│ ┃ Lore ✕ › fix the failing auth test and add an audit log call │ ┃ [ Browse ] [ Read ] [ Search ] [ New ] [ Ref │ ┃ Browse: could not parse lore's JSON output ⏺ Read(src/auth.ts) │ ┃ 0 concepts. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Lore
[ Browse ] [ Read ] [ Search ] [ New ] [ Refresh ] [ Refs: o Browse: could not parse lore's JSON output 0 concepts.
README

lore

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).

  • Built on Bun + TypeScript with an exact-pinned Commander parser fed by Lore's capability manifest; Lore still owns output, errors, and process lifecycle.
  • Published on npm as<!--lore-version:published-bullet:begin--> @opum-ai/lore@0.13.0 (bin lore) with six exact-pinned platform packages, including Windows ARM64.<!--lore-version:published-bullet:end-->
  • The agent bridge is a generated .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 with latest moved on each, and a clean-registry install agree on 0.13.0.<!--lore-version:status:end--> No quest pairing is claimed for this version by its number. 0.8.0 was released as a pair with quest 0.9.0 — deliberately NOT the same number; whether any lore version and any quest version form a qualified pair is measured by opum-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 by opum-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: true fails with E404 (LCLI-482). Every release after 0.6.0 has therefore been published with scripts/publish-release.sh and, unlike 0.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 Backlog.md integration: lore reads it via JSON

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:

  • Writes go through backlog task create / backlog task edit — lore captures the new id from the Created task <ID> line and never writes backlog/tasks/*.md directly.
  • Back-references live on the task as a queryable label doc:<conceptId> (Backlog drops unknown frontmatter on edit, so lore never stores its own metadata on tasks).
  • Backlog runs with 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.


Install

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.

Private-repository CI

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.


Quickstart (CLI-first)

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:

  • pretty — default; color on a TTY, honoring 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.

Refactoring and navigation

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.


How coding agents use lore

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.
  • A tiny managed block in 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.


One bundle, many consumers

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.


Roadmap

Tracked as Backlog.md milestones, built in order:

MilestoneScope
BJPUpstream stable JSON for Backlog.md reads (completed in PR #790; tagged-release adoption gates lore 0.1)
M0Foundations: repo, runtime pin, build/distribution skeleton
M1Core + scaffolding: init, new, validate, concept/frontmatter lib (gray-matter + Zod), bundle walk
M2Backlog coupling: link, sync, check, managed block (remark), status reconciliation
M3Navigability, search & refactoring: graph, orphans, query, context, replace, rename, supersede
M4Agent bridge: generated SKILL.md, CLAUDE.md nudge, lore instructions
M5Browsable + 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

Documentation

The full design lives in this repo's OKF bundle under docs/:


Contributing

This repository is public (main + dev; dev is the default branch). See CONTRIBUTING, the Code of Conduct, and SECURITY.

License

MIT © 2026 Opum AI.

Source 7 files
hooks/register.tsx 2004 lines
1// 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 lines
1// 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}
644
hooks/editor.tsx 86 lines
1// 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;
86
hooks/editor-ops.ts 263 lines
1// 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}
263
hooks/vendor/codemirror-state.js 3950 lines
1import { 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 lines
1// 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 }
88
types/index.d.ts 170 lines
1// 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