threadroom-pi 0.1.0-beta.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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/extensions/README.md +15 -0
- package/extensions/index.ts +280 -0
- package/extensions/native/index.ts +614 -0
- package/extensions/native/presentation.ts +30 -0
- package/extensions/native/receipt.ts +25 -0
- package/extensions/native/ui.ts +167 -0
- package/extensions/presentation/renderers.ts +185 -0
- package/extensions/questions/README.md +41 -0
- package/extensions/questions/compose.ts +82 -0
- package/extensions/questions/external-editor.ts +24 -0
- package/extensions/questions/host.ts +415 -0
- package/extensions/questions/index.ts +6 -0
- package/extensions/questions/model.ts +175 -0
- package/extensions/questions/stream.ts +299 -0
- package/extensions/questions/text.ts +10 -0
- package/extensions/questions/tool.ts +309 -0
- package/extensions/questions/types.ts +40 -0
- package/extensions/questions/view.ts +221 -0
- package/node_modules/threadroom-service/README.md +73 -0
- package/node_modules/threadroom-service/bin/threadroom-service.js +9 -0
- package/node_modules/threadroom-service/dist/public/app.js +349 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-bloom.svg +17 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-drift.svg +16 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-prowler.svg +15 -0
- package/node_modules/threadroom-service/dist/public/client.js +30 -0
- package/node_modules/threadroom-service/dist/public/index.html +54 -0
- package/node_modules/threadroom-service/dist/public/presentations.js +194 -0
- package/node_modules/threadroom-service/dist/public/routes.js +15 -0
- package/node_modules/threadroom-service/dist/public/styles.css +263 -0
- package/node_modules/threadroom-service/dist/src/live.js +170 -0
- package/node_modules/threadroom-service/dist/src/main.js +33 -0
- package/node_modules/threadroom-service/dist/src/presentations.js +116 -0
- package/node_modules/threadroom-service/dist/src/server.js +143 -0
- package/node_modules/threadroom-service/dist/src/site.js +53 -0
- package/node_modules/threadroom-service/dist/src/store.js +459 -0
- package/node_modules/threadroom-service/dist/src/ui-main.js +14 -0
- package/node_modules/threadroom-service/lib/cli.js +188 -0
- package/node_modules/threadroom-service/lib/ensure.js +157 -0
- package/node_modules/threadroom-service/lib/paths.js +19 -0
- package/node_modules/threadroom-service/package.json +19 -0
- package/package.json +50 -0
- package/scripts/stage-service.js +32 -0
- package/scripts/verify-packed.js +85 -0
- package/scripts/verify-release.js +79 -0
- package/src/client.js +135 -0
- package/src/config.js +57 -0
- package/src/http-transport.js +44 -0
- package/src/participation.js +211 -0
- package/src/service-runtime.js +43 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
2
|
+
import { QuestionModel } from './model.ts';
|
|
3
|
+
import { QuestionView } from './view.ts';
|
|
4
|
+
import type { QuestionGroup } from './types.ts';
|
|
5
|
+
import { editTextOutsidePi } from './external-editor.ts';
|
|
6
|
+
|
|
7
|
+
const registryKey = Symbol.for('pi.private-question.focus.v1');
|
|
8
|
+
const noReturn = Symbol('pi.private-question.no-return');
|
|
9
|
+
const owners: WeakMap<object, { handoff(data: string, fallback?: any, modalReturn?: any): false | { origin?: any } }> = (globalThis as any)[registryKey] ??= new WeakMap();
|
|
10
|
+
function mountedComponents(tui: any) {
|
|
11
|
+
const seen = new Set<any>(), queue = [...(tui?.children || [])];
|
|
12
|
+
while (queue.length) { const child = queue.pop(); if (!child || seen.has(child)) continue; seen.add(child); if (Array.isArray(child.children)) queue.push(...child.children); }
|
|
13
|
+
return seen;
|
|
14
|
+
}
|
|
15
|
+
function coreEditor(context: any) {
|
|
16
|
+
if (typeof context?.ui?.getCoreEditor !== 'function') return undefined;
|
|
17
|
+
try {
|
|
18
|
+
const editor = context.ui.getCoreEditor();
|
|
19
|
+
return editor && typeof editor.handleInput === 'function' ? editor : undefined;
|
|
20
|
+
} catch { return undefined; }
|
|
21
|
+
}
|
|
22
|
+
function mountedCoreEditor(context: any, tui: any) {
|
|
23
|
+
const editor = coreEditor(context);
|
|
24
|
+
return editor && mountedComponents(tui).has(editor) ? editor : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** Stock interactive hosts need the public widget capability, not a patched SDK.
|
|
27
|
+
* The widget factory validates the TUI focus boundary when it mounts. */
|
|
28
|
+
export function supportsQuestionHost(context: any) { return typeof context?.ui?.setWidget === 'function'; }
|
|
29
|
+
export type QuestionHostOptions = {
|
|
30
|
+
/** Optional older collapse/reentry route. It is independent of focus toggling. */
|
|
31
|
+
collapseKey?: string | false;
|
|
32
|
+
/** Toggle between the selected question and its previous input. `false` keeps focus local. */
|
|
33
|
+
focusToggleKey?: string | false;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A session-owned editor loan, not an overlay or replacement editor.
|
|
37
|
+
* Mount disposal retires focus; scope disposal also detaches outstanding groups. */
|
|
38
|
+
export function createQuestionHost(context: any, options: QuestionHostOptions = {}) {
|
|
39
|
+
let tui: any, palette: any, view: QuestionView | undefined, mounted = false, disposed = false, foreign = false;
|
|
40
|
+
let inspect = false, offset = 0, page = 1, shown: string | undefined, scheduled = false;
|
|
41
|
+
let chat = false, chatFrom: string | undefined, origin: any;
|
|
42
|
+
const collapsed = new Set<string>(), widgetKey = 'private-question-surface';
|
|
43
|
+
// An editor loan is safe only when raw input can reclaim the focus-toggle key.
|
|
44
|
+
// Legacy terminals report several native keys as Ctrl chords, so those
|
|
45
|
+
// spellings cannot safely become global shortcuts.
|
|
46
|
+
const hasTerminalInput = typeof context.ui.onTerminalInput === 'function';
|
|
47
|
+
const shortcutIdentity = (value: string) => {
|
|
48
|
+
const parts = value.toLowerCase().split('+').map((part) => part.trim());
|
|
49
|
+
const authoredKey = parts.pop();
|
|
50
|
+
const keyAliases: Record<string, string> = { esc: 'escape', return: 'enter' };
|
|
51
|
+
const key = authoredKey && (keyAliases[authoredKey] || authoredKey);
|
|
52
|
+
const order: Record<string, number> = { ctrl: 0, alt: 1, shift: 2, super: 3 };
|
|
53
|
+
if (!key || parts.some((part) => !Object.prototype.hasOwnProperty.call(order, part)) || new Set(parts).size !== parts.length) return false;
|
|
54
|
+
return [...parts.sort((left, right) => order[left] - order[right]), key].join('+');
|
|
55
|
+
};
|
|
56
|
+
const safeShortcut = (value: unknown) => {
|
|
57
|
+
if (typeof value !== 'string' || !value.trim()) return false;
|
|
58
|
+
const normalized = shortcutIdentity(value);
|
|
59
|
+
// Ctrl+H/I/J/M/[ are indistinguishable from Backspace, Tab, line feed,
|
|
60
|
+
// Enter and Escape respectively on legacy terminal input.
|
|
61
|
+
const nativeAliases = new Set(['backspace', 'tab', 'enter', 'escape', 'ctrl+h', 'ctrl+i', 'ctrl+j', 'ctrl+m', 'ctrl+[']);
|
|
62
|
+
return normalized && !nativeAliases.has(normalized) ? normalized : false;
|
|
63
|
+
};
|
|
64
|
+
const configuredCollapseKey = hasTerminalInput ? safeShortcut(options.collapseKey === undefined ? 'ctrl+]' : options.collapseKey) : false;
|
|
65
|
+
const focusToggleKey = hasTerminalInput ? safeShortcut(options.focusToggleKey === undefined ? 'shift+tab' : options.focusToggleKey) : false;
|
|
66
|
+
// Focus toggling is the primary route. If both options name the same shortcut,
|
|
67
|
+
// keep that route reachable instead of letting the earlier collapse branch win.
|
|
68
|
+
const collapseKey = configuredCollapseKey && focusToggleKey
|
|
69
|
+
&& configuredCollapseKey === focusToggleKey ? false : configuredCollapseKey;
|
|
70
|
+
const model = new QuestionModel(() => { if (!disposed) { tui?.requestRender(); schedule(); } });
|
|
71
|
+
const hasBlocker = () => model.tabs().some((tab) => tab.mode === 'blocking');
|
|
72
|
+
const tabKey = (tab: { key: string; incarnation: number }) => JSON.stringify([tab.key, tab.incarnation]);
|
|
73
|
+
const displayKey = () => { const tab = model.current()?.tab; return tab && tabKey(tab); };
|
|
74
|
+
// A pending blocker makes the whole shared question surface modal. Async
|
|
75
|
+
// tabs remain usable inside it, but a previously collapsed tab cannot become
|
|
76
|
+
// an escape route to Chat.
|
|
77
|
+
const isCollapsed = () => { const key = displayKey(); return !hasBlocker() && !!key && collapsed.has(key); };
|
|
78
|
+
const isHomeLoan = () => chat || isCollapsed() || !!model.current()?.paused;
|
|
79
|
+
let modal = false, modalClaimedInput = false, modalPendingClaim = false, modalOrigin: any, modalPaneTab: string | undefined;
|
|
80
|
+
let deferredModalReturn: { origin: any } | undefined, settledModalOrigin: any, explicitQuestionLoan = false, explicitQuestionOrigin: any;
|
|
81
|
+
// Optional SDK identity distinguishes the core editor from foreign prompts.
|
|
82
|
+
// Stock hosts retain the exact input a blocker or explicit action loans;
|
|
83
|
+
// that origin is not evidence that a later replacement is also Chat.
|
|
84
|
+
const mountedInput = (input: any) => input && typeof input.handleInput === 'function' && mountedComponents(tui).has(input) ? input : undefined;
|
|
85
|
+
const returnInput = () => coreEditor(context) ? mountedCoreEditor(context, tui) : mountedInput(origin);
|
|
86
|
+
const focusEditor = () => focusToggleKey && returnInput();
|
|
87
|
+
function blockerInputEligible(focus: any, entering = false) {
|
|
88
|
+
if (focus === component) return true;
|
|
89
|
+
const core = coreEditor(context);
|
|
90
|
+
if (core) return focus === mountedCoreEditor(context, tui);
|
|
91
|
+
const retainedOrigin = modal ? modalOrigin : origin, retained = mountedInput(retainedOrigin);
|
|
92
|
+
return !!mountedInput(focus) && (entering || modalPendingClaim || (!!retainedOrigin && focus === retained));
|
|
93
|
+
}
|
|
94
|
+
function release(clear = false) {
|
|
95
|
+
if (tui?.getFocusedComponent() !== component) return;
|
|
96
|
+
const modalReturn = modal, target = modalReturn ? mountedInput(modalOrigin) : returnInput();
|
|
97
|
+
if (target) tui.setFocus(target); else if (clear || modalReturn || !coreEditor(context)) tui.setFocus(null);
|
|
98
|
+
}
|
|
99
|
+
function finishModal(defer = false) {
|
|
100
|
+
if (!modal) return false;
|
|
101
|
+
const claimed = modalClaimedInput, origin = modalOrigin, target = mountedInput(origin);
|
|
102
|
+
modal = modalClaimedInput = modalPendingClaim = false; modalOrigin = undefined; modalPaneTab = undefined;
|
|
103
|
+
if (!claimed) return false;
|
|
104
|
+
settledModalOrigin = origin; explicitQuestionLoan = false; explicitQuestionOrigin = undefined;
|
|
105
|
+
if (tui?.getFocusedComponent() === component) { tui.setFocus(target || null); return true; }
|
|
106
|
+
if (defer) deferredModalReturn = { origin };
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function unwindDeferredModalReturn(data?: string) {
|
|
110
|
+
if (!deferredModalReturn || foreign || hasBlocker() || tui?.getFocusedComponent() !== component) return false;
|
|
111
|
+
const origin = deferredModalReturn.origin, target = mountedInput(origin); deferredModalReturn = undefined;
|
|
112
|
+
settledModalOrigin = origin; explicitQuestionLoan = false; explicitQuestionOrigin = undefined;
|
|
113
|
+
tui.setFocus(target || null); if (data !== undefined) target?.handleInput(data); return true;
|
|
114
|
+
}
|
|
115
|
+
function unwindSettledModalReturn(data?: string) {
|
|
116
|
+
if (!settledModalOrigin || explicitQuestionLoan || foreign || hasBlocker() || tui?.getFocusedComponent() !== component) return false;
|
|
117
|
+
const target = mountedInput(settledModalOrigin);
|
|
118
|
+
tui.setFocus(target || null); if (data !== undefined) target?.handleInput(data); return true;
|
|
119
|
+
}
|
|
120
|
+
function availablePaneTab(preferred?: string, includePaused = false) {
|
|
121
|
+
const available = (includePaused ? model.tabs() : model.unpausedTabs()).filter((tab) => tab.mode === 'async');
|
|
122
|
+
const visible = available.filter((tab) => !collapsed.has(tabKey(tab)));
|
|
123
|
+
return visible.find((tab) => tabKey(tab) === preferred) || visible[0]
|
|
124
|
+
|| available.find((tab) => tabKey(tab) === preferred) || available[0];
|
|
125
|
+
}
|
|
126
|
+
function continueInQuestions(tab: { key: string; groupId: string; questionId?: string; incarnation: number }) {
|
|
127
|
+
const loan = modalClaimedInput ? modalOrigin : explicitQuestionOrigin || returnInput();
|
|
128
|
+
modal = modalClaimedInput = modalPendingClaim = false; modalOrigin = undefined; modalPaneTab = undefined;
|
|
129
|
+
deferredModalReturn = undefined; settledModalOrigin = undefined;
|
|
130
|
+
explicitQuestionLoan = true; explicitQuestionOrigin = loan;
|
|
131
|
+
chat = false; chatFrom = undefined; collapsed.delete(tabKey(tab));
|
|
132
|
+
// Selecting also resumes a paused async group; successful required
|
|
133
|
+
// completion deliberately hands the still-owned pane to that question.
|
|
134
|
+
model.select(tab.groupId, tab.questionId);
|
|
135
|
+
}
|
|
136
|
+
function focusQuestion(explicit = false) {
|
|
137
|
+
const target = model.current()?.tab;
|
|
138
|
+
if (!target || foreign) return false;
|
|
139
|
+
const focus = tui.getFocusedComponent();
|
|
140
|
+
if (focus !== component) {
|
|
141
|
+
if (!explicit) return false;
|
|
142
|
+
const core = coreEditor(context);
|
|
143
|
+
if (core ? focus !== mountedCoreEditor(context, tui) : !mountedInput(focus)) return false;
|
|
144
|
+
if (!core) origin = focus;
|
|
145
|
+
if (modal && hasBlocker()) {
|
|
146
|
+
modalOrigin = focus; modalClaimedInput = true; modalPendingClaim = false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (explicit) {
|
|
150
|
+
// Explicit intent supersedes an older deferred return even when an
|
|
151
|
+
// overlay has already restored the pane before /asks runs.
|
|
152
|
+
explicitQuestionOrigin = focus === component
|
|
153
|
+
? deferredModalReturn?.origin || settledModalOrigin || (coreEditor(context) ? mountedCoreEditor(context, tui) : origin)
|
|
154
|
+
: focus;
|
|
155
|
+
deferredModalReturn = undefined; settledModalOrigin = undefined; explicitQuestionLoan = true;
|
|
156
|
+
}
|
|
157
|
+
collapsed.delete(JSON.stringify([target.key, target.incarnation])); chat = false; chatFrom = undefined;
|
|
158
|
+
model.select(target.groupId, target.questionId); tui.setFocus(component); tui.requestRender(); return true;
|
|
159
|
+
}
|
|
160
|
+
function enterChat() {
|
|
161
|
+
if (hasBlocker()) return false;
|
|
162
|
+
const target = focusEditor();
|
|
163
|
+
if (!target) return false;
|
|
164
|
+
chat = true; chatFrom = displayKey(); tui.setFocus(target); tui.requestRender(); return true;
|
|
165
|
+
}
|
|
166
|
+
const stopInput = hasTerminalInput ? context.ui.onTerminalInput((data: string) => {
|
|
167
|
+
if (disposed || foreign || !mounted || !model.current()) return;
|
|
168
|
+
const focus = tui.getFocusedComponent();
|
|
169
|
+
if (collapseKey && matchesKey(data, collapseKey as any)) {
|
|
170
|
+
if (hasBlocker()) {
|
|
171
|
+
if (!blockerInputEligible(focus, !modal)) return;
|
|
172
|
+
if (!isKeyRelease(data) && !isKeyRepeat(data)) reconcile();
|
|
173
|
+
return { consume: true };
|
|
174
|
+
}
|
|
175
|
+
const ordinaryEditor = returnInput();
|
|
176
|
+
if (!ordinaryEditor || (focus !== component && !(focus === ordinaryEditor && isHomeLoan()))) return;
|
|
177
|
+
if (!isKeyRelease(data) && !isKeyRepeat(data)) {
|
|
178
|
+
if (focus === component) toggleCollapse();
|
|
179
|
+
else focusQuestion(true);
|
|
180
|
+
}
|
|
181
|
+
return { consume: true };
|
|
182
|
+
}
|
|
183
|
+
if (!focusToggleKey || !matchesKey(data, focusToggleKey as any)) return;
|
|
184
|
+
if (hasBlocker()) {
|
|
185
|
+
if (!blockerInputEligible(focus, !modal)) return;
|
|
186
|
+
if (!isKeyRelease(data) && !isKeyRepeat(data)) reconcile();
|
|
187
|
+
return { consume: true };
|
|
188
|
+
}
|
|
189
|
+
const core = coreEditor(context), editor = focusEditor();
|
|
190
|
+
const eligible = focus === component ? !!editor
|
|
191
|
+
: core ? focus === editor : !!mountedInput(focus);
|
|
192
|
+
if (!eligible) return;
|
|
193
|
+
if (!isKeyRelease(data) && !isKeyRepeat(data)) {
|
|
194
|
+
if (focus === component) enterChat(); else focusQuestion(true);
|
|
195
|
+
}
|
|
196
|
+
return { consume: true };
|
|
197
|
+
}) : undefined;
|
|
198
|
+
function reconcile() {
|
|
199
|
+
if (disposed || !mounted || !tui) return;
|
|
200
|
+
let current = model.current();
|
|
201
|
+
const blocking = hasBlocker();
|
|
202
|
+
if (!current) { if (!finishModal(true) && !unwindDeferredModalReturn() && !unwindSettledModalReturn()) release(); return; }
|
|
203
|
+
if (foreign) { release(); return; }
|
|
204
|
+
if (!blocking && !modal && (unwindDeferredModalReturn() || unwindSettledModalReturn())) return;
|
|
205
|
+
const initialFocus = tui.getFocusedComponent(), enteringModal = blocking && !modal;
|
|
206
|
+
if (enteringModal) {
|
|
207
|
+
modal = true;
|
|
208
|
+
modalPendingClaim = initialFocus !== component;
|
|
209
|
+
modalClaimedInput = false;
|
|
210
|
+
modalOrigin = modalPendingClaim ? undefined : returnInput();
|
|
211
|
+
} else if (!blocking && modal) {
|
|
212
|
+
const paneOwned = !modalClaimedInput && !modalPendingClaim, paneTab = modalPaneTab;
|
|
213
|
+
const completion = model.takeBlockingCompletion(), answered = completion === 'answered';
|
|
214
|
+
const available = availablePaneTab(paneTab, answered);
|
|
215
|
+
if (answered && available) {
|
|
216
|
+
continueInQuestions(available); current = model.current()!;
|
|
217
|
+
} else {
|
|
218
|
+
const restored = finishModal(true);
|
|
219
|
+
chat = false; chatFrom = undefined;
|
|
220
|
+
if (restored) return;
|
|
221
|
+
if (paneOwned && available && tabKey(current.tab) !== tabKey(available)) {
|
|
222
|
+
model.select(available.groupId, available.questionId); current = model.current()!;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (blocking && current.paused) {
|
|
227
|
+
const required = model.tabs().find((tab) => tab.mode === 'blocking');
|
|
228
|
+
if (required) { model.select(required.groupId, required.questionId); current = model.current()!; }
|
|
229
|
+
}
|
|
230
|
+
const focus = tui.getFocusedComponent();
|
|
231
|
+
if (blocking) {
|
|
232
|
+
chat = false; chatFrom = undefined;
|
|
233
|
+
collapsed.delete(JSON.stringify([current.tab.key, current.tab.incarnation]));
|
|
234
|
+
if (focus !== component) {
|
|
235
|
+
// Public core identity distinguishes Chat from foreign prompts. Stock
|
|
236
|
+
// hosts retain the exact input captured when the modal claim succeeds,
|
|
237
|
+
// same protection after the loan exists. Normal prompt lifecycle still
|
|
238
|
+
// uses suspend() for the initial ambiguous stock boundary.
|
|
239
|
+
if (!blockerInputEligible(focus, enteringModal)) return;
|
|
240
|
+
if (modalPendingClaim) {
|
|
241
|
+
modalOrigin = mountedInput(focus); modalClaimedInput = true; modalPendingClaim = false; deferredModalReturn = undefined; settledModalOrigin = undefined; explicitQuestionLoan = false; explicitQuestionOrigin = undefined;
|
|
242
|
+
if (!coreEditor(context)) origin = modalOrigin;
|
|
243
|
+
} else if (coreEditor(context) && focus !== modalOrigin) {
|
|
244
|
+
// An authoritative replacement actually reclaimed as Chat is a fresh
|
|
245
|
+
// modal loan, including when this blocker began as pane-owned.
|
|
246
|
+
modalOrigin = mountedInput(focus); modalClaimedInput = true; deferredModalReturn = undefined; settledModalOrigin = undefined; explicitQuestionLoan = false; explicitQuestionOrigin = undefined;
|
|
247
|
+
}
|
|
248
|
+
tui.setFocus(component);
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (current.paused || (isCollapsed() && stopInput)) { release(); return; }
|
|
253
|
+
if (chat) {
|
|
254
|
+
if (focus === focusEditor()) return;
|
|
255
|
+
if (focus !== component) return; // A foreign prompt owns its own focus lifetime.
|
|
256
|
+
chat = false; chatFrom = undefined;
|
|
257
|
+
}
|
|
258
|
+
// Async-only arrivals are passive even when the SDK identifies its core
|
|
259
|
+
// editor. Explicit /asks or the focus-toggle key owns activation.
|
|
260
|
+
}
|
|
261
|
+
function schedule() {
|
|
262
|
+
if (scheduled || disposed) return; scheduled = true;
|
|
263
|
+
queueMicrotask(() => {
|
|
264
|
+
scheduled = false; if (disposed) return;
|
|
265
|
+
if (!model.tabs().length && mounted) {
|
|
266
|
+
model.takeBlockingCompletion();
|
|
267
|
+
explicitQuestionLoan = false; explicitQuestionOrigin = undefined;
|
|
268
|
+
if (!finishModal(true) && !unwindDeferredModalReturn() && !unwindSettledModalReturn()) release();
|
|
269
|
+
context.ui.setWidget(widgetKey, undefined);
|
|
270
|
+
} else reconcile();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
function toggleCollapse() {
|
|
274
|
+
const current = model.current(); if (!current || hasBlocker()) return;
|
|
275
|
+
const key = displayKey()!; if (isCollapsed()) collapsed.delete(key); else collapsed.add(key);
|
|
276
|
+
chat = false; chatFrom = undefined; inspect = false; offset = 0; reconcile(); tui.requestRender();
|
|
277
|
+
}
|
|
278
|
+
const owner = { handoff(data: string, fallback?: any, inheritedModalReturn?: any) {
|
|
279
|
+
if (disposed || foreign) return false;
|
|
280
|
+
// An outgoing private pane can transfer its existing loan, not invent a
|
|
281
|
+
// core role for the previous input or discover an arbitrary replacement.
|
|
282
|
+
if (!coreEditor(context) && !origin) origin = mountedInput(fallback);
|
|
283
|
+
if (inheritedModalReturn && !modalClaimedInput && !deferredModalReturn && !settledModalOrigin && !explicitQuestionLoan) {
|
|
284
|
+
if (hasBlocker()) {
|
|
285
|
+
modal = true; modalOrigin = inheritedModalReturn; modalClaimedInput = true; modalPendingClaim = false;
|
|
286
|
+
} else deferredModalReturn = { origin: inheritedModalReturn };
|
|
287
|
+
}
|
|
288
|
+
if (!hasBlocker() && deferredModalReturn) {
|
|
289
|
+
tui.setFocus(component); component.handleInput(data); return { origin: settledModalOrigin };
|
|
290
|
+
}
|
|
291
|
+
const settled = mountedInput(settledModalOrigin);
|
|
292
|
+
if (!hasBlocker() && !explicitQuestionLoan && settledModalOrigin) {
|
|
293
|
+
tui.setFocus(settled || null); settled?.handleInput(data); return { origin: settledModalOrigin };
|
|
294
|
+
}
|
|
295
|
+
const questionRoute = !!model.current() && !foreign && !isHomeLoan();
|
|
296
|
+
if (questionRoute) {
|
|
297
|
+
tui.setFocus(component); component.handleInput(data);
|
|
298
|
+
const after = tui.getFocusedComponent();
|
|
299
|
+
if (after !== component) return { origin: mountedInput(after) || noReturn };
|
|
300
|
+
return { origin: modalClaimedInput ? modalOrigin : deferredModalReturn?.origin || settledModalOrigin || (explicitQuestionLoan ? explicitQuestionOrigin : undefined) };
|
|
301
|
+
}
|
|
302
|
+
const home = returnInput(); tui.setFocus(home || null); home?.handleInput(data); return { origin: home || noReturn };
|
|
303
|
+
} };
|
|
304
|
+
const component = {
|
|
305
|
+
focused: false,
|
|
306
|
+
render(width: number) {
|
|
307
|
+
reconcile(); if (!view || !model.current()) return [];
|
|
308
|
+
const focus = tui.getFocusedComponent(), core = coreEditor(context), ordinaryEditor = returnInput(), blocking = hasBlocker();
|
|
309
|
+
view.focused = !foreign && focus === component;
|
|
310
|
+
view.suspended = foreign || blocking && focus !== component && !blockerInputEligible(focus);
|
|
311
|
+
view.inputIsCore = !!core;
|
|
312
|
+
const mayEnter = !foreign && (view.focused || (core ? !!ordinaryEditor && focus === ordinaryEditor : !!mountedInput(focus)));
|
|
313
|
+
const mayToggle = !blocking && !foreign && (view.focused ? !!ordinaryEditor : core ? !!ordinaryEditor && focus === ordinaryEditor : !!mountedInput(focus));
|
|
314
|
+
const mayCollapse = !blocking && !foreign && !!ordinaryEditor && (view.focused || focus === ordinaryEditor && isHomeLoan());
|
|
315
|
+
view.entryAvailable = mayEnter;
|
|
316
|
+
view.focusToggleKey = mayToggle ? focusToggleKey || undefined : undefined;
|
|
317
|
+
view.chatReturnKey = mayCollapse ? collapseKey || undefined : undefined;
|
|
318
|
+
view.chatFocused = !!core && !!ordinaryEditor && focus === ordinaryEditor;
|
|
319
|
+
const current = model.current()!; if (shown !== displayKey()) { shown = displayKey(); inspect = false; offset = 0; }
|
|
320
|
+
const framed = width >= 4, innerWidth = framed ? width - 2 : width;
|
|
321
|
+
const frame = view.frame(innerWidth, inspect), budget = Math.max(4, Math.min(20, tui.terminal.rows - 12));
|
|
322
|
+
const box = (rows: string[]) => framed ? rows.map((line, index) => {
|
|
323
|
+
const top = index === 0, bottom = index === rows.length - 1, text = truncateToWidth(line, innerWidth, '');
|
|
324
|
+
const role = blocking ? 'warning' : view.focused ? 'borderAccent' : 'borderMuted';
|
|
325
|
+
const horizontal = blocking || view.focused ? '─' : '┈', vertical = blocking || view.focused ? '│' : '┊';
|
|
326
|
+
const edge = (value: string) => palette.fg(role, value);
|
|
327
|
+
return edge(top ? '╭' : bottom ? '╰' : vertical) + text + (top || bottom ? edge(horizontal.repeat(Math.max(0, innerWidth - visibleWidth(text)))) : ' '.repeat(Math.max(0, innerWidth - visibleWidth(text)))) + edge(top ? '╮' : bottom ? '╯' : vertical);
|
|
328
|
+
}) : rows;
|
|
329
|
+
if (isCollapsed()) {
|
|
330
|
+
const reentry = view.chatReturnKey || view.focusToggleKey || (view.entryAvailable ? '/asks' : undefined);
|
|
331
|
+
return box([frame.header[0], truncateToWidth(`Collapsed${reentry ? ` · ${reentry} reopens` : ''} · draft retained`, innerWidth, ''), frame.footer]);
|
|
332
|
+
}
|
|
333
|
+
page = Math.max(1, budget - frame.header.length - 1); const last = Math.max(0, frame.lines.length - page);
|
|
334
|
+
offset = inspect ? Math.max(0, Math.min(last, offset)) : Math.max(0, Math.min(last, frame.focus[0] - Math.floor(page / 2)));
|
|
335
|
+
return box([...frame.header, ...frame.lines.slice(offset, offset + page), frame.footer].slice(0, budget));
|
|
336
|
+
},
|
|
337
|
+
handleInput(data: string) {
|
|
338
|
+
if (disposed || !mounted || !model.current()) {
|
|
339
|
+
if (tui?.getFocusedComponent() === component) {
|
|
340
|
+
const successor = owners.get(tui);
|
|
341
|
+
if (successor && successor !== owner) {
|
|
342
|
+
const inheritedModalReturn = deferredModalReturn?.origin || settledModalOrigin;
|
|
343
|
+
const editor = inheritedModalReturn ? mountedInput(inheritedModalReturn) : returnInput();
|
|
344
|
+
// Keep the successor's resolved exact route as a tombstone. If the
|
|
345
|
+
// live owner later retires, repeated stale preFocus restoration
|
|
346
|
+
// must neither revive older debt nor fall through to a new core.
|
|
347
|
+
const receipt = successor.handoff(data, editor, inheritedModalReturn);
|
|
348
|
+
if (receipt) {
|
|
349
|
+
deferredModalReturn = undefined; settledModalOrigin = receipt.origin === undefined ? noReturn : receipt.origin;
|
|
350
|
+
explicitQuestionLoan = false; explicitQuestionOrigin = undefined;
|
|
351
|
+
}
|
|
352
|
+
tui.requestRender(); return;
|
|
353
|
+
}
|
|
354
|
+
if (unwindDeferredModalReturn(data) || unwindSettledModalReturn(data)) { tui.requestRender(); return; }
|
|
355
|
+
if (foreign) return;
|
|
356
|
+
const editor = returnInput();
|
|
357
|
+
if (editor) { tui.setFocus(editor); editor.handleInput(data); }
|
|
358
|
+
else tui.setFocus(null);
|
|
359
|
+
tui.requestRender();
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (unwindDeferredModalReturn(data) || unwindSettledModalReturn(data)) { tui.requestRender(); return; }
|
|
364
|
+
if (foreign || tui.getFocusedComponent() !== component || isKeyRelease(data)) return;
|
|
365
|
+
if (focusToggleKey && matchesKey(data, focusToggleKey as any)) return; // Raw hook owns supported toggles.
|
|
366
|
+
if (collapseKey && matchesKey(data, collapseKey as any)) { if (!stopInput && !isKeyRepeat(data)) toggleCollapse(); return; }
|
|
367
|
+
if (isCollapsed()) return; // Never edit an invisible draft on a host without a raw listener.
|
|
368
|
+
if (matchesKey(data, 'pageUp') || matchesKey(data, 'pageDown')) { if (!inspect) { inspect = true; offset = 0; } else offset += matchesKey(data, 'pageDown') ? page : -page; tui.requestRender(); return; }
|
|
369
|
+
inspect = false; view!.handleInput(data);
|
|
370
|
+
if (matchesKey(data, 'tab')) focusQuestion();
|
|
371
|
+
reconcile(); schedule();
|
|
372
|
+
},
|
|
373
|
+
invalidate() { view?.invalidate(); },
|
|
374
|
+
dispose() { if (!finishModal(true) && !unwindDeferredModalReturn() && !unwindSettledModalReturn()) release(!tui || owners.get(tui) === owner); mounted = false; if (tui && owners.get(tui) === owner) owners.delete(tui); },
|
|
375
|
+
};
|
|
376
|
+
function ensure() {
|
|
377
|
+
if (disposed) throw Object.assign(new Error('Question host detached.'), { code: 'presentation_detached' });
|
|
378
|
+
if (!supportsQuestionHost(context)) throw Object.assign(new Error('This Pi host does not expose inline widgets; no question was presented.'), { code: 'unsupported_host' });
|
|
379
|
+
if (mounted) return;
|
|
380
|
+
context.ui.setWidget(widgetKey, (reference: any, theme: any) => {
|
|
381
|
+
if (typeof reference.getFocusedComponent !== 'function' || typeof reference.setFocus !== 'function') throw new Error('SDK lacks public inline focus support.');
|
|
382
|
+
tui = reference; palette = theme;
|
|
383
|
+
// Renderer references can be write-through Proxies. Never assign their methods.
|
|
384
|
+
const delegate = new Proxy({} as any, { get(_target, property) { const value = Reflect.get(tui, property, tui); return typeof value === 'function' ? value.bind(tui) : value; } });
|
|
385
|
+
view ||= new QuestionView(model, delegate, theme, async (text) => {
|
|
386
|
+
try {
|
|
387
|
+
const { SettingsManager } = await import('@earendil-works/pi-coding-agent');
|
|
388
|
+
if (disposed || !mounted || foreign || owners.get(tui) !== owner || tui.getFocusedComponent() !== component) return undefined;
|
|
389
|
+
const command = SettingsManager.create(context.cwd, undefined, { projectTrusted: context.isProjectTrusted?.() ?? false }).getExternalEditorCommand();
|
|
390
|
+
return editTextOutsidePi(delegate, command || '', text);
|
|
391
|
+
} catch (error) { if (disposed || !mounted || owners.get(tui) !== owner) return undefined; throw error; }
|
|
392
|
+
}); owners.set(tui, owner); mounted = true;
|
|
393
|
+
queueMicrotask(reconcile); return component;
|
|
394
|
+
}, { placement: 'aboveEditor' });
|
|
395
|
+
if (!mounted) throw new Error('SDK did not mount question widget synchronously.');
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
enqueue(group: QuestionGroup) {
|
|
399
|
+
ensure();
|
|
400
|
+
if (group.mode === 'blocking' && !hasBlocker()) modalPaneTab = displayKey();
|
|
401
|
+
const handle = model.enqueue(group);
|
|
402
|
+
return { outcome: handle.outcome, detach: handle.detach, answered: handle.answered,
|
|
403
|
+
require(value: boolean) {
|
|
404
|
+
if (value && !hasBlocker()) modalPaneTab = displayKey();
|
|
405
|
+
handle.require(value);
|
|
406
|
+
} };
|
|
407
|
+
},
|
|
408
|
+
select(groupId: string, questionId?: string) { ensure(); const tab = model.tabs().find((tab) => tab.groupId === groupId && tab.questionId === questionId); if (tab) collapsed.delete(JSON.stringify([tab.key, tab.incarnation])); chat = false; chatFrom = undefined; model.select(groupId, questionId); reconcile(); },
|
|
409
|
+
/** Explicit person action; automatic selection/projection never lends stock focus. */
|
|
410
|
+
activate() { ensure(); return focusQuestion(true); },
|
|
411
|
+
snapshot() { return Object.freeze({ tabs: model.tabs(), current: model.current() }); },
|
|
412
|
+
suspend(value: boolean) { foreign = value; if (value) release(); else reconcile(); tui?.requestRender(); },
|
|
413
|
+
dispose() { if (disposed) return; disposed = true; stopInput?.(); if (!finishModal(true) && !unwindDeferredModalReturn() && !unwindSettledModalReturn()) release(!tui || owners.get(tui) === owner); if (tui && owners.get(tui) === owner) owners.delete(tui); model.dispose(); view?.dispose(); if (mounted) context.ui.setWidget(widgetKey, undefined); mounted = false; },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { registerPrivateQuestions } from './compose.ts';
|
|
2
|
+
export { createQuestionHost } from './host.ts';
|
|
3
|
+
export { QuestionModel } from './model.ts';
|
|
4
|
+
export { QuestionView } from './view.ts';
|
|
5
|
+
export { registerBlockingQuestions, type QuestionPresenter } from './tool.ts';
|
|
6
|
+
export type { QuestionAnswer, QuestionGroup, QuestionOption, QuestionResult, QuestionSpec } from './types.ts';
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { AsyncQuestionRequirement, QuestionAnswer, QuestionGroup, QuestionResult, QuestionSpec } from './types.ts';
|
|
2
|
+
|
|
3
|
+
type Cell = { spec: QuestionSpec; index: number; incarnation: number; option: number; confirmed?: number; custom: boolean; reply: string; notes: string; checked: Set<number>; saving: boolean; error?: string; errorCode?: string };
|
|
4
|
+
type Group = { spec: QuestionGroup; incarnation: number; cells: Cell[]; paused: boolean; required: boolean; reviewChoice: 0 | 1; resolve?: (result: QuestionResult) => void; reject?: (error: Error) => void };
|
|
5
|
+
export type QuestionTab = Readonly<{ key: string; groupId: string; questionId?: string; mode: 'blocking' | 'async'; review: boolean; header?: string; incarnation: number }>;
|
|
6
|
+
export type QuestionState = Readonly<{
|
|
7
|
+
tab: QuestionTab; question?: QuestionSpec; index?: number; option?: number;
|
|
8
|
+
reply?: string; custom: boolean; notes?: string; checked?: readonly number[]; saving: boolean;
|
|
9
|
+
error?: string; errorCode?: string; paused: boolean; reviewChoice: 0 | 1;
|
|
10
|
+
}>;
|
|
11
|
+
const key = (group: string, question?: string) => JSON.stringify(question === undefined ? [group, 'review'] : [group, 'question', question]);
|
|
12
|
+
const detached = () => Object.assign(new Error('Question presentation detached.'), { code: 'presentation_detached' });
|
|
13
|
+
|
|
14
|
+
/** Presentation state for independent question sources. Disposal is not refusal;
|
|
15
|
+
* async success is source persistence, never a feedback-consumption receipt. */
|
|
16
|
+
export class QuestionModel {
|
|
17
|
+
private groups = new Map<string, Group>();
|
|
18
|
+
private activeKey?: string;
|
|
19
|
+
private disposed = false;
|
|
20
|
+
private serial = 0;
|
|
21
|
+
private blockingCompletion?: 'answered' | 'cancelled' | 'detached';
|
|
22
|
+
private changed: () => void;
|
|
23
|
+
constructor(changed: () => void = () => {}) { this.changed = changed; }
|
|
24
|
+
tabs(): readonly QuestionTab[] {
|
|
25
|
+
const mode = (group: Group) => group.required ? 'blocking' as const : group.spec.mode;
|
|
26
|
+
return [...this.groups.values()].sort((a, b) => Number(mode(a) === 'async') - Number(mode(b) === 'async')).flatMap((group) => [
|
|
27
|
+
...group.cells.map((cell) => Object.freeze({ key: key(group.spec.id, cell.spec.id), groupId: group.spec.id, questionId: cell.spec.id, mode: mode(group), review: false, header: cell.spec.header, incarnation: cell.incarnation })),
|
|
28
|
+
...(group.spec.mode === 'blocking' && group.spec.questions.length > 1 ? [Object.freeze({ key: key(group.spec.id), groupId: group.spec.id, mode: mode(group), review: true, incarnation: group.incarnation })] : []),
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
current(): QuestionState | undefined {
|
|
32
|
+
const tab = this.tabs().find((tab) => tab.key === this.activeKey) || this.tabs()[0];
|
|
33
|
+
if (!tab) return;
|
|
34
|
+
const group = this.groups.get(tab.groupId)!, cell = group.cells.find((cell) => cell.spec.id === tab.questionId);
|
|
35
|
+
return Object.freeze({ tab, question: cell?.spec, index: cell?.index, option: cell?.option, reply: cell?.reply, custom: !!cell?.custom, notes: cell?.notes,
|
|
36
|
+
checked: cell ? Object.freeze([...cell.checked]) : undefined, saving: !!cell?.saving, error: cell?.error, errorCode: cell?.errorCode, paused: group.paused, reviewChoice: group.reviewChoice });
|
|
37
|
+
}
|
|
38
|
+
enqueue(input: QuestionGroup) {
|
|
39
|
+
if (this.disposed) throw detached();
|
|
40
|
+
if (!input.id || this.groups.has(input.id)) throw new Error('Group identity must be unique.');
|
|
41
|
+
if (!input.questions.length || input.questions.some((q) => !q.id) || new Set(input.questions.map((q) => q.id)).size !== input.questions.length) throw new Error('A group needs unique nonempty question identities.');
|
|
42
|
+
if (input.mode === 'async' && !input.commit) throw new Error('Async sources own answer persistence.');
|
|
43
|
+
const questions = Object.freeze(input.questions.map((q) => Object.freeze({ ...q, options: q.options && Object.freeze(q.options.map((option) => Object.freeze({ ...option }))) })));
|
|
44
|
+
const group: Group = { spec: Object.freeze({ ...input, questions }), incarnation: ++this.serial, cells: questions.map((spec, index) => ({ spec, index, incarnation: ++this.serial, option: 0, custom: !spec.options?.length, reply: '', notes: '', checked: new Set(), saving: false })), paused: false, required: false, reviewChoice: 0 };
|
|
45
|
+
const outcome = input.mode === 'blocking' ? new Promise<QuestionResult>((resolve, reject) => { group.resolve = resolve; group.reject = reject; }) : undefined;
|
|
46
|
+
const previous = this.current()?.tab;
|
|
47
|
+
this.groups.set(input.id, group);
|
|
48
|
+
if (!previous || (input.mode === 'blocking' && previous.mode === 'async')) this.activeKey = this.tabs()[0]?.key;
|
|
49
|
+
else this.activeKey = previous.key;
|
|
50
|
+
this.changed();
|
|
51
|
+
return { outcome,
|
|
52
|
+
require: (value: boolean) => this.require(group, value),
|
|
53
|
+
answered: () => { if (this.groups.get(input.id) === group) this.remove(group, group.required ? 'answered' : undefined); },
|
|
54
|
+
detach: () => { if (this.groups.get(input.id) !== group) return;
|
|
55
|
+
this.remove(group, group.spec.mode === 'blocking' || group.required ? 'detached' : undefined); group.reject?.(detached()); } };
|
|
56
|
+
}
|
|
57
|
+
private require(group: Group, value: boolean) {
|
|
58
|
+
if (group.spec.mode !== 'async' || this.groups.get(group.spec.id) !== group || group.required === value) return;
|
|
59
|
+
if (value && !group.spec.releaseRequirement) throw new Error('A required async group needs a source-owned release callback.');
|
|
60
|
+
group.required = value;
|
|
61
|
+
if (value) {
|
|
62
|
+
group.paused = false;
|
|
63
|
+
this.activeKey = key(group.spec.id, group.cells[0]?.spec.id);
|
|
64
|
+
} else {
|
|
65
|
+
this.blockingCompletion = 'detached';
|
|
66
|
+
if (!this.tabs().some((tab) => tab.key === this.activeKey)) this.activeKey = this.tabs()[0]?.key;
|
|
67
|
+
}
|
|
68
|
+
this.changed();
|
|
69
|
+
}
|
|
70
|
+
select(groupId: string, questionId?: string) {
|
|
71
|
+
const target = this.tabs().find((tab) => tab.key === key(groupId, questionId));
|
|
72
|
+
if (!target) throw new Error('Unknown question tab.');
|
|
73
|
+
this.activeKey = target.key; this.groups.get(groupId)!.paused = false; this.changed();
|
|
74
|
+
}
|
|
75
|
+
navigate(delta: number) {
|
|
76
|
+
const tabs = this.tabs(), current = this.current(); if (!current) return;
|
|
77
|
+
const at = tabs.findIndex((tab) => tab.key === current.tab.key), tab = tabs[(at + delta + tabs.length) % tabs.length];
|
|
78
|
+
this.select(tab.groupId, tab.questionId);
|
|
79
|
+
}
|
|
80
|
+
/** Tabs whose groups have not been paused, without changing selection. */
|
|
81
|
+
unpausedTabs(): readonly QuestionTab[] {
|
|
82
|
+
return Object.freeze(this.tabs().filter((tab) => !this.groups.get(tab.groupId)?.paused));
|
|
83
|
+
}
|
|
84
|
+
/** The last required-group completion since the host last crossed out of modal state. */
|
|
85
|
+
takeBlockingCompletion() {
|
|
86
|
+
const completion = this.blockingCompletion; this.blockingCompletion = undefined; return completion;
|
|
87
|
+
}
|
|
88
|
+
private cell(): Cell | undefined {
|
|
89
|
+
const current = this.current();
|
|
90
|
+
return current && this.groups.get(current.tab.groupId)?.cells.find((cell) => cell.spec.id === current.tab.questionId);
|
|
91
|
+
}
|
|
92
|
+
moveOption(delta: number) {
|
|
93
|
+
const cell = this.cell(); if (!cell || cell.saving) return;
|
|
94
|
+
const count = (cell.spec.options?.length || 0) + 1;
|
|
95
|
+
cell.option = (cell.option + delta + count) % count; this.changed();
|
|
96
|
+
}
|
|
97
|
+
setReply(reply: string) { const cell = this.cell(); if (!cell || cell.saving) return; cell.reply = reply; cell.custom = !!reply || !cell.spec.options?.length; cell.confirmed = undefined; this.changed(); }
|
|
98
|
+
useCustom() { const cell = this.cell(); if (cell && !cell.saving) { cell.custom = true; this.changed(); } }
|
|
99
|
+
useChoices() { const cell = this.cell(); if (cell && !cell.saving && cell.spec.options?.length) { cell.custom = false; this.changed(); } }
|
|
100
|
+
/** An edit capability belongs to this exact cell/activation, not its reusable ID. */
|
|
101
|
+
draftEdit() {
|
|
102
|
+
const current = this.current(), cell = this.cell(); if (!current || !cell || cell.saving) return;
|
|
103
|
+
const group = this.groups.get(current.tab.groupId)!;
|
|
104
|
+
const replace = (field: 'reply' | 'notes', value: string) => {
|
|
105
|
+
if (this.disposed || this.groups.get(group.spec.id) !== group || !group.cells.includes(cell) || cell.saving || (field === 'notes' && cell.spec.allowNotes === false)) return false;
|
|
106
|
+
cell[field] = value;
|
|
107
|
+
if (field === 'reply') { cell.custom = !!value || !cell.spec.options?.length; cell.confirmed = undefined; }
|
|
108
|
+
this.changed(); return true;
|
|
109
|
+
};
|
|
110
|
+
return Object.freeze({ reply: cell.reply, notes: cell.notes, replaceReply: (value: string) => replace('reply', value), replaceNotes: (value: string) => replace('notes', value) });
|
|
111
|
+
}
|
|
112
|
+
setNotes(notes: string) { const cell = this.cell(); if (!cell || cell.saving || cell.spec.allowNotes === false) return; cell.notes = notes; this.changed(); }
|
|
113
|
+
toggleOption() {
|
|
114
|
+
const cell = this.cell(); if (!cell || cell.saving || !cell.spec.multiSelect || !cell.spec.options?.[cell.option]) return;
|
|
115
|
+
if (cell.checked.has(cell.option)) cell.checked.delete(cell.option); else cell.checked.add(cell.option); this.changed();
|
|
116
|
+
}
|
|
117
|
+
private answer(cell: Cell, confirming = false): QuestionAnswer | undefined {
|
|
118
|
+
const base = { questionIndex: cell.index, question: cell.spec.question, ...(cell.notes ? { notes: cell.notes } : {}) };
|
|
119
|
+
if (cell.spec.multiSelect) {
|
|
120
|
+
const indices = [...cell.checked].sort((a, b) => a - b); if (!indices.length && !(cell.custom && cell.reply)) return;
|
|
121
|
+
const choices = indices.map((index) => cell.spec.options![index]);
|
|
122
|
+
return Object.freeze({ ...base, selected: Object.freeze(choices.map((option) => option.label)), optionIndices: Object.freeze(indices), previews: Object.freeze(choices.map((option) => option.preview ?? null)), ...(cell.custom && cell.reply ? { answer: cell.reply, wasCustom: true } : {}) });
|
|
123
|
+
}
|
|
124
|
+
if (cell.custom) return cell.reply ? Object.freeze({ ...base, answer: cell.reply, wasCustom: true }) : undefined;
|
|
125
|
+
const index = confirming ? cell.option : cell.confirmed, option = index === undefined ? undefined : cell.spec.options?.[index];
|
|
126
|
+
if (!option) return;
|
|
127
|
+
return Object.freeze({ ...base, answer: option.label, optionIndex: index, ...(option.preview !== undefined ? { preview: option.preview } : {}) });
|
|
128
|
+
}
|
|
129
|
+
answers(groupId: string): readonly QuestionAnswer[] {
|
|
130
|
+
const group = this.groups.get(groupId); if (!group) return Object.freeze([]);
|
|
131
|
+
return Object.freeze(group.cells.flatMap((cell) => { const answer = this.answer(cell); return answer ? [answer] : []; }));
|
|
132
|
+
}
|
|
133
|
+
async confirm() {
|
|
134
|
+
const current = this.current(); if (!current || current.paused || current.saving) return;
|
|
135
|
+
const group = this.groups.get(current.tab.groupId)!;
|
|
136
|
+
if (current.tab.review) { if (group.reviewChoice) this.cancel(); else this.submit(group.spec.id); return; }
|
|
137
|
+
const cell = this.cell()!, answer = this.answer(cell, true); if (!answer) return;
|
|
138
|
+
if (group.spec.mode === 'blocking') {
|
|
139
|
+
cell.confirmed = cell.option;
|
|
140
|
+
if (group.cells.length === 1) this.submit(group.spec.id);
|
|
141
|
+
else this.select(group.spec.id, group.cells[cell.index + 1]?.spec.id);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
cell.saving = true; cell.error = cell.errorCode = undefined; this.changed();
|
|
145
|
+
try {
|
|
146
|
+
await group.spec.commit!(cell.spec.id, answer);
|
|
147
|
+
if (this.groups.get(group.spec.id) !== group) return;
|
|
148
|
+
group.cells = group.cells.filter((candidate) => candidate !== cell);
|
|
149
|
+
if (!group.cells.length) this.remove(group, group.required ? 'answered' : undefined); else this.changed();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (this.groups.get(group.spec.id) === group) { cell.error = String(error); cell.errorCode = typeof (error as any)?.code === 'string' ? (error as any).code : undefined; this.changed(); }
|
|
152
|
+
} finally { if (!this.disposed && this.groups.get(group.spec.id) === group) { cell.saving = false; this.changed(); } }
|
|
153
|
+
}
|
|
154
|
+
moveReview() { const current = this.current(); if (!current?.tab.review) return; const group = this.groups.get(current.tab.groupId)!; group.reviewChoice = group.reviewChoice ? 0 : 1; this.changed(); }
|
|
155
|
+
submit(groupId: string) {
|
|
156
|
+
const group = this.groups.get(groupId); if (!group || group.spec.mode !== 'blocking') return;
|
|
157
|
+
const result = Object.freeze({ answers: this.answers(groupId), cancelled: false }); this.remove(group, 'answered'); group.resolve!(result);
|
|
158
|
+
}
|
|
159
|
+
cancel() {
|
|
160
|
+
const current = this.current(); if (!current) return;
|
|
161
|
+
const group = this.groups.get(current.tab.groupId)!;
|
|
162
|
+
if (group.spec.mode === 'async') {
|
|
163
|
+
if (group.required) { group.spec.releaseRequirement?.(current.tab.questionId!); return; }
|
|
164
|
+
group.paused = true; this.changed(); return;
|
|
165
|
+
}
|
|
166
|
+
const result = Object.freeze({ answers: this.answers(group.spec.id), cancelled: true }); this.remove(group, 'cancelled'); group.resolve!(result);
|
|
167
|
+
}
|
|
168
|
+
private remove(group: Group, completion?: AsyncQuestionRequirement | 'cancelled') {
|
|
169
|
+
this.groups.delete(group.spec.id);
|
|
170
|
+
if ((group.spec.mode === 'blocking' || group.required) && completion) this.blockingCompletion = completion;
|
|
171
|
+
if (!this.tabs().some((tab) => tab.key === this.activeKey)) this.activeKey = this.tabs()[0]?.key;
|
|
172
|
+
this.changed();
|
|
173
|
+
}
|
|
174
|
+
dispose() { if (this.disposed) return; this.disposed = true; for (const group of [...this.groups.values()]) { this.remove(group, 'detached'); group.reject?.(detached()); } }
|
|
175
|
+
}
|