pi-weave 0.2.4 → 0.3.1

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 (62) hide show
  1. package/README.md +67 -37
  2. package/package.json +12 -3
  3. package/skills/weave-explore/SKILL.md +17 -33
  4. package/skills/weave-notepad/SKILL.md +57 -120
  5. package/skills/weave-notepad/references/link-repair.md +25 -73
  6. package/src/core/frontmatter.ts +3 -3
  7. package/src/core/graph/model.ts +2 -2
  8. package/src/core/graph/wikilinks.ts +1 -1
  9. package/src/core/index.ts +25 -1
  10. package/src/core/noteAction.ts +343 -0
  11. package/src/core/repoAction.ts +84 -0
  12. package/src/core/sessions.ts +80 -1
  13. package/src/core/summaries.ts +16 -0
  14. package/src/core/types.ts +1 -1
  15. package/src/core/workspace.ts +1 -1
  16. package/src/core/workspaceCommands.ts +227 -0
  17. package/src/opencode/commands.ts +138 -0
  18. package/src/opencode/index.ts +88 -0
  19. package/src/opencode/rpc.ts +37 -0
  20. package/src/opencode/tools.ts +43 -0
  21. package/src/opencode/tui.ts +96 -0
  22. package/src/opencode/v1.ts +161 -0
  23. package/src/pi/sessionScan.ts +4 -28
  24. package/src/pi/summarize.ts +10 -25
  25. package/src/pi/tools/noteTool.ts +7 -406
  26. package/src/pi/tools/repoTool.ts +8 -75
  27. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  28. package/src/pi/viewer/web/run.ts +16 -77
  29. package/src/web/client/dist/app.js +210 -178
  30. package/src/web/client/graph/Graph.tsx +18 -0
  31. package/src/web/client/graph/column.model.ts +19 -2
  32. package/src/web/client/graph/graph.model.ts +7 -5
  33. package/src/web/client/graph/positions.ts +1 -1
  34. package/src/web/client/graph/renderer.ts +9 -1
  35. package/src/web/client/note/Note.tsx +66 -91
  36. package/src/web/client/note/drafts.ts +172 -0
  37. package/src/web/client/note/note.model.ts +2 -34
  38. package/src/web/client/search/SearchPalette.tsx +9 -8
  39. package/src/web/client/shell/ContextRail.tsx +8 -9
  40. package/src/web/client/shell/Shell.tsx +371 -254
  41. package/src/web/client/shell/StatusBar.tsx +1 -1
  42. package/src/web/client/shell/focus.model.ts +2 -2
  43. package/src/web/client/shell/keys.model.ts +26 -31
  44. package/src/web/client/shell/keys.ts +1 -0
  45. package/src/web/client/shell/shell.model.ts +31 -107
  46. package/src/web/client/shell/theme.model.ts +4 -4
  47. package/src/web/client/shell/theme.ts +12 -93
  48. package/src/web/client/shell/workspace.css.ts +117 -0
  49. package/src/web/client/tree/Tree.tsx +43 -14
  50. package/src/web/client/tree/tree.model.ts +19 -0
  51. package/src/web/client/workspace.ts +19 -1
  52. package/src/web/server/controller.ts +79 -0
  53. package/src/web/server/routes.ts +34 -3
  54. package/src/web/server/server.ts +5 -0
  55. package/src/web/server/workspace-state.ts +121 -0
  56. package/src/web/shared/workspace.ts +270 -0
  57. package/src/web/client/selection.storage.ts +0 -69
  58. package/src/web/client/shell/Columns.tsx +0 -139
  59. package/src/web/client/shell/Divider.tsx +0 -15
  60. package/src/web/client/shell/Header.tsx +0 -119
  61. package/src/web/client/shell/drag.model.ts +0 -35
  62. package/src/web/client/shell/layout.model.ts +0 -132
@@ -0,0 +1,172 @@
1
+ import { tabSelection, type WorkspaceLayout } from "../../shared/workspace";
2
+
3
+ export interface NoteDraft {
4
+ body: string;
5
+ baseline: string;
6
+ saving: boolean;
7
+ }
8
+
9
+ interface TaskSource {
10
+ body: string;
11
+ version: number;
12
+ pending?: { body: string; version: number };
13
+ saving: boolean;
14
+ }
15
+
16
+ export interface DraftStore {
17
+ get(slug: string): Readonly<NoteDraft> | null;
18
+ subscribe(listener: () => void): () => void;
19
+ open(slug: string, body: string): void;
20
+ edit(slug: string, body: string): void;
21
+ discard(slug: string): void;
22
+ isDirty(slug: string): boolean;
23
+ dirtySlugs(): string[];
24
+ save(slug: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<void>;
25
+ nextLoadVersion(): number;
26
+ getTaskBody(slug: string, fallback: string): string;
27
+ taskBody(slug: string, serverBody: string, loadVersion: number): string;
28
+ isTaskSaving(slug: string): boolean;
29
+ saveTask(slug: string, body: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<boolean>;
30
+ clearTaskSources(slugs: readonly string[]): void;
31
+ }
32
+
33
+ export function createDraftStore(): DraftStore {
34
+ const drafts = new Map<string, NoteDraft>();
35
+ const taskSources = new Map<string, TaskSource>();
36
+ const listeners = new Set<() => void>();
37
+ let loadVersion = 0;
38
+ const notify = (): void => listeners.forEach((listener) => listener());
39
+
40
+ return {
41
+ get: (slug) => drafts.get(slug) ?? null,
42
+ subscribe(listener) {
43
+ listeners.add(listener);
44
+ return () => listeners.delete(listener);
45
+ },
46
+ open(slug, body) {
47
+ if (drafts.has(slug)) return;
48
+ drafts.set(slug, { body, baseline: body, saving: false });
49
+ notify();
50
+ },
51
+ edit(slug, body) {
52
+ const draft = drafts.get(slug);
53
+ if (draft === undefined || draft.body === body) return;
54
+ draft.body = body;
55
+ notify();
56
+ },
57
+ discard(slug) {
58
+ if (drafts.delete(slug)) notify();
59
+ },
60
+ isDirty(slug) {
61
+ const draft = drafts.get(slug);
62
+ return draft !== undefined && draft.body !== draft.baseline;
63
+ },
64
+ dirtySlugs() {
65
+ return [...drafts].filter(([, draft]) => draft.body !== draft.baseline).map(([slug]) => slug);
66
+ },
67
+ async save(slug, onSave) {
68
+ const draft = drafts.get(slug);
69
+ if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
70
+ const sent = draft.body;
71
+ draft.saving = true;
72
+ notify();
73
+ let ok = false;
74
+ try {
75
+ ok = await onSave(slug, sent);
76
+ } finally {
77
+ // Discard/reopen during an in-flight request gives the new draft a new identity.
78
+ if (drafts.get(slug) === draft) {
79
+ const source = taskSources.get(slug);
80
+ if (ok && source !== undefined) {
81
+ source.body = sent;
82
+ source.version = ++loadVersion;
83
+ delete source.pending;
84
+ }
85
+ if (ok && draft.body === sent) drafts.delete(slug);
86
+ else {
87
+ if (ok) draft.baseline = sent;
88
+ draft.saving = false;
89
+ }
90
+ notify();
91
+ }
92
+ }
93
+ },
94
+ nextLoadVersion() {
95
+ return ++loadVersion;
96
+ },
97
+ getTaskBody(slug, fallback) {
98
+ return taskSources.get(slug)?.body ?? fallback;
99
+ },
100
+ taskBody(slug, serverBody, version) {
101
+ let source = taskSources.get(slug);
102
+ let changed = false;
103
+ if (source === undefined) {
104
+ source = { body: serverBody, version, saving: false };
105
+ taskSources.set(slug, source);
106
+ } else if (version > source.version) {
107
+ if (source.saving) {
108
+ if (source.pending === undefined || version > source.pending.version) source.pending = { body: serverBody, version };
109
+ } else {
110
+ changed = source.body !== serverBody;
111
+ source.body = serverBody;
112
+ source.version = version;
113
+ }
114
+ }
115
+ if (changed) notify();
116
+ return source.body;
117
+ },
118
+ isTaskSaving: (slug) => taskSources.get(slug)?.saving ?? false,
119
+ async saveTask(slug, body, onSave) {
120
+ const source = taskSources.get(slug);
121
+ if (source === undefined || source.saving || drafts.get(slug)?.saving) return false;
122
+ source.saving = true;
123
+ notify();
124
+ let ok = false;
125
+ try {
126
+ ok = await onSave(slug, body);
127
+ return ok;
128
+ } finally {
129
+ if (taskSources.get(slug) === source) {
130
+ if (ok) {
131
+ source.body = body;
132
+ // Invalidate note loads that began before this save completed.
133
+ source.version = ++loadVersion;
134
+ } else if (source.pending !== undefined && source.pending.version > source.version) {
135
+ source.body = source.pending.body;
136
+ source.version = source.pending.version;
137
+ }
138
+ delete source.pending;
139
+ source.saving = false;
140
+ notify();
141
+ }
142
+ }
143
+ },
144
+ clearTaskSources(slugs) {
145
+ let changed = false;
146
+ for (const slug of slugs) changed = taskSources.delete(slug) || changed;
147
+ if (changed) notify();
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Removing the last selected view of a dirty note requires deliberate discard. */
153
+ export function mayRemoveDrafts(drafts: DraftStore, current: WorkspaceLayout, next: WorkspaceLayout, confirm: () => boolean): boolean {
154
+ const before = new Set(current.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
155
+ const after = new Set(next.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
156
+ const removed = [...before].filter((id): id is string => id !== null && id.startsWith("note:") && !after.has(id))
157
+ .map((id) => id.slice(5));
158
+ if (removed.some((slug) => drafts.isDirty(slug)) && !confirm()) return false;
159
+ removed.forEach((slug) => drafts.discard(slug));
160
+ drafts.clearTaskSources(removed);
161
+ return true;
162
+ }
163
+
164
+ /** Confirm only dirty drafts affected by a tree mutation, then clear affected state. */
165
+ export function mayMutateDrafts(drafts: DraftStore, affectedSlugs: readonly string[], confirm: () => boolean): boolean {
166
+ const affected = new Set(affectedSlugs);
167
+ const dirty = drafts.dirtySlugs().filter((slug) => affected.has(slug));
168
+ if (dirty.length > 0 && !confirm()) return false;
169
+ affected.forEach((slug) => drafts.discard(slug));
170
+ drafts.clearTaskSources([...affected]);
171
+ return true;
172
+ }
@@ -334,7 +334,7 @@ export function resolveMarkdownLink(index: WikiIndex, href: string): string | nu
334
334
  * Whether an unresolved wikilink should be drawn as a ghost.
335
335
  *
336
336
  * See {@link WikiIndex}: only when the payload independently confirms this
337
- * note has unresolved links. Obsidian's ghost styling is an *offer to create
337
+ * note has unresolved links. Ghost styling is an *offer to create
338
338
  * the note*, so showing it for a link that in fact resolves would be an offer
339
339
  * to create a note that already exists.
340
340
  */
@@ -847,32 +847,6 @@ export const SAVING_LABEL = "Saving…";
847
847
  export const EDITOR_ARIA_LABEL = "Note body";
848
848
  export const DISCARD_PROMPT = "Discard unsaved changes to this note?";
849
849
 
850
- /**
851
- * Whether the draft differs from the note as last loaded. Trailing whitespace
852
- * counts: the save trims, so calling such a draft clean would skip the prompt
853
- * on the one edit that gets silently reverted.
854
- */
855
- export function draftDirty(draft: string, body: string): boolean {
856
- return draft !== body;
857
- }
858
-
859
- /**
860
- * Whether a save's reply still belongs to the editor that issued it. A
861
- * counter, not the slug: reopening the same note is a new session too.
862
- */
863
- export function saveLanded(issued: number, current: number): boolean {
864
- return issued === current;
865
- }
866
-
867
- /**
868
- * Whether the draft moved since the bytes a save sent — keystrokes made
869
- * during the request were never in it, so a success must not close over them.
870
- * Unlike {@link saveLanded}, this is about the *text*, not the session.
871
- */
872
- export function draftMoved(sent: string, current: string | null): boolean {
873
- return current !== null && current !== sent;
874
- }
875
-
876
850
  /** The note header: title, provenance badge, relative time, tags. */
877
851
  export interface NoteHeaderView {
878
852
  readonly title: string;
@@ -959,13 +933,7 @@ export const PREVIEW_GAP = 8;
959
933
  /** The card's kind line for a link nothing in the vault answers to. */
960
934
  export const GHOST_KIND = "no note";
961
935
 
962
- /**
963
- * The card's DOM id, shared by the element and the `aria-describedby` it is
964
- * announced through. A constant rather than an inline literal on both sides
965
- * because the link is a *string* of rendered HTML — see
966
- * `Note.tsx`'s layout effect — and the pair cannot be typed into agreement.
967
- */
968
- export const PREVIEW_ID = "weave-preview";
936
+
969
937
 
970
938
  /** A `PreviewElement` the preview's delegated handlers may land on. */
971
939
  export interface PreviewAnchor {
@@ -9,12 +9,12 @@ import type { SearchState } from "./search.model";
9
9
 
10
10
  export interface SearchPaletteProps {
11
11
  graph: GraphPayload | null;
12
- onSelect: (id: string) => void;
12
+ onSelect: (id: string, newTab?: boolean) => void;
13
13
  onClose: () => void;
14
14
  ports: { fetch: FetchLike };
15
15
  }
16
16
 
17
- function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void; onHover: () => void }) {
17
+ function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: (newTab: boolean) => void; onHover: () => void }) {
18
18
  return (
19
19
  <li
20
20
  id={row.domId}
@@ -22,7 +22,7 @@ function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void;
22
22
  aria-selected={row.active}
23
23
  class={`weave-hit weave-hit-${row.kind}${row.active ? " weave-hit-on" : ""}`}
24
24
  onMouseMove={onHover}
25
- onClick={onPick}
25
+ onClick={(event) => onPick(event.metaKey || event.ctrlKey)}
26
26
  >
27
27
  <span class="weave-hit-badge">{row.badge}</span>
28
28
  <span class="weave-hit-label">{row.label}</span>
@@ -70,9 +70,9 @@ export function SearchPalette(props: SearchPaletteProps) {
70
70
  }, [state.query, props.ports.fetch]);
71
71
 
72
72
  const model = paletteModel(state, props.graph);
73
- const pick = (index: number | null): void => {
73
+ const pick = (index: number | null, newTab = false): void => {
74
74
  const id = resultIdAt(model.rows, index);
75
- if (id !== null) props.onSelect(id);
75
+ if (id !== null) props.onSelect(id, newTab);
76
76
  props.onClose();
77
77
  };
78
78
 
@@ -92,7 +92,7 @@ export function SearchPalette(props: SearchPaletteProps) {
92
92
  if (!next.handled) return;
93
93
  event.preventDefault();
94
94
  setState((current) => ({ ...current, cursor: next.cursor }));
95
- if (next.dismiss) pick(next.activate);
95
+ if (next.dismiss) pick(next.activate, event.metaKey || event.ctrlKey);
96
96
  }}
97
97
  >
98
98
  <input
@@ -108,14 +108,15 @@ export function SearchPalette(props: SearchPaletteProps) {
108
108
  />
109
109
  {model.status === null ? (
110
110
  <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={() => pick(index)} onHover={() => setState((current) => ({ ...current, cursor: index }))} />)}
111
+ {model.rows.map((row, index) => <Row key={row.id} row={row} onPick={(newTab) => pick(index, newTab)} onHover={() => setState((current) => ({ ...current, cursor: index }))} />)}
112
112
  </ul>
113
113
  ) : (
114
114
  <p class="weave-palette-status" role="status">{model.status}</p>
115
115
  )}
116
116
  <p class="weave-palette-foot">
117
117
  <span>{model.countLabel}</span>
118
- <span class="weave-palette-hint">{PALETTE_HINT}</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>
119
120
  </p>
120
121
  </div>
121
122
  </div>
@@ -22,13 +22,13 @@ import { Icon } from "../tree/Tree";
22
22
  export interface ContextRailProps {
23
23
  graph: GraphPayload | null;
24
24
  selectedId: string | null;
25
- onSelect: (id: string) => void;
25
+ onSelect: (id: string, newTab?: boolean) => void;
26
26
  }
27
27
 
28
- function Row({ row, onSelect }: { row: ContextRow; onSelect: () => void }) {
28
+ function Row({ row, onSelect }: { row: ContextRow; onSelect: (newTab: boolean) => void }) {
29
29
  return (
30
30
  <li class={`weave-ctx-row${row.selected ? " weave-row-on" : ""}`}>
31
- <button type="button" class="weave-ctx-link" onClick={onSelect}>
31
+ <button type="button" class="weave-ctx-link" onClick={(event) => onSelect(event.metaKey || event.ctrlKey)}>
32
32
  <span class="weave-kind" aria-hidden="true">
33
33
  <Icon name={row.kindIcon} class="weave-icon" />
34
34
  </span>
@@ -64,26 +64,26 @@ function Heading({ view, onToggle }: { view: RailSectionView; onToggle: () => vo
64
64
  );
65
65
  }
66
66
 
67
- function Group({ view, onSelect, onToggle }: { view: RailSectionView; onSelect: (id: string) => void; onToggle: () => void }) {
67
+ function Group({ view, onSelect, onToggle }: { view: RailSectionView; onSelect: (id: string, newTab?: boolean) => void; onToggle: () => void }) {
68
68
  return (
69
69
  <section class="weave-ctx-group">
70
70
  <Heading view={view} onToggle={onToggle} />
71
71
  <ul class="weave-ctx-rows" id={railPanelId(view.heading)} hidden={view.collapsed}>
72
72
  {view.rows.map((row) => (
73
- <Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
73
+ <Row key={row.id} row={row} onSelect={(newTab) => onSelect(row.target, newTab)} />
74
74
  ))}
75
75
  </ul>
76
76
  </section>
77
77
  );
78
78
  }
79
79
 
80
- function Tag({ tag, onSelect }: { tag: TagGroupRow; onSelect: (id: string) => void }) {
80
+ function Tag({ tag, onSelect }: { tag: TagGroupRow; onSelect: (id: string, newTab?: boolean) => void }) {
81
81
  return (
82
82
  <li class="weave-ctx-tag">
83
83
  <span class="weave-tag">#{tag.tag}</span>
84
84
  <ul class="weave-ctx-rows">
85
85
  {tag.siblings.map((row) => (
86
- <Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
86
+ <Row key={row.id} row={row} onSelect={(newTab) => onSelect(row.target, newTab)} />
87
87
  ))}
88
88
  </ul>
89
89
  </li>
@@ -102,7 +102,6 @@ export function ContextRail(props: ContextRailProps) {
102
102
  const tags = railTagsView(model.tags, toggles);
103
103
  return (
104
104
  <div class="weave-rail" aria-label={CONTEXT_EMPTY.title}>
105
- <h2 class="weave-col-title">{CONTEXT_EMPTY.title}</h2>
106
105
  {model.empty === null ? null : <p class="weave-ctx-empty">{model.empty}</p>}
107
106
  {model.groups.map((group) => {
108
107
  const view = railSectionView(group, toggles);
@@ -120,4 +119,4 @@ export function ContextRail(props: ContextRailProps) {
120
119
  )}
121
120
  </div>
122
121
  );
123
- }
122
+ }