@hachej/boring-ui-kit 0.4.25 → 0.4.27
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.
- package/README.md +40 -3
- package/dist/chat-artifacts.d.ts +157 -0
- package/dist/chat-artifacts.js +274 -0
- package/dist/chat-dictation.d.ts +27 -0
- package/dist/chat-dictation.js +71 -0
- package/dist/chat-react-controller.d.ts +25 -0
- package/dist/chat-react-controller.js +72 -0
- package/dist/chat-react-conversations.d.ts +40 -0
- package/dist/chat-react-conversations.js +68 -0
- package/dist/chat-react-draft.d.ts +11 -0
- package/dist/chat-react-draft.js +91 -0
- package/dist/chat-react-remote.d.ts +33 -0
- package/dist/chat-react-remote.js +72 -0
- package/dist/chat-react-session.d.ts +170 -0
- package/dist/chat-react-session.js +367 -0
- package/dist/chat-react-transcript.d.ts +39 -0
- package/dist/chat-react-transcript.js +123 -0
- package/dist/chat-react.d.ts +62 -0
- package/dist/chat-react.js +95 -0
- package/dist/chat-text.d.ts +131 -0
- package/dist/chat-text.js +220 -0
- package/dist/chat-view.d.ts +2 -17
- package/dist/chat-view.js +1 -0
- package/dist/chat-voice-message.d.ts +65 -0
- package/dist/chat-voice-message.js +235 -0
- package/dist/chat-voice.d.ts +7 -0
- package/dist/chat-voice.js +4 -0
- package/dist/experience-catalog.d.ts +2 -2
- package/dist/experience-region-tree.d.ts +1 -1
- package/dist/markdown-editor-labels.d.ts +83 -0
- package/dist/markdown-editor-labels.js +75 -0
- package/dist/markdown-editor.d.ts +7 -1
- package/dist/markdown-editor.js +28 -18
- package/dist/markdown-extensions.d.ts +4 -2
- package/dist/markdown-extensions.js +2 -2
- package/dist/markdown-preserved.d.ts +5 -1
- package/dist/markdown-preserved.js +15 -4
- package/dist/markdown-proposal-diff.d.ts +3 -1
- package/dist/markdown-proposal-diff.js +6 -5
- package/dist/run-notifications.d.ts +42 -0
- package/dist/run-notifications.js +67 -0
- package/dist/shared-chat/index.d.ts +1 -1
- package/dist/shared-chat/index.js +5 -4
- package/dist/text-draft-controls.d.ts +5 -1
- package/dist/text-draft-controls.js +6 -5
- package/dist/text-draft-labels.d.ts +26 -0
- package/dist/text-draft-labels.js +47 -0
- package/dist/voice-attempt.d.ts +33 -0
- package/dist/voice-attempt.js +122 -0
- package/dist/voice-capture.d.ts +12 -0
- package/dist/voice-capture.js +17 -2
- package/package.json +14 -2
- package/r/agent-target.json +5 -9
- package/r/app-shell.json +19 -28
- package/r/board-viewer.json +8 -11
- package/r/button.json +2 -9
- package/r/diagram-viewer.json +0 -5
- package/r/dock.json +2 -2
- package/r/documents.json +13 -18
- package/r/feedback-preview.json +1 -7
- package/r/feedback.json +12 -19
- package/r/file-tree.json +4 -14
- package/r/highlights.json +4 -9
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +3 -6
- package/r/maquette-viewer.json +2 -20
- package/r/markdown-editor.json +4 -7
- package/r/mockup-viewer.json +2 -5
- package/r/pi-ambient.json +11 -19
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -27
- package/r/pi-artifacts.json +3 -4
- package/r/pi-chat-approval.json +2 -5
- package/r/pi-chat-attachments.json +2 -4
- package/r/pi-chat-markdown.json +3 -5
- package/r/pi-chat-mentions.json +2 -3
- package/r/pi-chat-pickers.json +2 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -7
- package/r/pi-chat-slash.json +3 -6
- package/r/pi-chat-subagent.json +3 -8
- package/r/pi-chat-voice.json +7 -10
- package/r/pi-chat.json +25 -53
- package/r/pi-workspace.json +4 -7
- package/r/placed-agent.json +4 -5
- package/r/provider-setup.json +2 -8
- package/r/record-viewer.json +0 -9
- package/r/registry.json +182 -403
- package/r/requests-board.json +2 -10
- package/r/shared-chat.json +10 -24
- package/r/task-list-viewer.json +3 -9
- package/r/utils.json +14 -2
- package/r/version-history.json +3 -5
- package/r/viewer-code.json +2 -3
- package/r/viewer-frame.json +9 -17
- package/r/viewer-html.json +4 -5
- package/r/viewer-image.json +3 -4
- package/r/viewer-markdown.json +6 -8
- package/r/viewer-pdf.json +5 -7
- package/r/viewers.json +6 -6
- package/r/windows.json +3 -8
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/** Opt-in UI projection over the native controller; owns no Pi execution or controller lifetime. */
|
|
3
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
4
|
+
import { artifactKey, artifactTurn, artifactVersions, createPresentAcks, follows, pendingPresents, presentPath } from './chat-view.js';
|
|
5
|
+
export { useChatDraft } from './chat-react-draft.js';
|
|
6
|
+
export { useControllerPerConversation } from './chat-react-controller.js';
|
|
7
|
+
export { chunkKeys, useRowChunks, useTranscript, useTranscriptWindow, PAGE_FIRST, PAGE_MORE, CHUNK } from './chat-react-transcript.js';
|
|
8
|
+
export { useChatPlacement, readChatPlacement, useRemoteChat } from './chat-react-remote.js';
|
|
9
|
+
export { useConversationsClient, useConversations } from './chat-react-conversations.js';
|
|
10
|
+
import { watchConversation } from './run-notifications.js';
|
|
11
|
+
export { createNotificationStore, watchConversation } from './run-notifications.js';
|
|
12
|
+
/** The foreground adapter of watchConversation, with the same connected-only transition rules and borrowed lifecycle. */
|
|
13
|
+
export function useRunNotifications(controller, store, options) {
|
|
14
|
+
const latest = useRef(options);
|
|
15
|
+
useLayoutEffect(() => { latest.current = options; });
|
|
16
|
+
useEffect(() => watchConversation(controller, store, () => latest.current), [controller, store, options.conversationId]);
|
|
17
|
+
}
|
|
18
|
+
const subscribeNothing = () => () => { };
|
|
19
|
+
/** Selects the authorized native view using the controller's own cached snapshot and subscription. Borrows its lifetime. */
|
|
20
|
+
export function useChatView(controller, select) {
|
|
21
|
+
const getView = useCallback(() => controller?.getSnapshot().view, [controller]);
|
|
22
|
+
const view = useSyncExternalStore(controller?.subscribe ?? subscribeNothing, getView, () => undefined);
|
|
23
|
+
return useMemo(() => select(view), [view, select]);
|
|
24
|
+
}
|
|
25
|
+
const identityView = (view) => view;
|
|
26
|
+
/** One version/follow/auto-open and present-ack rule for every chat surface. Mount evidence must come from the viewer. */
|
|
27
|
+
export function useArtifactFollow(options) {
|
|
28
|
+
const { controller, conversationId, detect, active, autoOpen = false } = options;
|
|
29
|
+
const view = useChatView(controller, identityView);
|
|
30
|
+
const versions = useMemo(() => artifactVersions(view, detect), [view, detect]);
|
|
31
|
+
const turn = useMemo(() => artifactTurn(view), [view]);
|
|
32
|
+
const calls = useMemo(() => pendingPresents(view), [view]);
|
|
33
|
+
const latest = useRef(options);
|
|
34
|
+
// Latest host callbacks are used only by effects and event handlers, never to derive render output.
|
|
35
|
+
latest.current = options;
|
|
36
|
+
const seen = useRef(new Map());
|
|
37
|
+
const closedInTurn = useRef(new Map());
|
|
38
|
+
const instanceId = useId();
|
|
39
|
+
// A controller/conversation switch cannot carry a mount report, delayed callback or retry into the new binding.
|
|
40
|
+
// Child layout effects may report a committed mount before this hook's layout effect; teardown then fences delayed reports.
|
|
41
|
+
const binding = useMemo(() => ({ active: true, controller, conversationId }), [controller, conversationId]);
|
|
42
|
+
const current = useCallback(() => binding.active && latest.current.controller === binding.controller && latest.current.conversationId === binding.conversationId, [binding]);
|
|
43
|
+
const acks = useMemo(() => createPresentAcks({ instanceId,
|
|
44
|
+
answer: (id, body) => current() ? latest.current.answer?.(id, body) : undefined,
|
|
45
|
+
open: path => current() && latest.current.openPresent(path),
|
|
46
|
+
}), [current, instanceId]);
|
|
47
|
+
useLayoutEffect(() => { binding.active = true; return () => { binding.active = false; }; }, [binding]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!controller || conversationId === undefined)
|
|
50
|
+
return;
|
|
51
|
+
const keys = versions.map(version => JSON.stringify([artifactKey(version), version.revision]));
|
|
52
|
+
const known = seen.current.get(conversationId);
|
|
53
|
+
if (!known) {
|
|
54
|
+
seen.current.set(conversationId, new Set(keys));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const fresh = versions.filter((_, index) => !known.has(keys[index]));
|
|
58
|
+
for (const key of keys)
|
|
59
|
+
known.add(key);
|
|
60
|
+
if (!autoOpen || !fresh.length || latest.current.unsaved?.() || latest.current.narrow?.() || closedInTurn.current.get(conversationId) === turn)
|
|
61
|
+
return;
|
|
62
|
+
latest.current.onOpen({ descriptor: fresh[0], follow: true });
|
|
63
|
+
}, [versions, controller, conversationId, turn, autoOpen]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!options.answer || conversationId === undefined)
|
|
66
|
+
return;
|
|
67
|
+
acks.consider(calls, { closedThisTurn: closedInTurn.current.get(conversationId) === turn,
|
|
68
|
+
unsaved: latest.current.unsaved?.() === true, narrow: latest.current.narrow?.() === true, autoOpen,
|
|
69
|
+
showing: path => latest.current.showing(path) });
|
|
70
|
+
}, [acks, calls, conversationId, turn, autoOpen, options.answer]);
|
|
71
|
+
const onShown = useCallback((shown) => {
|
|
72
|
+
if (!current())
|
|
73
|
+
return;
|
|
74
|
+
const report = { path: presentPath(shown.path), revision: shown.revision };
|
|
75
|
+
if (shown.hidden)
|
|
76
|
+
acks.hidden(report);
|
|
77
|
+
else
|
|
78
|
+
acks.shown(report);
|
|
79
|
+
}, [acks, current]);
|
|
80
|
+
const onFailed = useCallback((failed) => { if (current())
|
|
81
|
+
acks.failed({ ...failed, path: presentPath(failed.path) }); }, [acks, current]);
|
|
82
|
+
const recordClosed = useCallback(() => { if (conversationId !== undefined)
|
|
83
|
+
closedInTurn.current.set(conversationId, turn); }, [conversationId, turn]);
|
|
84
|
+
const open = useCallback((descriptor) => latest.current.onOpen({ descriptor, follow: follows(descriptor, versions) }), [versions]);
|
|
85
|
+
const isOpen = useCallback((descriptor) => Boolean(active && artifactKey(active.descriptor) === artifactKey(descriptor) &&
|
|
86
|
+
(active.follow ? follows(descriptor, versions) : descriptor.revision === active.descriptor.revision)), [active, versions]);
|
|
87
|
+
const known = useMemo(() => active ? versions.filter(version => artifactKey(version) === artifactKey(active.descriptor)) : [], [active, versions]);
|
|
88
|
+
const shown = active ? active.follow ? known[0] ?? active.descriptor : active.descriptor : undefined;
|
|
89
|
+
const select = useCallback((version) => {
|
|
90
|
+
if (active)
|
|
91
|
+
latest.current.onOpen(version === 'latest' ? { descriptor: known[0] ?? active.descriptor, follow: true } : { descriptor: version, follow: false });
|
|
92
|
+
}, [active, known]);
|
|
93
|
+
return { versions, turn, known, shown, open, isOpen, select, recordClosed, onShown, onFailed };
|
|
94
|
+
}
|
|
95
|
+
export { useChatSession } from './chat-react-session.js';
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
/** The presentation label key for a microphone refusal; labels remain host-owned. */
|
|
123
|
+
export declare const voiceRefusalLabel: {
|
|
124
|
+
readonly denied: "voiceDenied";
|
|
125
|
+
readonly 'no-microphone': "voiceNoMicrophone";
|
|
126
|
+
readonly busy: "voiceBusy";
|
|
127
|
+
readonly insecure: "voiceInsecure";
|
|
128
|
+
readonly unsupported: "voiceUnsupported";
|
|
129
|
+
readonly failed: "voiceStartFailed";
|
|
130
|
+
};
|
|
131
|
+
//# sourceMappingURL=chat-text.d.ts.map
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
}
|
|
217
|
+
/** The presentation label key for a microphone refusal; labels remain host-owned. */
|
|
218
|
+
export const voiceRefusalLabel = {
|
|
219
|
+
denied: 'voiceDenied', 'no-microphone': 'voiceNoMicrophone', busy: 'voiceBusy', insecure: 'voiceInsecure', unsupported: 'voiceUnsupported', failed: 'voiceStartFailed',
|
|
220
|
+
};
|
package/dist/chat-view.d.ts
CHANGED
|
@@ -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,65 @@
|
|
|
1
|
+
import type { ChatController } from './native-chat.js';
|
|
2
|
+
import type { Dictation, DictationLabels } from './chat-dictation.js';
|
|
3
|
+
export type VoicePhase = 'starting' | 'recording' | 'processing' | 'transcribe-failed' | 'upload-failed' | 'sending' | 'unconfirmed' | 'blocked';
|
|
4
|
+
export interface PendingVoice {
|
|
5
|
+
readonly id: number;
|
|
6
|
+
readonly path: string | undefined;
|
|
7
|
+
readonly phase: VoicePhase;
|
|
8
|
+
readonly seconds: number;
|
|
9
|
+
readonly retry: () => void;
|
|
10
|
+
readonly audioOnly: (() => void) | undefined;
|
|
11
|
+
readonly discard: () => void;
|
|
12
|
+
}
|
|
13
|
+
export interface VoiceMessageHandle {
|
|
14
|
+
readonly canStart: boolean;
|
|
15
|
+
readonly phase: VoicePhase | 'idle';
|
|
16
|
+
readonly seconds: number;
|
|
17
|
+
readonly captureSequence: number;
|
|
18
|
+
readonly levels?: readonly number[];
|
|
19
|
+
readonly paused?: boolean;
|
|
20
|
+
readonly pause?: (() => void) | undefined;
|
|
21
|
+
readonly resume?: (() => void) | undefined;
|
|
22
|
+
readonly problem: string | undefined;
|
|
23
|
+
readonly pending: readonly PendingVoice[];
|
|
24
|
+
readonly start: () => void;
|
|
25
|
+
readonly finish: () => void;
|
|
26
|
+
readonly cancel: () => void;
|
|
27
|
+
}
|
|
28
|
+
export interface VoiceMessageConfig {
|
|
29
|
+
readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;
|
|
30
|
+
readonly maxMs?: number;
|
|
31
|
+
readonly upload?: (files: File[], signal: AbortSignal) => Promise<readonly {
|
|
32
|
+
readonly path?: string;
|
|
33
|
+
}[]>;
|
|
34
|
+
}
|
|
35
|
+
export interface VoiceRecorderOptions {
|
|
36
|
+
readonly controller: ChatController | undefined;
|
|
37
|
+
readonly config: VoiceMessageConfig | undefined;
|
|
38
|
+
readonly attachments?: {
|
|
39
|
+
readonly upload: NonNullable<VoiceMessageConfig['upload']>;
|
|
40
|
+
} | undefined;
|
|
41
|
+
readonly labels: DictationLabels;
|
|
42
|
+
readonly admitted?: (() => void) | undefined;
|
|
43
|
+
readonly activeController?: {
|
|
44
|
+
readonly current: ChatController;
|
|
45
|
+
} | undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Local admission preparation only. Native send/reconcile owns the submission; uploaded bytes stay in the host workspace. */
|
|
48
|
+
export declare function useVoiceMessage({ controller, config, attachments, labels, admitted, activeController }: VoiceRecorderOptions): VoiceMessageHandle;
|
|
49
|
+
export interface ChatVoiceOptions {
|
|
50
|
+
readonly controller: ChatController | undefined;
|
|
51
|
+
readonly voice: (VoiceMessageConfig & {
|
|
52
|
+
readonly send?: 'auto' | 'draft' | 'message';
|
|
53
|
+
}) | undefined;
|
|
54
|
+
readonly attachments?: VoiceRecorderOptions['attachments'];
|
|
55
|
+
readonly labels: DictationLabels;
|
|
56
|
+
readonly onDone: (outcome: 'sent' | 'drafted') => void;
|
|
57
|
+
}
|
|
58
|
+
export interface ChatVoiceHandle extends Dictation {
|
|
59
|
+
readonly pending?: readonly PendingVoice[];
|
|
60
|
+
readonly captureSequence?: number;
|
|
61
|
+
readonly canStart?: boolean;
|
|
62
|
+
}
|
|
63
|
+
/** Selects retained voice messages or text-only dictation without putting send/draft policy in copied shells. */
|
|
64
|
+
export declare function useChatVoice({ controller, voice, attachments, labels, onDone }: ChatVoiceOptions): ChatVoiceHandle;
|
|
65
|
+
//# sourceMappingURL=chat-voice-message.d.ts.map
|