pi-weave 0.3.9 → 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.
@@ -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 --------------------------------------------------
@@ -877,6 +921,24 @@ export interface NoteHeaderView {
877
921
  readonly created: string;
878
922
  readonly createdIso: string;
879
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(" / ");
880
942
  }
881
943
 
882
944
  /**
@@ -902,6 +964,8 @@ export function noteHeader(note: ViewNote, now: number): NoteHeaderView {
902
964
  created: relTime(note.created, now),
903
965
  createdIso: note.created,
904
966
  tags: note.tags,
967
+ byline: noteByline(note.source),
968
+ folder: noteFolder(note.slug),
905
969
  };
906
970
  }
907
971
 
@@ -22,6 +22,7 @@
22
22
  * rather than splitting `[[target]]` or `(url)`.
23
23
  */
24
24
 
25
+ import { splitRawTail } from "../../shared/view";
25
26
  import type { HistoryRevision } from "../../shared/wire";
26
27
 
27
28
  export const MARK_OPEN = "";
@@ -129,6 +130,13 @@ export interface ReplayStep {
129
130
  /** The body to render, with the added text marked. */
130
131
  marked: string;
131
132
  by: HistoryRevision["by"];
133
+ /**
134
+ * Whether the column scrolls to the change. Not when it starts in the
135
+ * `## Raw` tail: dictation lands at the very end of the note, and following
136
+ * it dragged the reader to the bottom on every Raw step, then back up on the
137
+ * next edit. The added words are still marked where they are.
138
+ */
139
+ follow: boolean;
132
140
  }
133
141
 
134
142
  function shortTime(iso: string, previous: string | null): string {
@@ -139,6 +147,12 @@ function shortTime(iso: string, previous: string | null): string {
139
147
  return sameDay ? time : `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${time}`;
140
148
  }
141
149
 
150
+ /** Whether offset `at` of `body` falls in its `## Raw` tail (or the gap just above it). */
151
+ export function inRawTail(body: string, at: number): boolean {
152
+ const { head, tail } = splitRawTail(body);
153
+ return tail !== "" && at >= head.length;
154
+ }
155
+
142
156
  /** Describe revision `index`. Clamped, so a stale index after a refetch still shows something. */
143
157
  export function replayStep(revisions: readonly HistoryRevision[], index: number): ReplayStep | null {
144
158
  if (revisions.length === 0) return null;
@@ -157,9 +171,70 @@ export function replayStep(revisions: readonly HistoryRevision[], index: number)
157
171
  change,
158
172
  marked: markChange(previous?.body ?? null, revision.body),
159
173
  by: revision.by,
174
+ follow: !inRawTail(revision.body, range.start),
160
175
  };
161
176
  }
162
177
 
178
+ /** One bar of the history band: how much a version changed, and who changed it. */
179
+ export interface ChangeBar {
180
+ /** 0–1 of the band's height; never so small that a version disappears. */
181
+ height: number;
182
+ by: HistoryRevision["by"];
183
+ }
184
+
185
+ /** The shortest bar, so a one-character fix still reads as a version. */
186
+ export const MIN_BAR = 0.14;
187
+
188
+ /**
189
+ * Bar heights for the history band (the refresh's "change bars"). Height
190
+ * follows how many characters a version added and removed, on a log scale so
191
+ * one big rewrite does not flatten every other bar into the floor. The first
192
+ * version counts its whole body.
193
+ */
194
+ export function changeBars(revisions: readonly HistoryRevision[]): ChangeBar[] {
195
+ const sizes = revisions.map((revision, i) => {
196
+ const range = changedRange(i === 0 ? "" : (revisions[i - 1] as HistoryRevision).body, revision.body);
197
+ return range.end - range.start + range.removed;
198
+ });
199
+ const top = Math.log1p(Math.max(0, ...sizes));
200
+ return revisions.map((revision, i) => ({
201
+ height: top === 0 ? MIN_BAR : MIN_BAR + (1 - MIN_BAR) * (Math.log1p(sizes[i] as number) / top),
202
+ by: revision.by,
203
+ }));
204
+ }
205
+
206
+ /** A day label under the band, at a fraction of its width. */
207
+ export interface DayMark {
208
+ /** 0–1 along the band: version index over the last index. */
209
+ at: number;
210
+ label: string;
211
+ }
212
+
213
+ /**
214
+ * Where each day starts along the band, labelled "Today", "Yesterday" or a
215
+ * short date. The first version always gets a label, so the band says when
216
+ * the history begins.
217
+ */
218
+ export function dayMarks(revisions: readonly HistoryRevision[], now: number): DayMark[] {
219
+ const today = new Date(now);
220
+ const yesterday = new Date(now - 86_400_000);
221
+ const last = Math.max(1, revisions.length - 1);
222
+ const marks: DayMark[] = [];
223
+ let previous = "";
224
+ revisions.forEach((revision, i) => {
225
+ const date = new Date(revision.at);
226
+ if (Number.isNaN(date.getTime())) return;
227
+ const day = date.toDateString();
228
+ if (day === previous) return;
229
+ previous = day;
230
+ const label = day === today.toDateString() ? "Today"
231
+ : day === yesterday.toDateString() ? "Yesterday"
232
+ : date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
233
+ marks.push({ at: revisions.length === 1 ? 0 : i / last, label });
234
+ });
235
+ return marks;
236
+ }
237
+
163
238
  /** The next index while playing, or null at the end. */
164
239
  export function nextStep(index: number, count: number): number | null {
165
240
  return index + 1 < count ? index + 1 : null;
@@ -1,32 +1,72 @@
1
1
  import { useEffect, useRef, useState } from "preact/hooks";
2
2
  import type { GraphPayload } from "../../shared/wire";
3
3
  import { useFocusTrap } from "../shell/FocusTrap";
4
+ import { ICON_BOX, ICON_STROKE } from "../shell/icons.model";
5
+ import type { IconDef } from "../shell/icons.model";
4
6
  import { fetchSearch } from "../api";
5
7
  import type { FetchLike } from "../api";
6
- import type { SearchRowView } from "./search.model";
7
- import { DEBOUNCE_MS, PALETTE_HINT, PALETTE_PLACEHOLDER, PALETTE_TITLE, initialSearchState, paletteModel, resultIdAt, searchKey } from "./search.model";
8
- import type { SearchState } from "./search.model";
8
+ import type { PaletteIntent, SearchRowView, SearchState } from "./search.model";
9
+ import {
10
+ DEBOUNCE_MS,
11
+ LIGHT_UP_KEY,
12
+ PALETTE_HINT,
13
+ PALETTE_PLACEHOLDER,
14
+ PALETTE_TITLE,
15
+ SCOPE_GROUP_LABEL,
16
+ SEARCH_GLYPH,
17
+ closesPalette,
18
+ initialSearchState,
19
+ lightUpIntent,
20
+ lightUpLabel,
21
+ nextState,
22
+ paletteKey,
23
+ paletteModel,
24
+ rowIntent,
25
+ serverQuery,
26
+ } from "./search.model";
9
27
 
10
28
  export interface SearchPaletteProps {
11
29
  graph: GraphPayload | null;
12
30
  onSelect: (id: string, newTab?: boolean) => void;
13
31
  onClose: () => void;
32
+ /** Light the query's matches (`ids`, the results) up in the graph. The footer offers it only when this is given. */
33
+ onLightUp?: (query: string, ids: readonly string[]) => void;
14
34
  ports: { fetch: FetchLike };
15
35
  }
16
36
 
37
+ function Glyph({ def, class: className }: { def: IconDef; class: string }) {
38
+ return (
39
+ <svg
40
+ class={className}
41
+ width={ICON_BOX}
42
+ height={ICON_BOX}
43
+ viewBox={`0 0 ${ICON_BOX} ${ICON_BOX}`}
44
+ fill={def.filled ? "currentColor" : "none"}
45
+ stroke={def.filled ? undefined : "currentColor"}
46
+ stroke-width={def.filled ? undefined : def.width ?? ICON_STROKE}
47
+ stroke-linecap="round"
48
+ stroke-linejoin="round"
49
+ aria-hidden="true"
50
+ >
51
+ {def.d.map((d) => <path d={d} />)}
52
+ </svg>
53
+ );
54
+ }
55
+
17
56
  function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: (newTab: boolean) => void; onHover: () => void }) {
18
57
  return (
19
58
  <li
20
59
  id={row.domId}
21
60
  role="option"
22
61
  aria-selected={row.active}
23
- class={`weave-hit weave-hit-${row.kind}${row.active ? " weave-hit-on" : ""}`}
62
+ class={`weave-hit weave-hit-${row.group}${row.active ? " weave-hit-on" : ""}`}
24
63
  onMouseMove={onHover}
25
64
  onClick={(event) => onPick(event.metaKey || event.ctrlKey)}
26
65
  >
27
- <span class="weave-hit-badge">{row.badge}</span>
28
- <span class="weave-hit-label">{row.label}</span>
29
- <span class="weave-hit-detail">{row.detail}</span>
66
+ <Glyph def={row.icon} class="weave-icon weave-hit-icon" />
67
+ <span class={row.mono ? "weave-hit-label weave-hit-mono" : "weave-hit-label"}>{row.label}</span>
68
+ <span class="weave-hit-detail">{row.parts.map((part) => part.mark ? <mark class="weave-hit-mark">{part.text}</mark> : part.text)}</span>
69
+ {row.meta ? <span class="weave-hit-meta">{row.meta}</span> : null}
30
70
  </li>
31
71
  );
32
72
  }
@@ -41,7 +81,7 @@ export function SearchPalette(props: SearchPaletteProps) {
41
81
  useEffect(() => void input.current?.focus(), []);
42
82
 
43
83
  useEffect(() => {
44
- const query = state.query.trim();
84
+ const query = serverQuery(state.query);
45
85
  let active = true;
46
86
  if (!query) {
47
87
  abort.current?.abort();
@@ -69,15 +109,17 @@ export function SearchPalette(props: SearchPaletteProps) {
69
109
  };
70
110
  }, [state.query, props.ports.fetch]);
71
111
 
72
- const model = paletteModel(state, props.graph);
73
- const pick = (index: number | null, newTab = false): void => {
74
- const id = resultIdAt(model.rows, index);
75
- if (id !== null) props.onSelect(id, newTab);
76
- props.onClose();
112
+ const model = paletteModel(state, props.graph, Date.now());
113
+ const run = (intent: PaletteIntent): void => {
114
+ if (intent.type === "open") props.onSelect(intent.id, intent.newTab);
115
+ if (intent.type === "lightUp") props.onLightUp?.(intent.query, intent.ids);
116
+ if (closesPalette(intent)) props.onClose();
117
+ else setState((current) => nextState(current, intent));
118
+ if (intent.type === "scope" || intent.type === "tag") input.current?.focus();
77
119
  };
78
120
 
79
121
  return (
80
- <div class="weave-scrim" onClick={() => pick(null)}>
122
+ <div class="weave-scrim" onClick={() => run({ type: "close" })}>
81
123
  <div
82
124
  class="weave-palette"
83
125
  role="dialog"
@@ -87,36 +129,63 @@ export function SearchPalette(props: SearchPaletteProps) {
87
129
  ref={trap.ref as { current: HTMLDivElement | null }}
88
130
  onClick={(event) => event.stopPropagation()}
89
131
  onKeyDown={(event) => {
90
- if (trap.onKeyDown(event as unknown as KeyboardEvent)) return;
91
- const next = searchKey(state.cursor, event.key, model.count);
92
- if (!next.handled) return;
132
+ const intent = paletteKey(model, { key: event.key, shift: event.shiftKey, mod: event.metaKey || event.ctrlKey, inInput: event.target === input.current }, props.onLightUp !== undefined);
133
+ if (intent.type === "none") {
134
+ trap.onKeyDown(event as unknown as KeyboardEvent);
135
+ return;
136
+ }
93
137
  event.preventDefault();
94
- setState((current) => ({ ...current, cursor: next.cursor }));
95
- if (next.dismiss) pick(next.activate, event.metaKey || event.ctrlKey);
138
+ run(intent);
96
139
  }}
97
140
  >
98
- <input
99
- type="search"
100
- class="weave-palette-input"
101
- ref={input}
102
- value={state.query}
103
- placeholder={PALETTE_PLACEHOLDER}
104
- aria-label={PALETTE_TITLE}
105
- aria-controls="weave-search-results"
106
- aria-activedescendant={model.activeDomId ?? undefined}
107
- onInput={(event) => setState((current) => ({ ...current, query: event.currentTarget.value, cursor: 0 }))}
108
- />
141
+ <div class="weave-palette-bar">
142
+ <Glyph def={SEARCH_GLYPH} class="weave-icon weave-palette-glyph" />
143
+ <input
144
+ type="search"
145
+ class="weave-palette-input"
146
+ ref={input}
147
+ value={state.query}
148
+ placeholder={PALETTE_PLACEHOLDER}
149
+ role="combobox"
150
+ aria-label={PALETTE_TITLE}
151
+ aria-expanded={model.count > 0}
152
+ aria-autocomplete="list"
153
+ aria-controls="weave-search-results"
154
+ aria-activedescendant={model.activeDomId ?? undefined}
155
+ onInput={(event) => setState((current) => ({ ...current, query: event.currentTarget.value, cursor: 0 }))}
156
+ />
157
+ <kbd class="weave-palette-kbd" aria-hidden="true">Esc</kbd>
158
+ </div>
159
+ {model.scopes.length ? (
160
+ <div class="weave-palette-scopes" role="group" aria-label={SCOPE_GROUP_LABEL}>
161
+ {model.scopes.map((scope) => (
162
+ <button key={scope.scope} type="button" class="weave-palette-scope" aria-pressed={scope.active} onClick={() => run({ type: "scope", scope: scope.scope })}>
163
+ {scope.label} <span class="weave-palette-scope-count">{scope.count}</span>
164
+ </button>
165
+ ))}
166
+ </div>
167
+ ) : null}
109
168
  {model.status === null ? (
110
169
  <ul id="weave-search-results" class="weave-hits" role="listbox" aria-label={PALETTE_TITLE}>
111
- {model.rows.map((row, index) => <Row key={row.id} row={row} onPick={(newTab) => pick(index, newTab)} onHover={() => setState((current) => ({ ...current, cursor: index }))} />)}
170
+ {model.groups.map((group) => (
171
+ <li key={group.group} role="presentation" class="weave-hit-section">
172
+ <div id={group.domId} role="presentation" class="weave-hit-group">{group.heading}</div>
173
+ <ul role="group" aria-labelledby={group.domId} class="weave-hit-rows">
174
+ {group.rows.map((row) => <Row key={row.id} row={row} onPick={(newTab) => run(rowIntent(row, newTab))} onHover={() => run({ type: "move", cursor: row.index })} />)}
175
+ </ul>
176
+ </li>
177
+ ))}
112
178
  </ul>
113
179
  ) : (
114
180
  <p class="weave-palette-status" role="status">{model.status}</p>
115
181
  )}
116
182
  <p class="weave-palette-foot">
117
- <span>{model.countLabel}</span>
118
- <button type="button" class="weave-chip" disabled={model.count === 0} onClick={() => pick(state.cursor, true)}>Open in new tab</button>
119
- <span class="weave-palette-hint">{PALETTE_HINT} · ⌘/Ctrl ↵ new tab</span>
183
+ <span>{PALETTE_HINT}</span>
184
+ {props.onLightUp && model.lightUp.length > 0 ? (
185
+ <button type="button" class="weave-palette-light" onClick={() => run(lightUpIntent(model))}>
186
+ {lightUpLabel(model.lightUp.length)} <kbd class="weave-palette-kbd">{LIGHT_UP_KEY}</kbd>
187
+ </button>
188
+ ) : null}
120
189
  </p>
121
190
  </div>
122
191
  </div>