@hachej/boring-ui-kit 0.1.111 → 0.2.0

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 (95) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +255 -239
  3. package/THIRD_PARTY_NOTICES.md +85 -0
  4. package/dist/anchor-conformance.d.ts +78 -0
  5. package/dist/anchor-conformance.js +288 -0
  6. package/dist/canvas-document.d.ts +25 -0
  7. package/dist/canvas-document.js +141 -0
  8. package/dist/canvas-editor-native.d.ts +22 -0
  9. package/dist/canvas-editor-native.js +112 -0
  10. package/dist/canvas-editor.d.ts +6 -0
  11. package/dist/canvas-editor.js +13 -0
  12. package/dist/canvas-mounted.d.ts +41 -0
  13. package/dist/canvas-mounted.js +180 -0
  14. package/dist/canvas-proposals.d.ts +11 -0
  15. package/dist/canvas-proposals.js +45 -0
  16. package/dist/canvas-records.d.ts +3 -0
  17. package/dist/canvas-records.js +39 -0
  18. package/dist/canvas.d.ts +92 -0
  19. package/dist/canvas.js +264 -0
  20. package/dist/contracts.d.ts +125 -0
  21. package/dist/contracts.js +1 -0
  22. package/dist/experience-catalog.d.ts +142 -0
  23. package/dist/experience-catalog.js +25 -0
  24. package/dist/experience-compose.d.ts +83 -0
  25. package/dist/experience-compose.js +218 -0
  26. package/dist/experience-composition-schema.d.ts +29 -0
  27. package/dist/experience-composition-schema.js +22 -0
  28. package/dist/experience-document-viewer.d.ts +13 -0
  29. package/dist/experience-document-viewer.js +54 -0
  30. package/dist/experience-document.d.ts +64 -0
  31. package/dist/experience-document.js +234 -0
  32. package/dist/experience-region-text.d.ts +3 -0
  33. package/dist/experience-region-text.js +84 -0
  34. package/dist/experience-region-tree.d.ts +22 -0
  35. package/dist/experience-region-tree.js +50 -0
  36. package/dist/experience-regions.d.ts +9 -0
  37. package/dist/experience-regions.js +122 -0
  38. package/dist/experience-renderer.d.ts +16 -0
  39. package/dist/experience-renderer.js +37 -0
  40. package/dist/experience.d.ts +12 -0
  41. package/dist/experience.js +23 -0
  42. package/dist/file-tree-shadcn.d.ts +8 -0
  43. package/dist/file-tree-shadcn.js +14 -0
  44. package/dist/file-tree-view.d.ts +64 -0
  45. package/dist/file-tree-view.js +256 -0
  46. package/dist/file-tree.d.ts +57 -0
  47. package/dist/file-tree.js +204 -0
  48. package/dist/html-preview.d.ts +2 -0
  49. package/dist/html-preview.js +47 -0
  50. package/dist/html-viewer.d.ts +13 -0
  51. package/dist/html-viewer.js +69 -0
  52. package/dist/html.d.ts +21 -0
  53. package/dist/html.js +21 -0
  54. package/dist/index.d.ts +2 -472
  55. package/dist/index.js +1 -2671
  56. package/dist/markdown-editor.d.ts +28 -0
  57. package/dist/markdown-editor.js +230 -0
  58. package/dist/markdown-extensions.d.ts +16 -0
  59. package/dist/markdown-extensions.js +96 -0
  60. package/dist/markdown-icons.d.ts +244 -0
  61. package/dist/markdown-icons.js +32 -0
  62. package/dist/markdown-mounted.d.ts +63 -0
  63. package/dist/markdown-mounted.js +263 -0
  64. package/dist/markdown-parser.d.ts +4 -0
  65. package/dist/markdown-parser.js +33 -0
  66. package/dist/markdown-proposal-diff.d.ts +5 -0
  67. package/dist/markdown-proposal-diff.js +122 -0
  68. package/dist/markdown-safety.d.ts +21 -0
  69. package/dist/markdown-safety.js +135 -0
  70. package/dist/markdown.d.ts +86 -0
  71. package/dist/markdown.js +121 -0
  72. package/dist/native-chat.d.ts +150 -0
  73. package/dist/native-chat.js +376 -0
  74. package/dist/pi.d.ts +12 -0
  75. package/dist/pi.js +1 -0
  76. package/dist/public-text-buffer.d.ts +5 -0
  77. package/dist/public-text-buffer.js +1 -0
  78. package/dist/remote-chat.d.ts +76 -0
  79. package/dist/remote-chat.js +324 -0
  80. package/dist/resources.d.ts +34 -0
  81. package/dist/resources.js +1 -0
  82. package/dist/text-buffer-state.d.ts +24 -0
  83. package/dist/text-buffer-state.js +1 -0
  84. package/dist/text-buffer.d.ts +52 -0
  85. package/dist/text-buffer.js +315 -0
  86. package/dist/text-draft-controls.d.ts +9 -0
  87. package/dist/text-draft-controls.js +33 -0
  88. package/dist/text-draft-types.d.ts +111 -0
  89. package/dist/text-draft-types.js +1 -0
  90. package/dist/text-drafts.d.ts +29 -0
  91. package/dist/text-drafts.js +378 -0
  92. package/package.json +226 -58
  93. package/styles/file-tree.css +46 -0
  94. package/dist/styles.css +0 -2308
  95. package/dist/tokens.css +0 -155
@@ -0,0 +1,135 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import { marked } from 'marked';
3
+ import { createMarkdownExtensions } from './markdown-extensions.js';
4
+ const FENCE = /^ {0,3}(```|~~~)/;
5
+ /** Blank-line runs outside fenced code collapse to one blank line, and the ends are trimmed: the only whitespace the rich editor may normalise. */
6
+ export function normalizeMarkdownWhitespace(text) {
7
+ const lines = text.split('\n');
8
+ const out = [];
9
+ let fence;
10
+ for (const line of lines) {
11
+ const opened = FENCE.exec(line);
12
+ if (opened && (!fence || opened[1] === fence))
13
+ fence = fence ? undefined : opened[1];
14
+ if (!fence && !opened && line.trim() === '' && out.length > 0 && out[out.length - 1].trim() === '')
15
+ continue;
16
+ out.push(line);
17
+ }
18
+ return out.join('\n').replace(/^\s+/, '').trimEnd();
19
+ }
20
+ const HEADING = /^ {0,3}#{1,6}(\s|$)/;
21
+ const LIST_ITEM = /^\s*([-*+]|\d{1,9}[.)])(\s|$)/;
22
+ /** A line that starts its own block instead of continuing the paragraph above it. */
23
+ const BLOCK_START = /^\s*([-*+]\s|\d{1,9}[.)]\s|#{1,6}(\s|$)|>|\||```|~~~|(\*\s*){3,}$|(-\s*){3,}$|(_\s*){3,}$)/;
24
+ /**
25
+ * The comparison form for the round trip: whitespace normalised, and blank lines beside an ATX heading dropped (the serialiser always
26
+ * puts one there, and "# Title" directly above a paragraph means the same document). Blank lines inside fenced code are kept. The
27
+ * indentation of a wrapped line that continues a list item's paragraph is dropped too: it is a lazy continuation line, the same
28
+ * paragraph however far it is indented, and the serialiser writes it unindented. A line that starts a block keeps its indentation, so
29
+ * a change of nesting is still a rewrite. A list may interrupt a paragraph, so the blank line the serialiser puts between a top-level
30
+ * paragraph and the list right under it is dropped; between list items it stays, since it makes the list loose.
31
+ */
32
+ function comparableMarkdown(text) {
33
+ const lines = normalizeMarkdownWhitespace(text).split('\n');
34
+ const out = [];
35
+ let fence;
36
+ let itemParagraph = false, topParagraph = false;
37
+ lines.forEach((line, index) => {
38
+ const opened = FENCE.exec(line);
39
+ if (opened && (!fence || opened[1] === fence))
40
+ fence = fence ? undefined : opened[1];
41
+ const blank = line.trim() === '' && !fence && !opened;
42
+ const afterParagraph = topParagraph;
43
+ topParagraph = false;
44
+ if (fence || opened || blank)
45
+ itemParagraph = false;
46
+ else if (LIST_ITEM.test(line))
47
+ itemParagraph = true;
48
+ else if (itemParagraph && !BLOCK_START.test(line))
49
+ line = line.trimStart();
50
+ else {
51
+ itemParagraph = false;
52
+ topParagraph = !/^\s/.test(line) && !BLOCK_START.test(line);
53
+ }
54
+ if (blank && (HEADING.test(lines[index - 1] ?? '') || HEADING.test(lines[index + 1] ?? '')))
55
+ return;
56
+ if (blank && afterParagraph && LIST_ITEM.test(lines[index + 1] ?? '') && !/^\s/.test(lines[index + 1] ?? ''))
57
+ return;
58
+ out.push(line);
59
+ });
60
+ return out.join('\n');
61
+ }
62
+ /** What follows the last visible character, so a rich edit keeps the document's own final newline. */
63
+ export function trailingWhitespace(text) { return /\s*$/.exec(text)?.[0] ?? ''; }
64
+ /** The text a rich edit publishes: the serialised document, whitespace normalised, with the original's trailing newline. */
65
+ export function finishRichMarkdown(serialized, previous) {
66
+ const body = normalizeMarkdownWhitespace(serialized);
67
+ return body === '' ? '' : body + (trailingWhitespace(previous) || '');
68
+ }
69
+ const stripCode = (text) => text.replace(/^ {0,3}(```|~~~)[\s\S]*?(?:^ {0,3}\1.*$|(?![\s\S]))/gm, '').replace(/`[^`\n]*`/g, '');
70
+ function hasToken(tokens, type, accept = () => true) {
71
+ return tokens.some(token => token.type === type && accept(token)
72
+ || ('tokens' in token && Array.isArray(token.tokens) && hasToken(token.tokens, type, accept))
73
+ || ('items' in token && Array.isArray(token.items) && hasToken(token.items, type, accept)));
74
+ }
75
+ /** Constructs that name themselves, for a short explanation. The round trip below is the authority. */
76
+ function namedReasons(text) {
77
+ const reasons = [];
78
+ if (/^?---[ \t]*\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.test(text))
79
+ reasons.push('front matter');
80
+ let tokens = [];
81
+ try {
82
+ tokens = marked.lexer(text);
83
+ }
84
+ catch { /* the round trip still decides */ }
85
+ if (hasToken(tokens, 'html'))
86
+ reasons.push('raw HTML');
87
+ const prose = stripCode(text);
88
+ if (/\[\^[^\]\s]+\]/.test(prose))
89
+ reasons.push('footnotes');
90
+ if (hasToken(tokens, 'def', token => !('tag' in token) || !String(token.tag).startsWith('^')))
91
+ reasons.push('reference-style links');
92
+ if (text.includes('\r'))
93
+ reasons.push('Windows line endings');
94
+ return reasons;
95
+ }
96
+ const cache = new Map();
97
+ /**
98
+ * Decides whether the rich editor can open and edit `text` without changing anything but what the person edits: the text is
99
+ * parsed into the editor's document and serialised back without any edit, and must equal the original apart from blank-line runs and
100
+ * the ends. Named constructs the editor cannot hold (front matter, raw HTML, footnotes, reference links, CRLF) are reported as the
101
+ * reason. Without a DOM only the named constructs can be checked.
102
+ */
103
+ export function checkMarkdownRichSafety(text) {
104
+ if (text === '')
105
+ return { safe: true };
106
+ const known = cache.get(text);
107
+ if (known)
108
+ return known;
109
+ const reasons = namedReasons(text);
110
+ if (reasons.length === 0 && typeof document !== 'undefined') {
111
+ let editor;
112
+ try {
113
+ editor = new Editor({ element: document.createElement('div'), extensions: createMarkdownExtensions(), content: '' });
114
+ editor.commands.setContent(text, { contentType: 'markdown', emitUpdate: false });
115
+ if (comparableMarkdown(editor.getMarkdown()) !== comparableMarkdown(text))
116
+ reasons.push('formatting the rich editor would rewrite');
117
+ }
118
+ catch {
119
+ reasons.push('content the rich editor cannot read');
120
+ }
121
+ finally {
122
+ editor?.destroy();
123
+ }
124
+ }
125
+ const result = reasons.length ? { safe: false, reasons } : { safe: true };
126
+ if (cache.size > 32)
127
+ cache.delete(cache.keys().next().value);
128
+ cache.set(text, result);
129
+ return result;
130
+ }
131
+ export function describeUnsafe(safety) {
132
+ if (safety.safe)
133
+ return '';
134
+ return `Rich editing is off because this document contains ${safety.reasons.join(', ')}, which it could not keep exactly. You can still edit the Markdown source.`;
135
+ }
@@ -0,0 +1,86 @@
1
+ import type { ReadResult, ResourceAccess, ResourceClient, ResourceExpectation, ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';
2
+ import { type TextEdit } from '@hachej/boring-files/text';
3
+ import type { PresentationCommand, PresentationResult } from './contracts.js';
4
+ import type { EditableViewerController, SaveResult, SaveSelection } from './resources.js';
5
+ import type { TextDraftActions, TextDraftOptions, TextDraftRecoveryState } from './text-draft-types.js';
6
+ export type MarkdownSource = {
7
+ readonly kind: 'saved';
8
+ readonly snapshot: ResourceSnapshot;
9
+ } | {
10
+ readonly kind: 'new';
11
+ readonly target: ResourceLocator;
12
+ readonly text?: string;
13
+ };
14
+ export interface MarkdownProposal {
15
+ readonly id: string;
16
+ readonly base: SaveSelection;
17
+ readonly before: string;
18
+ readonly after: string;
19
+ readonly edits: readonly TextEdit[];
20
+ readonly summary: string;
21
+ readonly adopted: boolean;
22
+ }
23
+ type MarkdownSubject = SaveSelection['target']['subject'];
24
+ export interface MarkdownInspection {
25
+ readonly selection: SaveSelection;
26
+ readonly text: string;
27
+ readonly dirty: boolean;
28
+ }
29
+ export interface MarkdownTools {
30
+ readonly inspect: PresentationCommand<{
31
+ readonly expiresAt: number;
32
+ }, MarkdownInspection, MarkdownSubject>;
33
+ readonly propose: PresentationCommand<{
34
+ readonly expiresAt: number;
35
+ readonly edits: readonly TextEdit[];
36
+ readonly summary: string;
37
+ }, void, MarkdownSubject>;
38
+ }
39
+ export interface MarkdownState {
40
+ readonly recovery: TextDraftRecoveryState;
41
+ readonly identity: Pick<ResourceAccess, 'scopeId' | 'principalId' | 'initiatorId'>;
42
+ readonly text: string;
43
+ readonly base: ResourceExpectation;
44
+ readonly proposals: readonly MarkdownProposal[];
45
+ readonly readOnly: boolean;
46
+ readonly bufferVersion: number;
47
+ readonly dirty: boolean;
48
+ readonly lifecycle: 'active' | 'disposed';
49
+ readonly save: {
50
+ readonly kind: 'idle';
51
+ } | {
52
+ readonly kind: 'pending';
53
+ readonly operationId: string;
54
+ } | {
55
+ readonly kind: 'settled';
56
+ readonly result: SaveResult;
57
+ };
58
+ readonly remote: ResourceExpectation | null;
59
+ }
60
+ export interface MarkdownActions extends TextDraftActions {
61
+ readonly propose: (base: SaveSelection, edits: readonly TextEdit[], summary?: string) => PresentationResult<void, MarkdownSubject>;
62
+ readonly accept: (proposalId: string) => Promise<SaveResult>;
63
+ readonly reject: (proposalId: string) => void;
64
+ readonly edit: (text: string) => void;
65
+ readonly selection: () => SaveSelection;
66
+ readonly refresh: () => Promise<ReadResult>;
67
+ readonly reconcile: () => Promise<SaveResult>;
68
+ /** For an unconfirmed save: stop reconciling and refresh, keeping the draft. The save is never reported committed or replayed. */
69
+ readonly abandon: () => Promise<ReadResult>;
70
+ readonly discardToRemote: () => Promise<ReadResult>;
71
+ }
72
+ export interface MarkdownOptions {
73
+ readonly drafts?: TextDraftOptions;
74
+ /** Expected authenticated identity is comparison metadata, never a grant. */
75
+ readonly identity: Pick<ResourceAccess, 'scopeId' | 'principalId' | 'initiatorId'>;
76
+ readonly instanceId: string;
77
+ readonly epoch: string;
78
+ readonly source: MarkdownSource;
79
+ readonly client: ResourceClient;
80
+ readonly readOnly?: boolean;
81
+ readonly onListenerError?: (error: unknown) => void;
82
+ }
83
+ export type MarkdownController = EditableViewerController<MarkdownState, MarkdownActions, MarkdownTools>;
84
+ export declare function createMarkdownController(options: MarkdownOptions): MarkdownController;
85
+ export {};
86
+ //# sourceMappingURL=markdown.d.ts.map
@@ -0,0 +1,121 @@
1
+ import { applyTextEdits, parseTextEdits } from '@hachej/boring-files/text';
2
+ import { createTextBuffer, freeze, sameBase } from './text-buffer.js';
3
+ import { randomUUID } from '@hachej/boring-files/platform';
4
+ export function createMarkdownController(options) {
5
+ const { drafts, ...bufferOptions } = options;
6
+ const buffer = createTextBuffer({ ...bufferOptions, ...(drafts ? { drafts: { ...drafts, format: 'markdown/v1' } } : {}), mediaType: 'text/markdown', readText: snapshot => {
7
+ if (snapshot.mediaType !== 'text/markdown' && snapshot.mediaType !== 'text/plain')
8
+ throw new TypeError('Expected Markdown or plain text');
9
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(snapshot.bytes);
10
+ } });
11
+ let state = freeze({ ...buffer.getSnapshot(), proposals: [] });
12
+ const listeners = new Set();
13
+ const update = (next) => {
14
+ state = freeze(next);
15
+ for (const listener of [...listeners]) {
16
+ try {
17
+ listener();
18
+ }
19
+ catch (error) {
20
+ queueMicrotask(() => { if (options.onListenerError)
21
+ options.onListenerError(error);
22
+ else
23
+ throw error; });
24
+ }
25
+ }
26
+ };
27
+ const unsubscribe = buffer.subscribe(() => update({ ...buffer.getSnapshot(), proposals: state.proposals }));
28
+ const selection = buffer.selection;
29
+ const selected = (value) => {
30
+ const current = selection().target, supplied = value.target;
31
+ return current.instanceId === supplied.instanceId && current.epoch === supplied.epoch
32
+ && current.subject.scopeId === supplied.subject.scopeId && current.subject.bufferVersion === supplied.subject.bufferVersion
33
+ && sameBase(current.subject.base, supplied.subject.base);
34
+ };
35
+ function propose(base, input, summary = '') {
36
+ if (state.lifecycle === 'disposed')
37
+ return { kind: 'unavailable', reason: 'Viewer is disposed' };
38
+ if (state.readOnly)
39
+ return { kind: 'denied', reason: 'Viewer is read-only' };
40
+ if (!selected(base))
41
+ return { kind: 'stale', reason: 'Proposal target has changed' };
42
+ const edits = parseTextEdits(input);
43
+ if (typeof summary !== 'string')
44
+ throw new TypeError('Proposal summary must be text');
45
+ const result = applyTextEdits(state.text, edits);
46
+ if (result.kind === 'rejected')
47
+ return { kind: 'denied', reason: result.reason };
48
+ const proposal = { id: randomUUID(), base: structuredClone(base), before: state.text, after: result.text, edits, summary, adopted: false };
49
+ update({ ...state, proposals: [...state.proposals, proposal] });
50
+ return { kind: 'proposed', proposalId: proposal.id, base: proposal.base.target };
51
+ }
52
+ function accept(proposalId) {
53
+ if (state.lifecycle === 'disposed')
54
+ return Promise.resolve({ kind: 'unavailable', reason: 'Viewer is disposed' });
55
+ if (state.readOnly)
56
+ return Promise.resolve({ kind: 'denied', reason: 'Viewer is read-only' });
57
+ const unconfirmed = state.save.kind === 'pending' ? state.save.operationId
58
+ : state.save.kind === 'settled' && state.save.result.kind === 'unknown' ? state.save.result.operationId : undefined;
59
+ if (unconfirmed !== undefined)
60
+ return Promise.resolve({ kind: 'unknown', operationId: unconfirmed, reason: 'Reconcile or await the earlier save before accepting a proposal' });
61
+ const proposal = state.proposals.find(value => value.id === proposalId);
62
+ if (!proposal || proposal.adopted || !selected(proposal.base))
63
+ return Promise.resolve({ kind: 'conflict', current: state.base.kind === 'revision' ? [state.base.target] : [], reason: 'Proposal base has changed or was already adopted' });
64
+ const nextVersion = state.bufferVersion + 1;
65
+ const adoptedSelection = freeze({ target: { ...proposal.base.target, subject: { ...proposal.base.target.subject, bufferVersion: nextVersion } } });
66
+ state = freeze({ ...state, proposals: state.proposals.map(value => value.id === proposalId ? { ...value, adopted: true } : value) });
67
+ buffer.edit(proposal.after, true);
68
+ return buffer.flush(adoptedSelection);
69
+ }
70
+ function commandInput(value) {
71
+ if (!value || typeof value !== 'object' || !('expiresAt' in value) || typeof value.expiresAt !== 'number' || !Number.isSafeInteger(value.expiresAt))
72
+ throw new TypeError('Command expiry is required');
73
+ return { expiresAt: value.expiresAt };
74
+ }
75
+ const tools = {
76
+ inspect: {
77
+ name: 'inspect_buffer', input: { jsonSchema: { type: 'object', properties: { expiresAt: { type: 'integer' } }, required: ['expiresAt'] }, parse: commandInput },
78
+ invoke: async (target, input, signal) => {
79
+ if (state.lifecycle === 'disposed')
80
+ return { kind: 'unavailable', reason: 'Viewer is disposed' };
81
+ if (signal?.aborted)
82
+ return { kind: 'denied', reason: 'Command was cancelled' };
83
+ if (commandInput(input).expiresAt <= Date.now() || !selected({ target }))
84
+ return { kind: 'stale', reason: 'Command expired or its target changed' };
85
+ return { kind: 'applied', value: { selection: selection(), text: state.text, dirty: state.dirty } };
86
+ },
87
+ },
88
+ propose: {
89
+ name: 'propose_patch', input: {
90
+ jsonSchema: { type: 'object', properties: { expiresAt: { type: 'integer' }, summary: { type: 'string' }, edits: { type: 'array', minItems: 1, items: { type: 'object', properties: { find: { type: 'string', minLength: 1 }, replace: { type: 'string' } }, required: ['find', 'replace'], additionalProperties: false } } }, required: ['expiresAt', 'summary', 'edits'] },
91
+ parse: value => {
92
+ const expiry = commandInput(value);
93
+ if (!value || typeof value !== 'object' || !('edits' in value) || !('summary' in value) || typeof value.summary !== 'string')
94
+ throw new TypeError('Expected edits and summary');
95
+ return { ...expiry, edits: parseTextEdits(value.edits), summary: value.summary };
96
+ },
97
+ },
98
+ invoke: async (target, input, signal) => {
99
+ if (signal?.aborted)
100
+ return { kind: 'denied', reason: 'Command was cancelled' };
101
+ if (commandInput(input).expiresAt <= Date.now())
102
+ return { kind: 'stale', reason: 'Command expired' };
103
+ return propose({ target }, input.edits, input.summary);
104
+ },
105
+ },
106
+ };
107
+ return {
108
+ getSnapshot: () => state,
109
+ subscribe: listener => { if (state.lifecycle === 'disposed')
110
+ return () => { }; listeners.add(listener); return () => { listeners.delete(listener); }; },
111
+ actions: {
112
+ edit: text => buffer.edit(text),
113
+ propose, accept, reject: proposalId => { if (state.lifecycle !== 'disposed')
114
+ update({ ...state, proposals: state.proposals.filter(value => value.id !== proposalId) }); },
115
+ checkDrafts: buffer.checkDrafts, checkpointDraft: buffer.checkpointDraft, restoreDraft: buffer.restoreDraft, discardDraft: buffer.discardDraft,
116
+ selection, refresh: () => buffer.refresh(false), discardToRemote: () => buffer.refresh(true), reconcile: buffer.reconcile, abandon: buffer.abandon,
117
+ },
118
+ tools: Object.freeze(tools), flush: buffer.flush,
119
+ dispose: () => { buffer.dispose(); unsubscribe(); listeners.clear(); },
120
+ };
121
+ }
@@ -0,0 +1,150 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ import type { Conversation, ConversationView, EntryRecord, InputSubmissionDraft, Submission, SubmissionRecord, Tx, WatchEnd } from '@earendil-works/pi-durable';
3
+ import type { ImageContent } from '@earendil-works/pi-ai';
4
+ import type { ChatSource } from './pi.js';
5
+ export interface ChatIdentity {
6
+ readonly runtimeId: string;
7
+ readonly scopeId: string;
8
+ readonly principalId: string;
9
+ }
10
+ export interface ChatAttachment {
11
+ readonly id: string;
12
+ readonly name: string;
13
+ readonly content: ImageContent;
14
+ }
15
+ export interface ChatDraft {
16
+ readonly text: string;
17
+ readonly attachments: readonly ChatAttachment[];
18
+ readonly version: number;
19
+ }
20
+ export interface ChatAttempt {
21
+ readonly requestId: string;
22
+ readonly draft: ChatDraft;
23
+ readonly whenBusy: NonNullable<InputSubmissionDraft['whenBusy']>;
24
+ }
25
+ /**
26
+ * Explicit content for `send` instead of the composer draft (the composer is then left alone). `restore` is what goes back into the
27
+ * composer if the host refuses it (default: the content itself), for example the person's own text without a host-attached block.
28
+ */
29
+ export interface ChatSendInput {
30
+ readonly text: string;
31
+ readonly attachments?: readonly ChatAttachment[];
32
+ readonly restore?: {
33
+ readonly text: string;
34
+ readonly attachments?: readonly ChatAttachment[];
35
+ };
36
+ }
37
+ export type ChatSendState = {
38
+ readonly kind: 'idle';
39
+ } | {
40
+ readonly kind: 'validating';
41
+ readonly draft: ChatDraft;
42
+ } | {
43
+ readonly kind: 'blocked';
44
+ readonly reason: string;
45
+ } | {
46
+ readonly kind: 'submitting' | 'unknown';
47
+ readonly attempt: ChatAttempt;
48
+ } | {
49
+ readonly kind: 'admitted';
50
+ readonly attempt: ChatAttempt;
51
+ readonly submissionId: Submission['id'];
52
+ readonly record?: SubmissionRecord;
53
+ };
54
+ /** `reconnecting`: the watch is still owned but its remote stream was lost and is being reopened (see `ChatLink`). */
55
+ export type ChatConnection = {
56
+ readonly kind: 'idle' | 'connecting' | 'connected' | 'reconnecting';
57
+ } | {
58
+ readonly kind: 'closed';
59
+ readonly end?: WatchEnd;
60
+ } | {
61
+ readonly kind: 'error';
62
+ readonly error: unknown;
63
+ };
64
+ /**
65
+ * The part of a conversation the controller uses. A native `Conversation` satisfies it, and so does the remote client of
66
+ * `@hachej/boring-ui-kit/remote-chat`, which implements exactly these members (everything else is unavailable over the wire).
67
+ */
68
+ export interface ChatConversation {
69
+ readonly id: Conversation['id'];
70
+ readonly watch: Conversation['watch'];
71
+ readonly submit: (draft: InputSubmissionDraft, context: Context) => Promise<Submission>;
72
+ readonly abort: Conversation['abort'];
73
+ readonly entries: Conversation['entries'];
74
+ /** Only `tx.submissionByRequest` is used, to reconcile an unconfirmed send. */
75
+ readonly commit: <T>(change: (tx: Pick<Tx, 'submissionByRequest'>) => T | Promise<T>, context: Context) => Promise<T>;
76
+ }
77
+ /**
78
+ * A remote source's link to its host, for example `link` from `@hachej/boring-ui-kit/remote-chat`. While it reports reconnecting, the
79
+ * controller's watch stays open (it receives a complete view when the stream is back) and the connection reads `reconnecting`.
80
+ */
81
+ export interface ChatLink {
82
+ readonly reconnecting: () => boolean;
83
+ readonly subscribe: (listener: () => void) => () => void;
84
+ }
85
+ export interface NativeChatHistorySource {
86
+ readonly id: Conversation['id'];
87
+ readonly entries: Conversation['entries'];
88
+ }
89
+ export type ChatHistoryState = {
90
+ readonly kind: 'disabled' | 'idle';
91
+ } | {
92
+ readonly kind: 'loading' | 'ready';
93
+ readonly entries: readonly EntryRecord[];
94
+ readonly hasMore: boolean;
95
+ } | {
96
+ readonly kind: 'error';
97
+ readonly entries: readonly EntryRecord[];
98
+ readonly hasMore: boolean;
99
+ readonly error: unknown;
100
+ };
101
+ export interface NativeChatSnapshot {
102
+ readonly identity: ChatIdentity;
103
+ readonly conversationId: Conversation['id'];
104
+ readonly connection: ChatConnection;
105
+ readonly view?: ConversationView;
106
+ readonly draft: ChatDraft;
107
+ /** Messages already taken from the composer that wait, in order, for the current send to be confirmed before they are submitted. */
108
+ readonly outbox: readonly ChatDraft[];
109
+ readonly history: ChatHistoryState;
110
+ readonly send: ChatSendState;
111
+ readonly stop: 'idle' | 'requested' | 'confirmed' | 'unconfirmed';
112
+ readonly disposed: boolean;
113
+ }
114
+ export interface NativeChatOptions {
115
+ readonly identity: ChatIdentity;
116
+ readonly conversation: ChatConversation;
117
+ readonly context: Context;
118
+ readonly source?: ChatSource;
119
+ readonly link?: ChatLink;
120
+ readonly history?: NativeChatHistorySource | false;
121
+ /** Validate the exact selected draft before any native admission. */
122
+ readonly beforeSubmit?: (draft: ChatDraft) => Promise<string | undefined>;
123
+ /** Only a qualified host boundary can prove a thrown submission was not admitted. */
124
+ readonly definitelyNotAdmitted?: (error: unknown) => boolean;
125
+ readonly onListenerError?: (error: unknown) => void;
126
+ }
127
+ /** Local presentation/draft state over a borrowed native conversation and one owned native watch. */
128
+ export declare function createNativeChatController(options: NativeChatOptions): {
129
+ conversation: ChatConversation;
130
+ getSnapshot: () => NativeChatSnapshot;
131
+ subscribe: (listener: () => void) => (() => void);
132
+ setText: (text: string) => void;
133
+ setAttachments: (attachments: readonly ChatAttachment[]) => void;
134
+ clearHistory: () => void;
135
+ loadEarlier: () => Promise<void>;
136
+ connect: () => Promise<void>;
137
+ /**
138
+ * Sends the composer draft (or `input`) and empties the composer at once. While another send is still being confirmed the message
139
+ * waits in `outbox` and is submitted, in order, after it. A refused message, and any waiting behind it, go back into the composer
140
+ * ahead of what was typed since; nothing is dropped or merged into another message. Resolves with the native submission, or
141
+ * undefined when the message was not admitted (refused, unknown, or the controller was disposed).
142
+ */
143
+ send: (whenBusy?: ChatAttempt["whenBusy"], input?: ChatSendInput) => Promise<Submission | undefined>;
144
+ reconcile: () => Promise<SubmissionRecord | undefined>;
145
+ retrySameRequest: () => Promise<Submission | undefined>;
146
+ stop: () => Promise<void>;
147
+ dispose: () => Promise<WatchEnd | undefined>;
148
+ };
149
+ export type NativeChatController = ReturnType<typeof createNativeChatController>;
150
+ //# sourceMappingURL=native-chat.d.ts.map