@hachej/boring-ui-kit 0.4.25 → 0.4.26

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 (74) hide show
  1. package/README.md +26 -2
  2. package/dist/chat-artifacts.d.ts +157 -0
  3. package/dist/chat-artifacts.js +274 -0
  4. package/dist/chat-react-controller.d.ts +25 -0
  5. package/dist/chat-react-controller.js +72 -0
  6. package/dist/chat-react-conversations.d.ts +40 -0
  7. package/dist/chat-react-conversations.js +68 -0
  8. package/dist/chat-react-draft.d.ts +11 -0
  9. package/dist/chat-react-draft.js +91 -0
  10. package/dist/chat-react-remote.d.ts +33 -0
  11. package/dist/chat-react-remote.js +72 -0
  12. package/dist/chat-react-transcript.d.ts +39 -0
  13. package/dist/chat-react-transcript.js +123 -0
  14. package/dist/chat-react.d.ts +60 -0
  15. package/dist/chat-react.js +94 -0
  16. package/dist/chat-text.d.ts +122 -0
  17. package/dist/chat-text.js +216 -0
  18. package/dist/chat-view.d.ts +2 -17
  19. package/dist/chat-view.js +1 -0
  20. package/dist/run-notifications.d.ts +42 -0
  21. package/dist/run-notifications.js +67 -0
  22. package/dist/shared-chat/index.d.ts +1 -1
  23. package/dist/shared-chat/index.js +5 -4
  24. package/dist/voice-capture.d.ts +12 -0
  25. package/dist/voice-capture.js +17 -2
  26. package/package.json +10 -2
  27. package/r/agent-target.json +5 -9
  28. package/r/app-shell.json +16 -25
  29. package/r/board-viewer.json +7 -11
  30. package/r/button.json +2 -9
  31. package/r/diagram-viewer.json +0 -5
  32. package/r/dock.json +2 -2
  33. package/r/documents.json +13 -18
  34. package/r/feedback-preview.json +1 -7
  35. package/r/feedback.json +12 -19
  36. package/r/file-tree.json +4 -14
  37. package/r/highlights.json +4 -9
  38. package/r/html-viewer.json +3 -3
  39. package/r/looks-viewer.json +2 -6
  40. package/r/maquette-viewer.json +2 -20
  41. package/r/markdown-editor.json +4 -7
  42. package/r/mockup-viewer.json +2 -5
  43. package/r/pi-ambient.json +11 -19
  44. package/r/pi-app-kinds.json +10 -10
  45. package/r/pi-app.json +16 -26
  46. package/r/pi-artifacts.json +3 -4
  47. package/r/pi-chat-approval.json +2 -5
  48. package/r/pi-chat-attachments.json +2 -4
  49. package/r/pi-chat-markdown.json +3 -5
  50. package/r/pi-chat-mentions.json +2 -3
  51. package/r/pi-chat-pickers.json +2 -4
  52. package/r/pi-chat-plugins.json +11 -11
  53. package/r/pi-chat-question.json +1 -7
  54. package/r/pi-chat-slash.json +3 -6
  55. package/r/pi-chat-subagent.json +3 -8
  56. package/r/pi-chat-voice.json +6 -9
  57. package/r/pi-chat.json +17 -45
  58. package/r/pi-workspace.json +4 -7
  59. package/r/placed-agent.json +4 -5
  60. package/r/provider-setup.json +2 -8
  61. package/r/record-viewer.json +0 -9
  62. package/r/registry.json +163 -396
  63. package/r/requests-board.json +2 -10
  64. package/r/shared-chat.json +9 -23
  65. package/r/task-list-viewer.json +3 -9
  66. package/r/version-history.json +3 -5
  67. package/r/viewer-code.json +2 -3
  68. package/r/viewer-frame.json +7 -15
  69. package/r/viewer-html.json +4 -5
  70. package/r/viewer-image.json +3 -4
  71. package/r/viewer-markdown.json +6 -8
  72. package/r/viewer-pdf.json +5 -7
  73. package/r/viewers.json +6 -6
  74. package/r/windows.json +2 -7
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Optional chat text rules: the grammar of what a person types and what a message shows. Pure string functions; no React, files, agent
3
+ * or controller runtime. Registry items (pi-chat, pi-chat-slash, pi-chat-markdown, pi-chat-voice, shared-chat, ...) call these and keep
4
+ * no copy, so a composer, a bubble and the shared chat agree on one `@mention` syntax.
5
+ */
6
+ /** Where a `/` menu entry comes from. Skills and host commands are grouped separately in the menu. */
7
+ export type SlashSource = 'local' | 'extension' | 'prompt' | 'skill';
8
+ /** The unified `/` menu entry. */
9
+ export interface SlashItem {
10
+ readonly name: string;
11
+ readonly description: string;
12
+ readonly source: SlashSource;
13
+ readonly sourcePlugin?: string;
14
+ }
15
+ /** The commands and skills a `/` menu lists (the item's `SlashConfig` is assignable to it). */
16
+ export interface SlashEntries {
17
+ readonly commands?: readonly {
18
+ readonly name: string;
19
+ readonly description?: string | undefined;
20
+ readonly sourcePlugin?: string | undefined;
21
+ }[] | undefined;
22
+ readonly skills?: readonly {
23
+ readonly name: string;
24
+ readonly description: string;
25
+ readonly sourcePlugin?: string | undefined;
26
+ }[] | undefined;
27
+ }
28
+ /** The `/` menu entries: host commands first, then skills. */
29
+ export declare function slashItems(config: SlashEntries): SlashItem[];
30
+ /** The group an item lives under in the filter chips: skills, its plugin, or `built-in`. */
31
+ export declare function slashGroup(item: Pick<SlashItem, 'source' | 'sourcePlugin'>): string;
32
+ /** `/` opens the menu only when the text before the caret is one `/token`, as in v2. */
33
+ export declare function slashQuery(text: string, caret: number): string | null;
34
+ export interface MentionTrigger {
35
+ readonly query: string;
36
+ readonly start: number;
37
+ readonly end: number;
38
+ }
39
+ /** An `@` at the start or after whitespace, up to the caret. */
40
+ export declare function mentionTrigger(text: string, caret: number): MentionTrigger | null;
41
+ /**
42
+ * The text of a mention of `path` (a workspace path or a shared-chat member id). A value with whitespace, a quote or a backslash is
43
+ * written in double quotes, `@"docs/Meeting notes.md"`, with `\` and `"` escaped, because a bare mention ends at a space and a bare `\`
44
+ * is not an escape. Any other value stays bare (`@docs/plan.md`).
45
+ */
46
+ export declare function mentionToken(path: string): string;
47
+ /**
48
+ * The `@` tokens of a message, bare or quoted: group 1 is the character before the `@` (or the start), group 2 the quoted body (escapes
49
+ * still in it), group 3 the bare path. `mentionedPaths` of `@hachej/boring-agent/mentions` parses the same syntax (a contract test keeps
50
+ * them equal).
51
+ */
52
+ export declare const MENTION_TOKEN: RegExp;
53
+ /** The distinct values a message mentions, in order. The same result as the agent's `mentionedPaths`. */
54
+ export declare function mentionedPaths(text: string): string[];
55
+ /** Whether the draft already holds a mention of `path`. */
56
+ export declare function hasMention(text: string, path: string): boolean;
57
+ /** The draft without its mention of `path` (and the space after it). */
58
+ export declare function removeMention(text: string, path: string): string;
59
+ /** A registered prefix wins only for that kind; ordinary file paths keep their existing presentation. */
60
+ export declare function mentionKind<Kind>(value: string, config: {
61
+ readonly kinds?: Readonly<Record<string, Kind>> | undefined;
62
+ } | undefined): {
63
+ readonly kind: Kind;
64
+ readonly path: string;
65
+ } | undefined;
66
+ /** The composer's editable text and hidden app tokens, derived only from the full draft. File tokens remain editable text. */
67
+ export declare function kindMentionDraft<Kind>(text: string, config: {
68
+ readonly kinds?: Readonly<Record<string, Kind>> | undefined;
69
+ } | undefined): {
70
+ readonly text: string;
71
+ readonly tokens: readonly string[];
72
+ };
73
+ /** Keep an explicit leading skill first, then complete hidden tokens before any unfinished quoted input. */
74
+ export declare function writeKindMentionDraft(text: string, tokens: readonly string[], skills?: readonly string[]): string;
75
+ /** Consume every draft token for voice sends, including repeated references to the same path. */
76
+ export declare function withoutMentions(text: string): string;
77
+ /** Refused messages restore the words, never the app card chips. File mentions retain their existing recovery behaviour. */
78
+ export declare function withoutKindMentions<Kind>(text: string, config: {
79
+ readonly kinds?: Readonly<Record<string, Kind>> | undefined;
80
+ } | undefined): string;
81
+ /**
82
+ * The draft with `text` added at its end: a space between them unless the draft is empty or already ends in whitespace. Dictation,
83
+ * voice transcripts, picked mentions and app targets all append this way.
84
+ */
85
+ export declare function appendToDraft(draft: string, text: string): string;
86
+ /** The draft with `text` put back before it on its own line (a refused or withdrawn message); whitespace-only parts are dropped. */
87
+ export declare function prependToDraft(draft: string, text: string): string;
88
+ export type Piece = {
89
+ readonly text: string;
90
+ readonly kind?: 'mention' | 'skill';
91
+ readonly value?: string;
92
+ };
93
+ /** Upload paths are `uploads/<name>`: the attachments of a message, shown as cards rather than as text. */
94
+ export declare const isUploadPath: (path: string) => boolean;
95
+ export type FileKind = 'document' | 'image' | 'pdf' | 'sheet' | 'slides' | 'audio' | 'file';
96
+ /** The card's kind, from the extension alone. */
97
+ export declare function fileKind(path: string): FileKind;
98
+ /** Recorded voice uses a reserved upload filename prefix; other audio remains a plain file card. */
99
+ export declare const isVoicePath: (path: string) => boolean;
100
+ /** The name a card shows: the upload's file name without the random suffix `uploadPath` adds (`report-1a2b3c4d.pdf` is `report.pdf`). */
101
+ export declare function displayName(path: string): string;
102
+ /**
103
+ * The pieces a message shows once `hidden` ones are taken out: a space that a removed piece leaves at a join is dropped, and the ends are
104
+ * trimmed where it was. The message's own text is not changed, only what the bubble draws.
105
+ */
106
+ export declare function visibleParts(parts: readonly Piece[], hidden: (part: Piece) => boolean): Piece[];
107
+ /** Paste-to-file thresholds: a paste with more characters or more lines than these becomes a file. Defaults 2000 and 40. */
108
+ export interface PasteToFile {
109
+ readonly minChars?: number | undefined;
110
+ readonly minLines?: number | undefined;
111
+ }
112
+ /** Whether pasted text is long enough to become a file. `more` than the limits: the characters, or the lines, of the text. */
113
+ export declare function isLongPaste(text: string, limits?: PasteToFile): boolean;
114
+ /** Markdown in pasted text: a heading, a list or quote item, a fence, a table row, emphasis or a link. Anything else is plain text. */
115
+ export declare function looksLikeMarkdown(text: string): boolean;
116
+ /** Split a sent message into plain text, `@path` mentions and a leading `/skill` token. */
117
+ export declare function pieces(text: string, options: {
118
+ readonly mentions: boolean;
119
+ readonly skills: readonly string[];
120
+ readonly uploads?: boolean;
121
+ }): Piece[];
122
+ //# sourceMappingURL=chat-text.d.ts.map
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Optional chat text rules: the grammar of what a person types and what a message shows. Pure string functions; no React, files, agent
3
+ * or controller runtime. Registry items (pi-chat, pi-chat-slash, pi-chat-markdown, pi-chat-voice, shared-chat, ...) call these and keep
4
+ * no copy, so a composer, a bubble and the shared chat agree on one `@mention` syntax.
5
+ */
6
+ /** The `/` menu entries: host commands first, then skills. */
7
+ export function slashItems(config) {
8
+ return [
9
+ ...(config.commands ?? []).map((command) => ({ name: command.name, description: command.description ?? '', source: 'local', ...(command.sourcePlugin ? { sourcePlugin: command.sourcePlugin } : {}) })),
10
+ ...(config.skills ?? []).map((skill) => ({ name: skill.name, description: skill.description, source: 'skill', ...(skill.sourcePlugin ? { sourcePlugin: skill.sourcePlugin } : {}) })),
11
+ ];
12
+ }
13
+ /** The group an item lives under in the filter chips: skills, its plugin, or `built-in`. */
14
+ export function slashGroup(item) {
15
+ if (item.source === 'skill')
16
+ return 'skills';
17
+ return item.sourcePlugin || 'built-in';
18
+ }
19
+ /** `/` opens the menu only when the text before the caret is one `/token`, as in v2. */
20
+ export function slashQuery(text, caret) {
21
+ const match = /^\/(\S*)$/.exec(text.slice(0, caret));
22
+ return match ? match[1] : null;
23
+ }
24
+ /** An `@` at the start or after whitespace, up to the caret. */
25
+ export function mentionTrigger(text, caret) {
26
+ const before = text.slice(0, caret);
27
+ const match = /(^|\s)@(\S*)$/.exec(before);
28
+ // A typed opening quote (`@"Meeting`) is not part of the search; the token starts at the `@` either way.
29
+ return match ? { query: match[2].replace(/^"/, ''), start: before.length - match[2].length - 1, end: caret } : null;
30
+ }
31
+ /**
32
+ * The text of a mention of `path` (a workspace path or a shared-chat member id). A value with whitespace, a quote or a backslash is
33
+ * written in double quotes, `@"docs/Meeting notes.md"`, with `\` and `"` escaped, because a bare mention ends at a space and a bare `\`
34
+ * is not an escape. Any other value stays bare (`@docs/plan.md`).
35
+ */
36
+ export function mentionToken(path) {
37
+ return /[\s"\\]/.test(path) ? `@"${path.replace(/[\\"]/g, '\\$&')}"` : `@${path}`;
38
+ }
39
+ /**
40
+ * The `@` tokens of a message, bare or quoted: group 1 is the character before the `@` (or the start), group 2 the quoted body (escapes
41
+ * still in it), group 3 the bare path. `mentionedPaths` of `@hachej/boring-agent/mentions` parses the same syntax (a contract test keeps
42
+ * them equal).
43
+ */
44
+ export const MENTION_TOKEN = /(^|\s)@(?:"((?:[^"\\]|\\.)*)"|([^\s]+))/g;
45
+ /** The value of one `MENTION_TOKEN` match: a quoted body unescaped, a bare one without trailing sentence punctuation. */
46
+ const mentionValue = (match) => match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].replace(/[.,;:!?)\]}'"]+$/, '');
47
+ /** The distinct values a message mentions, in order. The same result as the agent's `mentionedPaths`. */
48
+ export function mentionedPaths(text) {
49
+ const found = new Set();
50
+ for (const match of text.matchAll(MENTION_TOKEN)) {
51
+ const value = mentionValue(match);
52
+ if (value)
53
+ found.add(value);
54
+ }
55
+ return [...found];
56
+ }
57
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ /** Whether the draft already holds a mention of `path`. */
59
+ export function hasMention(text, path) {
60
+ return pieces(text, { mentions: true, skills: [] }).some(piece => piece.kind === 'mention' && piece.value === path);
61
+ }
62
+ /** The draft without its mention of `path` (and the space after it). */
63
+ export function removeMention(text, path) {
64
+ let removed = false, gap = false;
65
+ return pieces(text, { mentions: true, skills: [] }).map(piece => {
66
+ if (!removed && piece.kind === 'mention' && piece.value === path) {
67
+ removed = true;
68
+ gap = true;
69
+ return '';
70
+ }
71
+ if (gap) {
72
+ gap = false;
73
+ return piece.text.replace(/^ /, '');
74
+ }
75
+ return piece.text;
76
+ }).join('').replace(/^[ ]+/, '');
77
+ }
78
+ /** A registered prefix wins only for that kind; ordinary file paths keep their existing presentation. */
79
+ export function mentionKind(value, config) {
80
+ const match = /^([a-z][a-z0-9-]*):(.+)$/s.exec(value);
81
+ const kind = match && config?.kinds && Object.hasOwn(config.kinds, match[1]) ? config.kinds[match[1]] : undefined;
82
+ return kind ? { kind, path: match[2] } : undefined;
83
+ }
84
+ /** The composer's editable text and hidden app tokens, derived only from the full draft. File tokens remain editable text. */
85
+ export function kindMentionDraft(text, config) {
86
+ const hidden = new Set(), tokens = [];
87
+ let offset = 0;
88
+ for (const piece of pieces(text, { mentions: true, skills: [] })) {
89
+ const end = offset + piece.text.length;
90
+ if (piece.kind === 'mention' && mentionKind(piece.value, config)) {
91
+ tokens.push(piece.text);
92
+ for (let at = offset; at < end; at++)
93
+ hidden.add(at);
94
+ if (offset > 0 && text[offset - 1] === ' ' && !hidden.has(offset - 1))
95
+ hidden.add(offset - 1);
96
+ else if (text[end] === ' ')
97
+ hidden.add(end);
98
+ }
99
+ offset = end;
100
+ }
101
+ return { text: text.split('').filter((_, at) => !hidden.has(at)).join(''), tokens };
102
+ }
103
+ /** Keep an explicit leading skill first, then complete hidden tokens before any unfinished quoted input. */
104
+ export function writeKindMentionDraft(text, tokens, skills = []) {
105
+ if (!tokens.length)
106
+ return text;
107
+ const leading = pieces(text, { mentions: false, skills })[0];
108
+ if (leading?.kind === 'skill')
109
+ return `${leading.text} ${tokens.join(' ')}${text.slice(leading.text.length)}`;
110
+ return `${tokens.join(' ')}${text ? ` ${text}` : ''}`;
111
+ }
112
+ /** Consume every draft token for voice sends, including repeated references to the same path. */
113
+ export function withoutMentions(text) {
114
+ const words = visibleParts(pieces(text, { mentions: true, skills: [] }), piece => piece.kind === 'mention').map(piece => piece.text).join('').replace(/^[ ]+/, '');
115
+ return words.trim() ? words : '';
116
+ }
117
+ /** Refused messages restore the words, never the app card chips. File mentions retain their existing recovery behaviour. */
118
+ export function withoutKindMentions(text, config) {
119
+ return visibleParts(pieces(text, { mentions: true, skills: [] }), piece => piece.kind === 'mention' && Boolean(mentionKind(piece.value, config))).map(piece => piece.text).join('').replace(/^[ ]+/, '');
120
+ }
121
+ /**
122
+ * The draft with `text` added at its end: a space between them unless the draft is empty or already ends in whitespace. Dictation,
123
+ * voice transcripts, picked mentions and app targets all append this way.
124
+ */
125
+ export function appendToDraft(draft, text) {
126
+ return `${draft}${draft === '' || /\s$/.test(draft) ? '' : ' '}${text}`;
127
+ }
128
+ /** The draft with `text` put back before it on its own line (a refused or withdrawn message); whitespace-only parts are dropped. */
129
+ export function prependToDraft(draft, text) {
130
+ return [text, draft].filter(value => value.trim()).join('\n');
131
+ }
132
+ /** Upload paths are `uploads/<name>`: the attachments of a message, shown as cards rather than as text. */
133
+ export const isUploadPath = (path) => path.startsWith('uploads/');
134
+ const KIND_BY_EXTENSION = {
135
+ png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image', bmp: 'image', avif: 'image',
136
+ pdf: 'pdf',
137
+ webm: 'audio', ogg: 'audio', m4a: 'audio', mp4: 'audio', mp3: 'audio', wav: 'audio',
138
+ csv: 'sheet', tsv: 'sheet', xls: 'sheet', xlsx: 'sheet', xlsm: 'sheet', ods: 'sheet', numbers: 'sheet',
139
+ ppt: 'slides', pptx: 'slides', odp: 'slides', key: 'slides',
140
+ md: 'document', markdown: 'document', txt: 'document', doc: 'document', docx: 'document', odt: 'document', rtf: 'document',
141
+ };
142
+ /** The card's kind, from the extension alone. */
143
+ export function fileKind(path) {
144
+ const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? '';
145
+ return KIND_BY_EXTENSION[extension] ?? 'file';
146
+ }
147
+ /** Recorded voice uses a reserved upload filename prefix; other audio remains a plain file card. */
148
+ export const isVoicePath = (path) => /^uploads\/voice-[^/]+$/.test(path) && fileKind(path) === 'audio';
149
+ /** The name a card shows: the upload's file name without the random suffix `uploadPath` adds (`report-1a2b3c4d.pdf` is `report.pdf`). */
150
+ export function displayName(path) {
151
+ const name = path.slice(path.lastIndexOf('/') + 1);
152
+ return name.replace(/-[0-9a-f]{8}(?=\.[^.]*$|$)/i, '');
153
+ }
154
+ /**
155
+ * The pieces a message shows once `hidden` ones are taken out: a space that a removed piece leaves at a join is dropped, and the ends are
156
+ * trimmed where it was. The message's own text is not changed, only what the bubble draws.
157
+ */
158
+ export function visibleParts(parts, hidden) {
159
+ const out = [];
160
+ let gap = false;
161
+ for (const part of parts) {
162
+ if (hidden(part)) {
163
+ gap = true;
164
+ continue;
165
+ }
166
+ const prev = out[out.length - 1];
167
+ const joins = gap && part.kind === undefined && (prev === undefined || (prev.kind === undefined && /\s$/.test(prev.text)));
168
+ out.push(joins ? { text: part.text.replace(/^\s+/, '') } : part);
169
+ gap = false;
170
+ }
171
+ const last = out[out.length - 1];
172
+ if (gap && last && last.kind === undefined)
173
+ out[out.length - 1] = { text: last.text.replace(/\s+$/, '') };
174
+ return out.filter(part => part.kind !== undefined || part.text !== '');
175
+ }
176
+ /** Whether pasted text is long enough to become a file. `more` than the limits: the characters, or the lines, of the text. */
177
+ export function isLongPaste(text, limits = {}) {
178
+ const { minChars = 2000, minLines = 40 } = limits;
179
+ return text.length > minChars || text.split('\n').length > minLines;
180
+ }
181
+ /** Markdown in pasted text: a heading, a list or quote item, a fence, a table row, emphasis or a link. Anything else is plain text. */
182
+ export function looksLikeMarkdown(text) {
183
+ return /^\s{0,3}(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~)/m.test(text) || /\*\*[^*\n]+\*\*|\]\([^)\n]+\)|^\|.*\|\s*$/m.test(text);
184
+ }
185
+ /** Split a sent message into plain text, `@path` mentions and a leading `/skill` token. */
186
+ export function pieces(text, options) {
187
+ const found = [];
188
+ const skill = options.skills.length ? new RegExp(`^/(${options.skills.map(escape).join('|')})(?=\\s|$)`).exec(text) : null;
189
+ if (skill)
190
+ found.push({ start: 0, end: skill[0].length, kind: 'skill', value: skill[1] });
191
+ if (options.mentions || options.uploads) {
192
+ for (const match of text.matchAll(MENTION_TOKEN)) {
193
+ // A quoted path keeps its punctuation (the closing quote ends it); a bare one drops trailing punctuation, as a sentence would.
194
+ const quoted = match[2] !== undefined;
195
+ const path = mentionValue(match);
196
+ if (!path || (!options.mentions && !isUploadPath(path)))
197
+ continue;
198
+ const start = match.index + match[1].length;
199
+ const end = quoted ? match.index + match[0].length : start + 1 + path.length;
200
+ found.push({ start, end, kind: 'mention', value: path });
201
+ }
202
+ }
203
+ const out = [];
204
+ let cursor = 0;
205
+ for (const item of found.sort((a, b) => a.start - b.start)) {
206
+ if (item.start < cursor)
207
+ continue;
208
+ if (item.start > cursor)
209
+ out.push({ text: text.slice(cursor, item.start) });
210
+ out.push({ text: text.slice(item.start, item.end), kind: item.kind, value: item.value });
211
+ cursor = item.end;
212
+ }
213
+ if (cursor < text.length)
214
+ out.push({ text: text.slice(cursor) });
215
+ return out;
216
+ }
@@ -34,23 +34,7 @@ export interface ToolEntry<Content = unknown> {
34
34
  readonly status: ToolStatus;
35
35
  readonly custom?: Content;
36
36
  }
37
- /**
38
- * An artifact a tool call made (or a running `present` call is making), as the chat places it. `descriptor` is the artifacts item's own
39
- * value: the chat hands it back to the host's `open` and `isOpen` and never reads it.
40
- */
41
- export interface ChatArtifact {
42
- readonly state: 'presenting' | 'ready';
43
- /** One card per id in a reply: the last version, at the place of its last step. */
44
- readonly id: string | undefined;
45
- readonly title: string | undefined;
46
- /** The workspace path, for the conversation's file list. */
47
- readonly path: string | undefined;
48
- /** The tool's own result carries it, so its card replaces the step; a host-detected one is shown in addition to the step. */
49
- readonly own: boolean;
50
- readonly descriptor: unknown;
51
- }
52
- /** Finds an artifact descriptor a finished call's result does not carry itself (the host's `detect`). */
53
- export type ArtifactDetect = (call: ToolCall, result: ToolResultMessage) => unknown;
37
+ import type { ArtifactDetect, ChatArtifact } from './chat-artifacts.js';
54
38
  /** The artifact a call made, or that a running call (`running`) is making; undefined for any other call. */
55
39
  export type ArtifactRecognise = (call: ToolCall, result: ToolResultMessage | undefined, running: boolean, detect: ArtifactDetect | undefined) => ChatArtifact | undefined;
56
40
  /**
@@ -275,4 +259,5 @@ export declare function finishNotice(rows: readonly Row<unknown>[], text?: Notic
275
259
  readonly kind: 'done' | 'error';
276
260
  readonly summary: string;
277
261
  } | undefined;
262
+ export * from './chat-artifacts.js';
278
263
  //# sourceMappingURL=chat-view.d.ts.map
package/dist/chat-view.js CHANGED
@@ -391,3 +391,4 @@ export function finishNotice(rows, text = defaultNoticeText) {
391
391
  return { kind: 'error', summary: last.errorMessage ?? text.runFailed };
392
392
  return { kind: 'done', summary: summaryOf(last?.text ?? '') || text.finished };
393
393
  }
394
+ export * from './chat-artifacts.js';
@@ -0,0 +1,42 @@
1
+ import type { ToolCall } from '@earendil-works/pi-ai';
2
+ import type { ChatController } from './native-chat.js';
3
+ import type { NoticeText } from './chat-view.js';
4
+ /** Transient host presentation, never native execution state. */
5
+ export type NotificationKind = 'done' | 'input' | 'error';
6
+ export interface AgentNotification {
7
+ readonly id: string;
8
+ readonly kind: NotificationKind;
9
+ readonly title: string;
10
+ readonly summary?: string | undefined;
11
+ readonly conversationId?: string | undefined;
12
+ readonly createdAt: number;
13
+ }
14
+ export type NewNotification = Omit<AgentNotification, 'id' | 'createdAt'> & {
15
+ readonly id?: string | undefined;
16
+ };
17
+ export interface NotificationStore {
18
+ readonly subscribe: (listener: () => void) => () => void;
19
+ readonly getSnapshot: () => readonly AgentNotification[];
20
+ readonly push: (notification: NewNotification) => string;
21
+ readonly dismiss: (id: string) => void;
22
+ readonly dismissConversation: (conversationId: string, kind?: NotificationKind) => void;
23
+ readonly clear: () => void;
24
+ }
25
+ export declare function createNotificationStore(): NotificationStore;
26
+ type Outcome = 'done' | 'error' | null;
27
+ export interface RunNotificationOptions {
28
+ readonly conversationId: string;
29
+ readonly title: string | (() => string);
30
+ /** The source recipe owns the ask_user form grammar. */
31
+ readonly question: (call: ToolCall) => string;
32
+ readonly text?: NoticeText | undefined;
33
+ readonly enabled?: boolean | undefined;
34
+ /** Suppresses pushes, not observation or dismissals (for an already visible conversation). */
35
+ readonly suppress?: (() => boolean) | undefined;
36
+ readonly onOutcome?: ((outcome: Outcome) => void) | undefined;
37
+ }
38
+ export type RunNotificationSource = Pick<ChatController, 'getSnapshot' | 'subscribe'>;
39
+ /** Borrows an existing controller; unsubscribe detaches only this viewer, never stops or disposes a task. */
40
+ export declare function watchConversation(controller: RunNotificationSource, store: NotificationStore, options: RunNotificationOptions | (() => RunNotificationOptions)): () => void;
41
+ export {};
42
+ //# sourceMappingURL=run-notifications.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { derive, finishNotice, pendingNotice } from './chat-view.js';
2
+ export function createNotificationStore() {
3
+ let items = [];
4
+ let sequence = 0;
5
+ const listeners = new Set();
6
+ const set = (next) => { items = next; for (const listener of [...listeners])
7
+ listener(); };
8
+ return {
9
+ subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; },
10
+ getSnapshot: () => items,
11
+ push: notification => {
12
+ const id = notification.id ?? `notification-${++sequence}`;
13
+ set([...items.filter(item => item.id !== id), { ...notification, id, createdAt: Date.now() }]);
14
+ return id;
15
+ },
16
+ dismiss: id => { if (items.some(item => item.id === id))
17
+ set(items.filter(item => item.id !== id)); },
18
+ dismissConversation: (conversationId, kind) => {
19
+ if (items.some(item => item.conversationId === conversationId && (!kind || item.kind === kind)))
20
+ set(items.filter(item => item.conversationId !== conversationId || (kind && item.kind !== kind)));
21
+ },
22
+ clear: () => { if (items.length)
23
+ set([]); },
24
+ };
25
+ }
26
+ /** Both watchers consume connected observations only; a transport gap cannot establish a finish or consume a transition. */
27
+ function transition(before, now, rows, text) {
28
+ return { state: now, inputChanged: now.question !== before?.question,
29
+ started: Boolean(before && now.working && !before.working),
30
+ finished: before?.working && !now.working ? finishNotice(rows, text) : undefined };
31
+ }
32
+ /** Borrows an existing controller; unsubscribe detaches only this viewer, never stops or disposes a task. */
33
+ export function watchConversation(controller, store, options) {
34
+ let before;
35
+ const observe = () => {
36
+ const snapshot = controller.getSnapshot();
37
+ if (snapshot.connection.kind !== 'connected')
38
+ return;
39
+ const config = typeof options === 'function' ? options() : options;
40
+ const derived = derive(snapshot.view, { mode: 'expert', renderEntry: undefined, renderTool: undefined, groupTool: undefined });
41
+ const next = transition(before, { working: Boolean(derived.run), question: pendingNotice(derived.rows, config.question, config.text) }, derived.rows, config.text);
42
+ before = next.state; // Fence reentrant host callbacks before delivering effects.
43
+ const { conversationId } = config;
44
+ const push = (kind, summary) => {
45
+ if (config.enabled === false || config.suppress?.())
46
+ return;
47
+ store.push({ id: `${conversationId}:${kind === 'input' ? 'input' : 'done'}`, kind, title: typeof config.title === 'function' ? config.title() : config.title, summary, conversationId });
48
+ };
49
+ if (next.started) {
50
+ store.dismiss(`${conversationId}:done`);
51
+ config.onOutcome?.(null);
52
+ }
53
+ if (next.inputChanged) {
54
+ if (next.state.question === undefined)
55
+ store.dismiss(`${conversationId}:input`);
56
+ else
57
+ push('input', next.state.question);
58
+ }
59
+ if (next.finished) {
60
+ config.onOutcome?.(next.finished.kind);
61
+ push(next.finished.kind, next.finished.summary);
62
+ }
63
+ };
64
+ const unsubscribe = controller.subscribe(observe);
65
+ observe();
66
+ return unsubscribe;
67
+ }
@@ -45,7 +45,7 @@ export declare function createSharedChat(source: ChannelSource, options: {
45
45
  canRemove: (message: ChannelMessage) => boolean;
46
46
  };
47
47
  export type SharedChatController = ReturnType<typeof createSharedChat>;
48
- /** The composer uses the existing quoted @ token grammar. Tokens carry stable member ids; picker labels show names. */
48
+ /** A member is mentioned with the chat's one `@` token grammar (`mentionToken` of `chat-text`). Tokens carry stable member ids; picker labels show names. */
49
49
  export declare function memberMention(member: Member): string;
50
50
  export { createFakeChannelSource } from './fake.js';
51
51
  export type { FakeChannelSeed } from './fake.js';
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from '@hachej/boring-files/platform';
2
2
  import { createChatOutbox } from '../chat-outbox.js';
3
+ import { mentionedPaths, mentionToken } from '../chat-text.js';
3
4
  import { ChannelRequestError } from './error.js';
4
5
  export { ChannelRequestError } from './error.js';
5
6
  import { cardActions } from './source.js';
@@ -277,13 +278,13 @@ function latestAt(messages) {
277
278
  const now = Date.now(), newest = Math.max(0, ...messages.map(message => Date.parse(message.at)).filter(Number.isFinite));
278
279
  return new Date(Math.max(now, newest + 1)).toISOString();
279
280
  }
280
- /** The composer uses the existing quoted @ token grammar. Tokens carry stable member ids; picker labels show names. */
281
+ /** A member is mentioned with the chat's one `@` token grammar (`mentionToken` of `chat-text`). Tokens carry stable member ids; picker labels show names. */
281
282
  export function memberMention(member) {
282
- return /[\s"\\]/.test(member.id) ? `@"${member.id.replace(/[\\"]/g, '\\$&')}"` : `@${member.id}`;
283
+ return mentionToken(member.id);
283
284
  }
285
+ /** Whether the text mentions the member, parsed as the composer and the agent parse mentions (`mentionedPaths` of `chat-text`). */
284
286
  function hasMemberMention(text, member) {
285
- const token = memberMention(member);
286
- return [...text.matchAll(/(^|\s)(@"(?:[^"\\]|\\.)*"|@[^\s]+)/g)].some(match => match[2] === token);
287
+ return mentionedPaths(text).includes(member.id);
287
288
  }
288
289
  export { createFakeChannelSource } from './fake.js';
289
290
  export { createChannelSocket } from './socket.js';
@@ -18,8 +18,20 @@ export interface VoiceRecording {
18
18
  /** The recording reached `maxMs` and stopped on its own. */
19
19
  readonly truncated: boolean;
20
20
  }
21
+ /** Live capture facts. PCM elapsed time and level come from kept samples, never a synthetic waveform. */
22
+ export interface VoiceCaptureProgress {
23
+ readonly elapsedMs: number;
24
+ /** Root mean square amplitude in [0, 1]; unavailable on the MediaRecorder fallback. */
25
+ readonly level: number | undefined;
26
+ readonly paused: boolean;
27
+ readonly canPause: boolean;
28
+ }
21
29
  export interface VoiceCapture {
22
30
  readonly start: () => Promise<VoiceStart>;
31
+ readonly progress: () => VoiceCaptureProgress | null;
32
+ /** PCM only: pauses/resumes appending samples. False when unavailable or already ended. */
33
+ readonly pause: () => boolean;
34
+ readonly resume: () => boolean;
23
35
  /** Ends the recording and returns it; null when nothing was recorded (never started, cancelled, or empty). */
24
36
  readonly stop: () => Promise<VoiceRecording | null>;
25
37
  /** Ends the recording, releases the microphone and discards the audio. */
@@ -213,7 +213,7 @@ export function createVoiceCapture(options = {}) {
213
213
  }
214
214
  let cancelled;
215
215
  const current = {
216
- stream, context, source: null, node: null, chunks: [], samples: 0, startedAt: null, truncated: false, cancelled: false, ended: false,
216
+ stream, context, source: null, node: null, chunks: [], samples: 0, startedAt: null, truncated: false, cancelled: false, ended: false, paused: false, level: 0,
217
217
  maxSamples: Number.isFinite(maxMs) && maxMs > 0 ? Math.floor((maxMs / 1000) * WAV_RATE) : Number.POSITIVE_INFINITY,
218
218
  cancel: () => cancelled(),
219
219
  };
@@ -251,7 +251,7 @@ export function createVoiceCapture(options = {}) {
251
251
  return refused('unsupported', error instanceof Error ? error.message : String(error));
252
252
  }
253
253
  current.node.onaudioprocess = event => {
254
- if (current.ended)
254
+ if (current.ended || current.paused)
255
255
  return;
256
256
  const input = event.inputBuffer.getChannelData(0);
257
257
  if (current.startedAt === null) {
@@ -261,6 +261,10 @@ export function createVoiceCapture(options = {}) {
261
261
  const chunk = downsample(input, context.sampleRate);
262
262
  const room = current.maxSamples - current.samples;
263
263
  const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
264
+ let squares = 0;
265
+ for (const sample of kept)
266
+ squares += sample * sample;
267
+ current.level = kept.length ? Math.min(1, Math.sqrt(squares / kept.length)) : 0;
264
268
  current.chunks.push(kept);
265
269
  current.samples += kept.length;
266
270
  if (current.samples >= current.maxSamples)
@@ -298,6 +302,17 @@ export function createVoiceCapture(options = {}) {
298
302
  }
299
303
  };
300
304
  return {
305
+ progress() {
306
+ if (wav)
307
+ return { elapsedMs: wav.samples / WAV_RATE * 1000, level: wav.level, paused: wav.paused, canPause: !wav.ended && wav.startedAt !== null };
308
+ if (!session || session.startedAt === null)
309
+ return null;
310
+ return { elapsedMs: Math.min(Math.max(0, (session.stoppedAt ?? now()) - session.startedAt), maxMs > 0 ? maxMs : Infinity), level: undefined, paused: false, canPause: false };
311
+ },
312
+ pause() { if (!wav || wav.ended || wav.startedAt === null)
313
+ return false; wav.paused = true; return true; },
314
+ resume() { if (!wav || wav.ended || wav.startedAt === null)
315
+ return false; wav.paused = false; return true; },
301
316
  async start() {
302
317
  if (session || wav || opening)
303
318
  return { kind: 'refused', code: 'failed', reason: 'Already recording.' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -59,6 +59,14 @@
59
59
  "types": "./dist/chat-view.d.ts",
60
60
  "import": "./dist/chat-view.js"
61
61
  },
62
+ "./chat-react": {
63
+ "types": "./dist/chat-react.d.ts",
64
+ "import": "./dist/chat-react.js"
65
+ },
66
+ "./chat-text": {
67
+ "types": "./dist/chat-text.d.ts",
68
+ "import": "./dist/chat-text.js"
69
+ },
62
70
  "./voice-capture": {
63
71
  "types": "./dist/voice-capture.d.ts",
64
72
  "import": "./dist/voice-capture.js"
@@ -138,7 +146,7 @@
138
146
  }
139
147
  },
140
148
  "peerDependencies": {
141
- "@hachej/boring-files": "^0.4.25",
149
+ "@hachej/boring-files": "^0.4.26",
142
150
  "@earendil-works/pi-durable": "~1.2.0",
143
151
  "react": "^19.0.0",
144
152
  "react-dom": "^19.0.0",