pi-weave 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +21 -17
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +22 -2
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/context/context.model.ts +5 -0
  31. package/src/web/client/dist/app.js +499 -129
  32. package/src/web/client/graph/Graph.tsx +166 -14
  33. package/src/web/client/graph/column.model.ts +59 -2
  34. package/src/web/client/graph/graph.model.ts +183 -7
  35. package/src/web/client/graph/renderer.ts +39 -9
  36. package/src/web/client/graph/spotlight.model.ts +472 -0
  37. package/src/web/client/note/Note.tsx +370 -66
  38. package/src/web/client/note/Replay.tsx +139 -0
  39. package/src/web/client/note/drafts.ts +115 -11
  40. package/src/web/client/note/editbar.model.ts +153 -0
  41. package/src/web/client/note/note.model.ts +82 -1
  42. package/src/web/client/note/replay.model.ts +248 -0
  43. package/src/web/client/search/SearchPalette.tsx +103 -34
  44. package/src/web/client/search/search.model.ts +533 -26
  45. package/src/web/client/shell/ContextRail.tsx +7 -0
  46. package/src/web/client/shell/Settings.tsx +21 -1
  47. package/src/web/client/shell/Shell.tsx +93 -27
  48. package/src/web/client/shell/StatusBar.tsx +1 -0
  49. package/src/web/client/shell/icons.model.ts +68 -1
  50. package/src/web/client/shell/keys.model.ts +31 -8
  51. package/src/web/client/shell/shell.model.ts +48 -1
  52. package/src/web/client/shell/theme.model.ts +3 -0
  53. package/src/web/client/shell/theme.ts +354 -32
  54. package/src/web/client/shell/workspace.css.ts +40 -9
  55. package/src/web/client/trail/TrailStrip.tsx +55 -0
  56. package/src/web/client/trail/trail.model.ts +195 -0
  57. package/src/web/client/trail/trail.ts +45 -0
  58. package/src/web/client/tree/Tree.tsx +7 -3
  59. package/src/web/client/tree/tree.model.ts +2 -5
  60. package/src/web/server/page.ts +1 -1
  61. package/src/web/server/routes.ts +83 -3
  62. package/src/web/shared/logo.ts +1 -1
  63. package/src/web/shared/themes.ts +1 -1
  64. package/src/web/shared/view.ts +10 -4
  65. package/src/web/shared/wire.ts +74 -1
  66. package/src/web/shared/workspace.ts +10 -2
  67. package/src/core/view/health.ts +0 -160
  68. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  69. package/src/pi/viewer/tui/branding.ts +0 -14
  70. package/src/pi/viewer/tui/model.ts +0 -390
  71. package/src/pi/viewer/tui/openNote.ts +0 -11
  72. package/src/pi/viewer/tui/run.ts +0 -71
  73. package/src/pi/viewer/tui/surface/base.ts +0 -203
  74. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  75. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  76. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  77. package/src/pi/viewer/tui/surface/health.ts +0 -102
  78. package/src/pi/viewer/tui/theme.ts +0 -90
  79. package/src/pi/viewer/tui/workspace.ts +0 -53
  80. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The history slider's controls, and the one DOM step the replay needs.
3
+ *
4
+ * Everything that can be decided without a DOM is in `replay.model.ts`.
5
+ */
6
+
7
+ import { useLayoutEffect, useRef } from "preact/hooks";
8
+ import type { HistoryRevision } from "../../shared/wire";
9
+ import { HISTORY_EMPTY, HISTORY_OFF_HINT, HISTORY_TRIMMED, MARK_CLOSE, MARK_OPEN, changeBars, dayMarks, replayStep, splitMarked } from "./replay.model";
10
+
11
+ /** What the column holds while replaying. `null` revisions means still loading. */
12
+ export interface ReplayState {
13
+ revisions: HistoryRevision[] | null;
14
+ enabled: boolean;
15
+ /** Older revisions were dropped to keep the history file small. */
16
+ trimmed: boolean;
17
+ index: number;
18
+ playing: boolean;
19
+ error: string | null;
20
+ }
21
+
22
+ /**
23
+ * Turn the marker characters in rendered note HTML into `<mark>` elements.
24
+ * Text nodes are created with `createTextNode`, so nothing here parses HTML.
25
+ */
26
+ export function wrapMarks(root: HTMLElement): HTMLElement | null {
27
+ const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
28
+ const nodes: Text[] = [];
29
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) nodes.push(node as Text);
30
+ let inside = false;
31
+ let first: HTMLElement | null = null;
32
+ for (const node of nodes) {
33
+ const text = node.data;
34
+ if (!inside && !text.includes(MARK_OPEN) && !text.includes(MARK_CLOSE)) continue;
35
+ const split = splitMarked(text, inside);
36
+ inside = split.inside;
37
+ const fragment = root.ownerDocument.createDocumentFragment();
38
+ for (const part of split.parts) {
39
+ const textNode = root.ownerDocument.createTextNode(part.text);
40
+ if (!part.marked) {
41
+ fragment.append(textNode);
42
+ continue;
43
+ }
44
+ const mark = root.ownerDocument.createElement("mark");
45
+ mark.className = "weave-replay-mark";
46
+ mark.append(textNode);
47
+ fragment.append(mark);
48
+ first ??= mark;
49
+ }
50
+ node.replaceWith(fragment);
51
+ }
52
+ return first;
53
+ }
54
+
55
+ /** Past this many versions the band shows no per-version bars: they would be hairlines. */
56
+ const MAX_BARS = 160;
57
+
58
+ const Icon = (props: { d: string; fill?: boolean }) => (
59
+ <svg width="14" height="14" viewBox="0 0 18 18" aria-hidden="true"
60
+ fill={props.fill ? "currentColor" : "none"} stroke={props.fill ? "none" : "currentColor"}
61
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
62
+ <path d={props.d} />
63
+ </svg>
64
+ );
65
+
66
+ export function ReplayBar(props: {
67
+ state: ReplayState;
68
+ /** Epoch ms, for the "Today" / "Yesterday" day labels. */
69
+ now: number;
70
+ onIndex: (index: number) => void;
71
+ onPlay: () => void;
72
+ }) {
73
+ const { revisions, index, playing, enabled, trimmed, error } = props.state;
74
+ const track = useRef<HTMLDivElement>(null);
75
+ const last = revisions === null ? 0 : revisions.length - 1;
76
+ // CSSOM, not a style attribute (the CSP forbids those): how far along the
77
+ // track the filled part reaches.
78
+ useLayoutEffect(() => {
79
+ track.current?.style.setProperty("--weave-replay-at", `${last === 0 ? 100 : (index / last) * 100}%`);
80
+ }, [index, last]);
81
+ if (error !== null) return <div class="weave-replay weave-replay-note" role="status">{error}</div>;
82
+ if (revisions === null) return <div class="weave-replay weave-replay-note" role="status">Loading history…</div>;
83
+ const step = replayStep(revisions, index);
84
+ if (step === null) return <div class="weave-replay weave-replay-note" role="status">{enabled ? HISTORY_EMPTY : HISTORY_OFF_HINT}</div>;
85
+ return (
86
+ <div class="weave-replay" role="group" aria-label="Note history">
87
+ <p class="weave-replay-caption" aria-live="polite">
88
+ <span class="weave-replay-clock"><Icon d="M9 3a6 6 0 1 0 0 12A6 6 0 0 0 9 3zM9 5.5V9l2.5 1.5" /></span>
89
+ <span class="weave-replay-position">Version {step.position}</span>
90
+ <span class={`weave-replay-who weave-replay-${step.by}`}>{step.action}</span>
91
+ <span class="weave-replay-time" title={step.atIso}>{step.at}</span>
92
+ {step.change === "" ? null : <span class="weave-replay-change">{step.change}</span>}
93
+ {trimmed && index === 0 ? <span class="weave-replay-time">{HISTORY_TRIMMED}</span> : null}
94
+ </p>
95
+ <div class="weave-replay-controls">
96
+ <button type="button" aria-label="Previous version" disabled={index <= 0} onClick={() => props.onIndex(index - 1)}>
97
+ <Icon d="m11 4-5 5 5 5" />
98
+ </button>
99
+ <button type="button" class="weave-replay-play" aria-label={playing ? "Pause replay" : "Play replay"} disabled={last === 0} onClick={props.onPlay}>
100
+ {playing ? <Icon d="M5 4h3v10H5zM10 4h3v10h-3z" fill /> : <Icon d="M6 4v10l8-5z" fill />}
101
+ </button>
102
+ <button type="button" aria-label="Next version" disabled={index >= last} onClick={() => props.onIndex(index + 1)}>
103
+ <Icon d="m7 4 5 5-5 5" />
104
+ </button>
105
+ <div class="weave-replay-track" ref={track}>
106
+ {revisions.length > MAX_BARS || last === 0 ? null : (
107
+ <span class="weave-replay-bars" aria-hidden="true" style={{ "--weave-bars": `${revisions.length}` }}>
108
+ {changeBars(revisions).map((bar, at) => (
109
+ // Placed where the slider's thumb sits for this version, so a bar and its stop line up.
110
+ <span key={at} style={{ "--weave-bar": `${Math.round(bar.height * 100)}%`, "--weave-bar-at": `${(at / last) * 100}%` }}
111
+ class={`weave-replay-bar weave-replay-${bar.by}${at > index ? " weave-replay-ahead" : ""}${at === index ? " weave-replay-current" : ""}`} />
112
+ ))}
113
+ </span>
114
+ )}
115
+ {last === 0 ? null : (
116
+ <span class="weave-replay-days" aria-hidden="true">
117
+ {dayMarks(revisions, props.now).map((mark) => (
118
+ <span key={mark.at} class="weave-replay-day" style={{ "--weave-day-at": `${mark.at * 100}%` }}>{mark.label}</span>
119
+ ))}
120
+ </span>
121
+ )}
122
+ <input
123
+ class="weave-replay-slider"
124
+ type="range"
125
+ aria-label="Version"
126
+ aria-valuetext={`${step.position}: ${step.action}`}
127
+ min={0}
128
+ max={last}
129
+ step={1}
130
+ value={index}
131
+ disabled={last === 0}
132
+ onInput={(event) => props.onIndex(Number(event.currentTarget.value))}
133
+ />
134
+ </div>
135
+ </div>
136
+ {enabled ? null : <p class="weave-replay-hint">{HISTORY_OFF_HINT}</p>}
137
+ </div>
138
+ );
139
+ }
@@ -1,11 +1,47 @@
1
+ import { joinRawOpening, joinRawTail, splitRawOpening, splitRawTail } from "../../shared/view";
1
2
  import { tabSelection, type WorkspaceLayout } from "../../shared/workspace";
2
3
 
4
+ /**
5
+ * An open editor. `body` is only the part above the `## Raw` tail, and the
6
+ * tail's entries get their own box (`raw`): one textarea holding both used
7
+ * to drop anything typed below the heading on save, without a word.
8
+ */
3
9
  export interface NoteDraft {
4
10
  body: string;
5
11
  baseline: string;
12
+ /** The verbatim `## Raw` tail as it was when the editor opened; "" when none. */
13
+ tail: string;
14
+ /** The tail's entries, below its heading and notice, as edited. */
15
+ raw: string;
16
+ rawBaseline: string;
6
17
  saving: boolean;
18
+ /** The body and Raw entries as they were when the editor opened: what Discard returns to. */
19
+ original: string;
20
+ originalRaw: string;
21
+ /** When an autosave last landed (epoch ms); null until one has. */
22
+ savedAt: number | null;
23
+ /** The last save attempt failed; cleared by the next one that lands. */
24
+ failed: boolean;
7
25
  }
8
26
 
27
+ /** How a save should treat the `## Raw` tail; `raw` means the person edited it. */
28
+ export interface SaveOptions {
29
+ raw?: boolean;
30
+ /** An autosave: the shell reports a failure in the edit bar, not with an alert. */
31
+ quiet?: boolean;
32
+ }
33
+
34
+ /** How a draft save behaves. */
35
+ export interface DraftSaveOptions {
36
+ /**
37
+ * Keep the editor open after the write lands (autosave). Without it a save
38
+ * that catches up with the editor closes it, which is what Done does.
39
+ */
40
+ keepOpen?: boolean;
41
+ }
42
+
43
+ export type SaveNote = (slug: string, body: string, options?: SaveOptions) => Promise<boolean>;
44
+
9
45
  interface TaskSource {
10
46
  body: string;
11
47
  version: number;
@@ -16,26 +52,44 @@ interface TaskSource {
16
52
  export interface DraftStore {
17
53
  get(slug: string): Readonly<NoteDraft> | null;
18
54
  subscribe(listener: () => void): () => void;
55
+ /** Open an editor on a full note body; the `## Raw` tail is split off. */
19
56
  open(slug: string, body: string): void;
20
57
  edit(slug: string, body: string): void;
58
+ /** Edit the `## Raw` entries. */
59
+ editRaw(slug: string, raw: string): void;
60
+ /**
61
+ * Catch an open editor's Raw box up with the note on disk. Entries an
62
+ * agent appended while the editor was open (and survived an autosave,
63
+ * which keeps the server's tail) would otherwise be missing from it, and
64
+ * the next save of an edited Raw would write them away.
65
+ */
66
+ syncTail(slug: string, serverBody: string): void;
21
67
  discard(slug: string): void;
22
68
  isDirty(slug: string): boolean;
23
69
  dirtySlugs(): string[];
24
- save(slug: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<void>;
70
+ save(slug: string, onSave: SaveNote, options?: DraftSaveOptions): Promise<void>;
71
+ /**
72
+ * Put the body and Raw entries back to how they were when the editor
73
+ * opened. Returns whether that differs from what is on disk now (an
74
+ * autosave already wrote the edits), in which case the caller saves.
75
+ */
76
+ revert(slug: string): boolean;
25
77
  nextLoadVersion(): number;
26
78
  getTaskBody(slug: string, fallback: string): string;
27
79
  taskBody(slug: string, serverBody: string, loadVersion: number): string;
28
80
  isTaskSaving(slug: string): boolean;
29
- saveTask(slug: string, body: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<boolean>;
81
+ saveTask(slug: string, body: string, onSave: SaveNote): Promise<boolean>;
30
82
  clearTaskSources(slugs: readonly string[]): void;
31
83
  }
32
84
 
33
- export function createDraftStore(): DraftStore {
85
+ /** `now` is the clock behind "Saved 12:04"; tests pass a fixed one. */
86
+ export function createDraftStore(now: () => number = Date.now): DraftStore {
34
87
  const drafts = new Map<string, NoteDraft>();
35
88
  const taskSources = new Map<string, TaskSource>();
36
89
  const listeners = new Set<() => void>();
37
90
  let loadVersion = 0;
38
91
  const notify = (): void => listeners.forEach((listener) => listener());
92
+ const dirty = (draft: NoteDraft): boolean => draft.body !== draft.baseline || draft.raw !== draft.rawBaseline;
39
93
 
40
94
  return {
41
95
  get: (slug) => drafts.get(slug) ?? null,
@@ -45,13 +99,41 @@ export function createDraftStore(): DraftStore {
45
99
  },
46
100
  open(slug, body) {
47
101
  if (drafts.has(slug)) return;
48
- drafts.set(slug, { body, baseline: body, saving: false });
102
+ const { head, tail } = splitRawTail(body);
103
+ const { entries } = splitRawOpening(tail);
104
+ drafts.set(slug, { body: head, baseline: head, tail, raw: entries, rawBaseline: entries, saving: false, original: head, originalRaw: entries, savedAt: null, failed: false });
49
105
  notify();
50
106
  },
51
107
  edit(slug, body) {
52
108
  const draft = drafts.get(slug);
53
109
  if (draft === undefined || draft.body === body) return;
54
110
  draft.body = body;
111
+ // New words get a fresh autosave attempt; a failure says so until then.
112
+ draft.failed = false;
113
+ notify();
114
+ },
115
+ editRaw(slug, raw) {
116
+ const draft = drafts.get(slug);
117
+ if (draft === undefined || draft.raw === raw) return;
118
+ draft.raw = raw;
119
+ draft.failed = false;
120
+ notify();
121
+ },
122
+ syncTail(slug, serverBody) {
123
+ const draft = drafts.get(slug);
124
+ // A write in flight answers for itself, and Raw edits not yet saved are
125
+ // the person's to keep: only an untouched box follows the disk.
126
+ if (draft === undefined || draft.saving || draft.raw !== draft.rawBaseline) return;
127
+ const { tail } = splitRawTail(serverBody);
128
+ if (tail === draft.tail) return;
129
+ const { entries } = splitRawOpening(tail);
130
+ // Discard returns to how the note was when the editor opened, plus what
131
+ // was appended since: an agent's entry is not the person's edit to undo.
132
+ if (draft.originalRaw === draft.rawBaseline) draft.originalRaw = entries;
133
+ else if (entries.startsWith(draft.rawBaseline)) draft.originalRaw += entries.slice(draft.rawBaseline.length);
134
+ draft.tail = tail;
135
+ draft.raw = entries;
136
+ draft.rawBaseline = entries;
55
137
  notify();
56
138
  },
57
139
  discard(slug) {
@@ -59,32 +141,54 @@ export function createDraftStore(): DraftStore {
59
141
  },
60
142
  isDirty(slug) {
61
143
  const draft = drafts.get(slug);
62
- return draft !== undefined && draft.body !== draft.baseline;
144
+ return draft !== undefined && dirty(draft);
63
145
  },
64
146
  dirtySlugs() {
65
- return [...drafts].filter(([, draft]) => draft.body !== draft.baseline).map(([slug]) => slug);
147
+ return [...drafts].filter(([, draft]) => dirty(draft)).map(([slug]) => slug);
148
+ },
149
+ revert(slug) {
150
+ const draft = drafts.get(slug);
151
+ if (draft === undefined) return false;
152
+ draft.body = draft.original;
153
+ draft.raw = draft.originalRaw;
154
+ notify();
155
+ return dirty(draft);
66
156
  },
67
- async save(slug, onSave) {
157
+ async save(slug, onSave, options) {
68
158
  const draft = drafts.get(slug);
69
159
  if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
70
160
  const sent = draft.body;
161
+ const sentRaw = draft.raw;
162
+ // An untouched Raw is sent as it was and kept by the server, so an
163
+ // entry dictated while this editor was open survives the save. An
164
+ // edited one is sent as the person left it, and replaces the tail.
165
+ const rawEdited = sentRaw !== draft.rawBaseline;
166
+ const tail = rawEdited ? joinRawOpening(splitRawOpening(draft.tail).opening, sentRaw) : draft.tail;
167
+ const full = joinRawTail(sent, tail);
71
168
  draft.saving = true;
72
169
  notify();
73
170
  let ok = false;
74
171
  try {
75
- ok = await onSave(slug, sent);
172
+ const saveOptions: SaveOptions = { ...(rawEdited ? { raw: true } : {}), ...(options?.keepOpen ? { quiet: true } : {}) };
173
+ ok = await onSave(slug, full, Object.keys(saveOptions).length === 0 ? undefined : saveOptions);
76
174
  } finally {
77
175
  // Discard/reopen during an in-flight request gives the new draft a new identity.
78
176
  if (drafts.get(slug) === draft) {
79
177
  const source = taskSources.get(slug);
80
178
  if (ok && source !== undefined) {
81
- source.body = sent;
179
+ source.body = full;
82
180
  source.version = ++loadVersion;
83
181
  delete source.pending;
84
182
  }
85
- if (ok && draft.body === sent) drafts.delete(slug);
183
+ if (ok && !options?.keepOpen && draft.body === sent && draft.raw === sentRaw) drafts.delete(slug);
86
184
  else {
87
- if (ok) draft.baseline = sent;
185
+ if (ok) {
186
+ draft.baseline = sent;
187
+ draft.rawBaseline = sentRaw;
188
+ draft.tail = tail;
189
+ draft.savedAt = now();
190
+ }
191
+ draft.failed = !ok;
88
192
  draft.saving = false;
89
193
  }
90
194
  notify();
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The edit bar: the pure half (the refresh's E1).
3
+ *
4
+ * While a note is being edited, one bar under the title says so, carries the
5
+ * formatting buttons, shows whether the edits are on disk yet, and offers
6
+ * Discard and Done. Saving is automatic: a short pause in typing writes the
7
+ * draft (see `drafts.ts`, `keepOpen`), so Done only has to catch up and close.
8
+ *
9
+ * Everything that can be decided without a DOM is here: what the status says,
10
+ * and how a formatting button rewrites the text around the selection.
11
+ */
12
+
13
+ import type { IconName } from "../shell/icons.model";
14
+
15
+ /** How long typing has to pause before the draft is written. */
16
+ export const AUTOSAVE_MS = 2500;
17
+
18
+ export const EDITING_LABEL = "Editing";
19
+ export const DISCARD_EDITS_LABEL = "Discard changes";
20
+ export const RETRY_LABEL = "Retry";
21
+ /** Asked before Discard writes the opening version back over autosaved edits. */
22
+ export const REVERT_PROMPT = "Put this note back the way it was when you started editing?";
23
+
24
+ /** What the status slot shows. */
25
+ export type EditStatusKind = "saving" | "failed" | "pending" | "saved" | "clean";
26
+
27
+ export interface EditStatus {
28
+ kind: EditStatusKind;
29
+ label: string;
30
+ }
31
+
32
+ /** The parts of a draft the status depends on. */
33
+ export interface EditStatusInput {
34
+ saving: boolean;
35
+ failed: boolean;
36
+ /** Edits not yet on disk. */
37
+ dirty: boolean;
38
+ savedAt: number | null;
39
+ }
40
+
41
+ function clock(at: number): string {
42
+ return new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
43
+ }
44
+
45
+ /** The status slot, in priority order: a write in flight, a failed one, waiting edits, the last save. */
46
+ export function editStatus(input: EditStatusInput): EditStatus {
47
+ if (input.saving) return { kind: "saving", label: "Saving…" };
48
+ if (input.failed) return { kind: "failed", label: "Not saved" };
49
+ if (input.dirty) return { kind: "pending", label: "Unsaved changes" };
50
+ if (input.savedAt !== null) return { kind: "saved", label: `Saved ${clock(input.savedAt)}` };
51
+ return { kind: "clean", label: "No changes yet" };
52
+ }
53
+
54
+ /** The formatting buttons, in bar order. */
55
+ export type FormatKind = "heading" | "bold" | "italic" | "link" | "wikilink" | "task";
56
+
57
+ export interface FormatTool {
58
+ kind: FormatKind;
59
+ icon: IconName;
60
+ label: string;
61
+ /** The key that applies it with ⌘/Ctrl, if any. */
62
+ key: string | null;
63
+ }
64
+
65
+ export const FORMAT_TOOLS: readonly FormatTool[] = [
66
+ { kind: "heading", icon: "heading", label: "Heading", key: null },
67
+ { kind: "bold", icon: "bold", label: "Bold", key: "b" },
68
+ { kind: "italic", icon: "italic", label: "Italic", key: "i" },
69
+ // No ⌘K: that is search, everywhere in the workspace, the editor included.
70
+ { kind: "link", icon: "link", label: "Link", key: null },
71
+ { kind: "wikilink", icon: "wikilink", label: "Link to a note", key: null },
72
+ { kind: "task", icon: "task", label: "Task", key: null },
73
+ ];
74
+
75
+ /** The tool bound to a ⌘/Ctrl key, if any. */
76
+ export function formatForKey(key: string): FormatKind | null {
77
+ return FORMAT_TOOLS.find((tool) => tool.key === key.toLowerCase())?.kind ?? null;
78
+ }
79
+
80
+ /** A textarea's value and selection. */
81
+ export interface TextEdit {
82
+ value: string;
83
+ start: number;
84
+ end: number;
85
+ }
86
+
87
+ /** Wrap the selection, or insert a placeholder and select it so typing replaces it. */
88
+ function wrap(edit: TextEdit, before: string, after: string, placeholder: string): TextEdit {
89
+ const selected = edit.value.slice(edit.start, edit.end);
90
+ const inner = selected === "" ? placeholder : selected;
91
+ const value = edit.value.slice(0, edit.start) + before + inner + after + edit.value.slice(edit.end);
92
+ return { value, start: edit.start + before.length, end: edit.start + before.length + inner.length };
93
+ }
94
+
95
+ /**
96
+ * Rewrite every line the selection touches. A selection that ends just after
97
+ * a line break (a triple-clicked line) does not touch the line below it.
98
+ */
99
+ function eachLine(edit: TextEdit, rewrite: (line: string) => string): TextEdit {
100
+ const from = edit.value.lastIndexOf("\n", edit.start - 1) + 1;
101
+ const last = edit.end > edit.start && edit.value[edit.end - 1] === "\n" ? edit.end - 1 : edit.end;
102
+ const newline = edit.value.indexOf("\n", last);
103
+ const to = newline === -1 ? edit.value.length : newline;
104
+ const lines = edit.value.slice(from, to).split("\n").map(rewrite).join("\n");
105
+ const value = edit.value.slice(0, from) + lines + edit.value.slice(to);
106
+ return { value, start: from, end: from + lines.length };
107
+ }
108
+
109
+ const HEADING_RE = /^#{1,6}[ \t]+/;
110
+ const TASK_RE = /^([ \t]*)[-*+][ \t]+\[[ xX]\][ \t]+/;
111
+ const BULLET_RE = /^([ \t]*)[-*+][ \t]+/;
112
+
113
+ /** Apply one formatting button to the text around the selection. */
114
+ export function applyFormat(edit: TextEdit, kind: FormatKind): TextEdit {
115
+ switch (kind) {
116
+ case "bold":
117
+ return wrap(edit, "**", "**", "bold text");
118
+ case "italic":
119
+ return wrap(edit, "_", "_", "italic text");
120
+ case "wikilink":
121
+ return wrap(edit, "[[", "]]", "note title");
122
+ case "link": {
123
+ const selected = edit.value.slice(edit.start, edit.end);
124
+ if (selected === "") return wrap(edit, "[", "](https://)", "link text");
125
+ // With text selected, the URL is what is left to type, so select that.
126
+ const value = `${edit.value.slice(0, edit.start)}[${selected}](https://)${edit.value.slice(edit.end)}`;
127
+ const url = edit.start + selected.length + 3;
128
+ return { value, start: url, end: url + "https://".length };
129
+ }
130
+ case "heading":
131
+ return eachLine(edit, (line) => (HEADING_RE.test(line) ? line.replace(HEADING_RE, "") : `## ${line}`));
132
+ case "task":
133
+ return eachLine(edit, (line) => {
134
+ if (TASK_RE.test(line)) return line.replace(TASK_RE, "$1");
135
+ if (BULLET_RE.test(line)) return line.replace(BULLET_RE, "$1- [ ] ");
136
+ return `- [ ] ${line}`;
137
+ });
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The one span a formatting change replaced: `before[start, end)` became
143
+ * `text`. The editor inserts just that span through the browser, so ⌘Z
144
+ * undoes the formatting; assigning the whole value would wipe the undo stack.
145
+ */
146
+ export function replacedSpan(before: string, after: string): { start: number; end: number; text: string } {
147
+ const max = Math.min(before.length, after.length);
148
+ let head = 0;
149
+ while (head < max && before.charCodeAt(head) === after.charCodeAt(head)) head++;
150
+ let tail = 0;
151
+ while (tail < max - head && before.charCodeAt(before.length - 1 - tail) === after.charCodeAt(after.length - 1 - tail)) tail++;
152
+ return { start: head, end: before.length - tail, text: after.slice(head, after.length - tail) };
153
+ }
@@ -678,7 +678,51 @@ export const SANITIZE_CONFIG: SanitizeConfig = frozen({
678
678
  * what gets handed over, with {@link SANITIZE_CONFIG}.
679
679
  */
680
680
  export function renderNote(purify: Purifier, body: string, index: WikiIndex): string {
681
- return purify.sanitize(renderMarkdown(body, index), SANITIZE_CONFIG);
681
+ return tldrCard(purify.sanitize(renderMarkdown(body, index), SANITIZE_CONFIG));
682
+ }
683
+
684
+ // `TLDR`, `TL;DR`, either with the colon inside or after the bold, as search and the graph sheet read it.
685
+ const TLDR_OPEN_RE = /^(\s*<h1>[\s\S]*?<\/h1>)?\s*<p><strong>TL;?DR[ \t]*:?<\/strong>[ \t]*:?[ \t\n]*/i;
686
+ const LIST_TAG_RE = /<(\/?)(?:ul|ol)\b[^>]*>/g;
687
+
688
+ /**
689
+ * Lift a note's opening TLDR into a card.
690
+ *
691
+ * Finalized notes open with a `**TLDR:**` paragraph, often followed by a list
692
+ * of `- [ ]` tasks (the notepad skill's format). The reading view sets that
693
+ * block apart so the summary is the first thing the eye lands on. Only the
694
+ * opening TLDR counts (optionally under a repeated `# Title`); a TLDR further
695
+ * down is ordinary prose.
696
+ *
697
+ * It runs on the *sanitised* HTML and only moves markup that is already
698
+ * there, adding one `div` and one label `span` of its own, so it opens no
699
+ * hole in the three layers above. The task checkboxes keep their order, and
700
+ * with it their `data-weave-task` indices.
701
+ */
702
+ export function tldrCard(html: string): string {
703
+ const open = TLDR_OPEN_RE.exec(html);
704
+ if (open === null) return html;
705
+ const close = html.indexOf("</p>", open[0].length);
706
+ if (close === -1) return html;
707
+ const lead = open[1] ?? "";
708
+ const summary = html.slice(open[0].length, close);
709
+ let end = close + "</p>".length;
710
+ const list = /^\s*<(?:ul|ol)\b/.exec(html.slice(end));
711
+ if (list !== null) {
712
+ // Balanced, so a nested list stays inside the card with its parent.
713
+ LIST_TAG_RE.lastIndex = end;
714
+ let depth = 0;
715
+ for (let tag = LIST_TAG_RE.exec(html); tag !== null; tag = LIST_TAG_RE.exec(html)) {
716
+ depth += tag[1] === "/" ? -1 : 1;
717
+ if (depth === 0) {
718
+ end = tag.index + tag[0].length;
719
+ break;
720
+ }
721
+ }
722
+ if (depth !== 0) return html;
723
+ }
724
+ const items = html.slice(close + "</p>".length, end);
725
+ return `${lead}\n<div class="weave-tldr"><p><span class="weave-tldr-label">TLDR</span>${summary}</p>${items}\n</div>${html.slice(end)}`;
682
726
  }
683
727
 
684
728
  // --- reading a click back out of the DOM --------------------------------------------------
@@ -845,6 +889,23 @@ export const DONE_LABEL = "Done";
845
889
  export const SAVE_LABEL = "Save";
846
890
  export const SAVING_LABEL = "Saving…";
847
891
  export const EDITOR_ARIA_LABEL = "Note body";
892
+ export const EDITOR_PLACEHOLDER = "Start writing…";
893
+ /** Names the `## Raw` section beneath the editor, and its own text box. */
894
+ export const RAW_TAIL_ARIA_LABEL = "Raw notes";
895
+ export const RAW_EDITOR_ARIA_LABEL = "Raw notes text";
896
+ export const RAW_TITLE = "Raw";
897
+
898
+ /**
899
+ * The quiet line beside the Raw toggle. Closed, it says how much is folded
900
+ * away; open, it says what saving does with the words.
901
+ */
902
+ export function rawSummary(entries: string, open: boolean): string {
903
+ if (open) return "Your words as captured. Edits here are saved with the note.";
904
+ const count = entries.match(/^<!-- appended\b/gm)?.length ?? 0;
905
+ if (count > 0) return `${count} ${count === 1 ? "entry" : "entries"} captured`;
906
+ const lines = entries === "" ? 0 : entries.split("\n").length;
907
+ return `${lines} ${lines === 1 ? "line" : "lines"}`;
908
+ }
848
909
  export const DISCARD_PROMPT = "Discard unsaved changes to this note?";
849
910
 
850
911
  /** The note header: title, provenance badge, relative time, tags. */
@@ -860,6 +921,24 @@ export interface NoteHeaderView {
860
921
  readonly created: string;
861
922
  readonly createdIso: string;
862
923
  readonly tags: readonly string[];
924
+ /** Who wrote it, as a sentence: "You wrote this". */
925
+ readonly byline: string;
926
+ /** Where it lives: "Vault / projects", or "Vault" at the root. */
927
+ readonly folder: string;
928
+ }
929
+
930
+ /** The byline under the title, in plain words rather than a provenance code. */
931
+ export function noteByline(source: WireNoteSource): string {
932
+ if (source === "human") return "You wrote this";
933
+ if (source === "agent") return "Agent wrote this";
934
+ return "Generated by pi-weave";
935
+ }
936
+
937
+ export const VAULT_LABEL = "Vault";
938
+
939
+ /** The folder crumb above the title: the slug's directories under the vault. */
940
+ export function noteFolder(slug: string): string {
941
+ return [VAULT_LABEL, ...slug.split("/").slice(0, -1)].join(" / ");
863
942
  }
864
943
 
865
944
  /**
@@ -885,6 +964,8 @@ export function noteHeader(note: ViewNote, now: number): NoteHeaderView {
885
964
  created: relTime(note.created, now),
886
965
  createdIso: note.created,
887
966
  tags: note.tags,
967
+ byline: noteByline(note.source),
968
+ folder: noteFolder(note.slug),
888
969
  };
889
970
  }
890
971