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,248 @@
1
+ /**
2
+ * Replay: the pure half of the note history slider.
3
+ *
4
+ * The server sends every saved revision of a note, oldest first (see
5
+ * `src/core/history.ts`). This module decides what one step of the slider
6
+ * says ("Agent rewrote the summary · 10:05 · +120 −40") and which text that
7
+ * step added, so the column can highlight it.
8
+ *
9
+ * ## Highlighting through Markdown
10
+ *
11
+ * The added text is found in the Markdown source, but it has to be shown in
12
+ * the rendered HTML. So the source is marked with two private-use characters,
13
+ * {@link MARK_OPEN} and {@link MARK_CLOSE}, before it is rendered, and the
14
+ * column turns the marked text nodes into `<mark>` elements afterwards
15
+ * ({@link splitMarked}). The markers are characters, not tags, so they pass
16
+ * through the renderer and the sanitiser as plain text and cannot introduce
17
+ * markup of their own.
18
+ *
19
+ * A marker must not change what the Markdown means, so {@link markChange}
20
+ * puts one pair on each changed line, after any block syntax (`## `, `- `,
21
+ * `> `), skips fence and rule lines, and widens a range to cover a whole link
22
+ * rather than splitting `[[target]]` or `(url)`.
23
+ */
24
+
25
+ import { splitRawTail } from "../../shared/view";
26
+ import type { HistoryRevision } from "../../shared/wire";
27
+
28
+ export const MARK_OPEN = "";
29
+ export const MARK_CLOSE = "";
30
+
31
+ /** Milliseconds between steps while playing. */
32
+ export const PLAY_INTERVAL_MS = 900;
33
+
34
+ /** The smallest prefix/suffix-preserving edit from `from` to `to` (same rule as core's log). */
35
+ export function changedRange(from: string, to: string): { start: number; end: number; removed: number } {
36
+ const max = Math.min(from.length, to.length);
37
+ let head = 0;
38
+ while (head < max && from.charCodeAt(head) === to.charCodeAt(head)) head++;
39
+ let tail = 0;
40
+ while (tail < max - head && from.charCodeAt(from.length - 1 - tail) === to.charCodeAt(to.length - 1 - tail)) tail++;
41
+ return { start: head, end: to.length - tail, removed: from.length - tail - head };
42
+ }
43
+
44
+ const BLOCK_PREFIX_RE = /^[ \t]*(?:#{1,6}[ \t]+|>[ \t]?|(?:[*+-]|\d{1,9}[.)])[ \t]+(?:\[[ xX]\][ \t]+)?)*/;
45
+ const SKIP_LINE_RE = /^[ \t]*(?:`{3,}|~{3,}|<!--|(?:[-*_][ \t]*){3,}$)/;
46
+ const LINK_RE = /!?\[\[[^\]\n]*\]\]|!?\[[^\]\n]*\]\([^)\n]*\)|<[a-z][a-z0-9+.-]*:[^>\s]*>/gi;
47
+
48
+ /** Mark the text `body` gained over `previous` (the whole body for the first revision). */
49
+ export function markChange(previous: string | null, body: string): string {
50
+ const { start, end } = changedRange(previous ?? "", body);
51
+ if (start >= end) return body;
52
+ const ranges: Array<[number, number]> = [];
53
+ let lineStart = body.lastIndexOf("\n", start - 1) + 1;
54
+ while (lineStart < end) {
55
+ const newline = body.indexOf("\n", lineStart);
56
+ const lineEnd = newline === -1 ? body.length : newline;
57
+ const line = body.slice(lineStart, lineEnd);
58
+ if (!SKIP_LINE_RE.test(line)) {
59
+ const prefix = (BLOCK_PREFIX_RE.exec(line) as RegExpExecArray)[0].length;
60
+ let from = Math.max(start - lineStart, prefix);
61
+ let to = Math.min(end, lineEnd) - lineStart;
62
+ for (const link of line.matchAll(LINK_RE)) {
63
+ const linkEnd = (link.index as number) + link[0].length;
64
+ if (from > (link.index as number) && from < linkEnd) from = link.index as number;
65
+ if (to > (link.index as number) && to < linkEnd) to = linkEnd;
66
+ }
67
+ if (line.slice(from, to).trim() !== "") ranges.push([lineStart + from, lineStart + to]);
68
+ }
69
+ lineStart = lineEnd + 1;
70
+ }
71
+ let marked = body;
72
+ for (const [from, to] of ranges.reverse()) {
73
+ marked = marked.slice(0, from) + MARK_OPEN + marked.slice(from, to) + MARK_CLOSE + marked.slice(to);
74
+ }
75
+ return marked;
76
+ }
77
+
78
+ /**
79
+ * Split one text node's text at the markers. `inside` carries over from the
80
+ * previous text node, because a marked range can span elements
81
+ * (`plain **bold** plain`).
82
+ */
83
+ export function splitMarked(text: string, inside: boolean): { parts: Array<{ text: string; marked: boolean }>; inside: boolean } {
84
+ const parts: Array<{ text: string; marked: boolean }> = [];
85
+ let current = "";
86
+ let state = inside;
87
+ for (const char of text) {
88
+ if (char === MARK_OPEN || char === MARK_CLOSE) {
89
+ if (current !== "") parts.push({ text: current, marked: state });
90
+ current = "";
91
+ state = char === MARK_OPEN;
92
+ } else {
93
+ current += char;
94
+ }
95
+ }
96
+ if (current !== "") parts.push({ text: current, marked: state });
97
+ return { parts, inside: state };
98
+ }
99
+
100
+ const WHO: Record<HistoryRevision["by"], string> = {
101
+ human: "You",
102
+ agent: "Agent",
103
+ outside: "Outside edit",
104
+ system: "pi-weave",
105
+ };
106
+
107
+ const WHAT: Record<string, string> = {
108
+ create: "created the note",
109
+ edit: "edited",
110
+ append: "added to the note",
111
+ raw: "added words to Raw",
112
+ finalize: "rewrote the summary",
113
+ generate: "generated the note",
114
+ repair: "fixed links",
115
+ outside: "changed outside pi-weave",
116
+ baseline: "earliest saved version",
117
+ };
118
+
119
+ /** One step of the slider, as the column shows it. */
120
+ export interface ReplayStep {
121
+ /** "3 of 12". */
122
+ position: string;
123
+ /** "Agent rewrote the summary". */
124
+ action: string;
125
+ /** Short local time; the full ISO string goes in `atIso`. */
126
+ at: string;
127
+ atIso: string;
128
+ /** "+120 −40", or "" when nothing changed in the text. */
129
+ change: string;
130
+ /** The body to render, with the added text marked. */
131
+ marked: string;
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;
140
+ }
141
+
142
+ function shortTime(iso: string, previous: string | null): string {
143
+ const date = new Date(iso);
144
+ if (Number.isNaN(date.getTime())) return iso;
145
+ const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
146
+ const sameDay = previous !== null && new Date(previous).toDateString() === date.toDateString();
147
+ return sameDay ? time : `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${time}`;
148
+ }
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
+
156
+ /** Describe revision `index`. Clamped, so a stale index after a refetch still shows something. */
157
+ export function replayStep(revisions: readonly HistoryRevision[], index: number): ReplayStep | null {
158
+ if (revisions.length === 0) return null;
159
+ const i = Math.max(0, Math.min(revisions.length - 1, index));
160
+ const revision = revisions[i] as HistoryRevision;
161
+ const previous = i === 0 ? null : (revisions[i - 1] as HistoryRevision);
162
+ const range = changedRange(previous?.body ?? "", revision.body);
163
+ const added = range.end - range.start;
164
+ const change = [added > 0 ? `+${added}` : "", range.removed > 0 ? `−${range.removed}` : ""].filter(Boolean).join(" ");
165
+ const what = WHAT[revision.op] ?? revision.op;
166
+ return {
167
+ position: `${i + 1} of ${revisions.length}`,
168
+ action: revision.op === "baseline" ? what[0]!.toUpperCase() + what.slice(1) : `${WHO[revision.by]} ${what}`,
169
+ at: shortTime(revision.at, previous?.at ?? null),
170
+ atIso: revision.at,
171
+ change,
172
+ marked: markChange(previous?.body ?? null, revision.body),
173
+ by: revision.by,
174
+ follow: !inRawTail(revision.body, range.start),
175
+ };
176
+ }
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
+
238
+ /** The next index while playing, or null at the end. */
239
+ export function nextStep(index: number, count: number): number | null {
240
+ return index + 1 < count ? index + 1 : null;
241
+ }
242
+
243
+ export const HISTORY_LABEL = "History";
244
+ export const HISTORY_CLOSE_LABEL = "Close history";
245
+ export const HISTORY_OFF_HINT = "History is off, so new changes are not being saved. Turn it on in Settings → Editor.";
246
+ /** Shown on the oldest version kept, once older ones were dropped (the file holds at most 500). */
247
+ export const HISTORY_TRIMMED = "Older versions were trimmed";
248
+ export const HISTORY_EMPTY = "No saved versions yet. Changes are saved from now on.";
@@ -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>