@joewinke/jatui 0.38.6 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CLAUDE.md +31 -0
  2. package/THIRD_PARTY_NOTICES.md +51 -0
  3. package/dist/components/BulkActionBar.svelte +32 -2
  4. package/dist/components/BulkActionBar.svelte.d.ts +5 -1
  5. package/dist/components/FloatingActionBar.svelte +102 -34
  6. package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
  7. package/dist/components/MediaExplorer.svelte +249 -0
  8. package/dist/components/MediaExplorer.svelte.d.ts +37 -0
  9. package/dist/components/NotificationCenter.svelte +481 -0
  10. package/dist/components/NotificationCenter.svelte.d.ts +29 -0
  11. package/dist/components/SearchDropdown.svelte +54 -8
  12. package/dist/components/SearchDropdown.svelte.d.ts +12 -0
  13. package/dist/components/agent/AgentLog.svelte +217 -0
  14. package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
  15. package/dist/components/agent/AgentLogRow.svelte +281 -0
  16. package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
  17. package/dist/components/agent/AgentTaskList.svelte +607 -0
  18. package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
  19. package/dist/components/agent/AgentThinking.svelte +280 -0
  20. package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
  21. package/dist/components/agent/ComposerGlow.svelte +265 -0
  22. package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
  23. package/dist/components/agent/ContextMeter.svelte +277 -0
  24. package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
  25. package/dist/components/agent/Questionnaire.svelte +767 -0
  26. package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
  27. package/dist/components/agent/ShimmerText.svelte +100 -0
  28. package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
  29. package/dist/components/agent/StreamedText.svelte +135 -0
  30. package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
  31. package/dist/components/agent/agentLog.d.ts +22 -0
  32. package/dist/components/agent/agentLog.js +26 -0
  33. package/dist/components/agent/agentTaskList.d.ts +90 -0
  34. package/dist/components/agent/agentTaskList.js +93 -0
  35. package/dist/components/agent/agentThinking.d.ts +46 -0
  36. package/dist/components/agent/agentThinking.js +78 -0
  37. package/dist/components/agent/contextMeter.d.ts +97 -0
  38. package/dist/components/agent/contextMeter.js +156 -0
  39. package/dist/components/agent/questionnaire.d.ts +104 -0
  40. package/dist/components/agent/questionnaire.js +225 -0
  41. package/dist/components/agent/revealCore.d.ts +74 -0
  42. package/dist/components/agent/revealCore.js +160 -0
  43. package/dist/components/agent/revealTicker.d.ts +34 -0
  44. package/dist/components/agent/revealTicker.js +73 -0
  45. package/dist/components/agent/streamedText.d.ts +28 -0
  46. package/dist/components/agent/streamedText.js +59 -0
  47. package/dist/components/messaging/MessageInput.svelte +34 -4
  48. package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
  49. package/dist/components/messaging/MessageItem.svelte +2 -2
  50. package/dist/components/notificationCenter.d.ts +65 -0
  51. package/dist/components/notificationCenter.js +110 -0
  52. package/dist/components/progress/Steps.svelte +269 -5
  53. package/dist/components/progress/Steps.svelte.d.ts +16 -1
  54. package/dist/components/progress/stepsSummary.d.ts +14 -0
  55. package/dist/components/progress/stepsSummary.js +32 -0
  56. package/dist/index.d.ts +34 -0
  57. package/dist/index.js +29 -0
  58. package/dist/selection/clickSelection.d.ts +89 -0
  59. package/dist/selection/clickSelection.js +152 -0
  60. package/dist/selection/createClickSelection.d.ts +79 -0
  61. package/dist/selection/createClickSelection.js +134 -0
  62. package/package.json +3 -2
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ContextMeter — pure math (DOM-free, unit-tested in tests/unit/contextMeter.test.ts).
3
+ *
4
+ * Clean-room: built from the behaviour description of a segmented
5
+ * context-window meter (stacked bar per token bucket, "820k / 1M (82%)",
6
+ * expandable breakdown, deferred buckets listed but not counted).
7
+ */
8
+ export interface ContextSegment {
9
+ id?: string;
10
+ label: string;
11
+ tokens: number;
12
+ /** Listed in the breakdown with a dash; excluded from the bar and the total. */
13
+ deferred?: boolean;
14
+ /** Any CSS colour (a token var, ideally). Default: the palette slot. */
15
+ color?: string;
16
+ /** Group id — the breakdown folds members under a collapsible header row. */
17
+ group?: string;
18
+ }
19
+ export interface ContextGroup {
20
+ id: string;
21
+ label: string;
22
+ /** Start unfolded. Default false. */
23
+ defaultExpanded?: boolean;
24
+ }
25
+ export type ContextTone = 'normal' | 'warning' | 'error';
26
+ /**
27
+ * Palette slot `i`: the six semantic theme colours, then the same six mixed
28
+ * toward the page surface (so they lighten in light themes and darken in dark
29
+ * ones), then a fainter round; cycles after 18.
30
+ */
31
+ export declare function segmentColor(i: number): string;
32
+ /** Non-negative finite token count. */
33
+ export declare function safeTokens(n: number | null | undefined): number;
34
+ /**
35
+ * Compact number: 950 → "950", 12_345 → "12.3k", 820_000 → "820k",
36
+ * 1_000_000 → "1M", 1_250_000 → "1.25M". At most 3 significant-ish digits,
37
+ * trailing zeros trimmed; rounding that reaches the next unit rolls over
38
+ * (999_999 → "1M").
39
+ */
40
+ export declare function formatCompact(value: number): string;
41
+ /** Whole-percent label: 0 → "0%", 0.004 → "<1%", 0.82 → "82%", 1.05 → "105%". */
42
+ export declare function formatPercent(ratio: number): string;
43
+ export interface MeterSegment {
44
+ key: string;
45
+ label: string;
46
+ tokens: number;
47
+ deferred: boolean;
48
+ color: string;
49
+ group?: string;
50
+ /** Share of the BAR (0..100). 0 for deferred segments. */
51
+ width: number;
52
+ /** tokens / max (unclamped ratio; 0 for deferred). */
53
+ share: number;
54
+ }
55
+ export interface ContextMeterModel {
56
+ max: number;
57
+ /** Sum of counted (non-deferred) tokens. */
58
+ used: number;
59
+ /** max - used, floored at 0. */
60
+ free: number;
61
+ /** used / max (unclamped; 1 when max <= 0 and anything is used). */
62
+ ratio: number;
63
+ /** Width of the free-space remainder in the bar (0..100). */
64
+ freeWidth: number;
65
+ over: boolean;
66
+ tone: ContextTone;
67
+ segments: MeterSegment[];
68
+ }
69
+ /**
70
+ * Totals, shares and bar widths. Deferred segments keep their place (and
71
+ * palette slot is skipped for them so bar colours stay contiguous) but count
72
+ * for nothing. When used > max the bar is clamped: widths are rescaled to
73
+ * fill exactly 100% and free space is 0.
74
+ */
75
+ export declare function computeContextMeter(max: number, segments: readonly ContextSegment[], warnAt?: number): ContextMeterModel;
76
+ export type BreakdownRow = {
77
+ kind: 'segment';
78
+ segment: MeterSegment;
79
+ } | {
80
+ kind: 'group';
81
+ id: string;
82
+ label: string;
83
+ /** Counted tokens of the members (deferred members excluded). */
84
+ tokens: number;
85
+ share: number;
86
+ /** True when every member is deferred (show a dash, not 0). */
87
+ allDeferred: boolean;
88
+ members: MeterSegment[];
89
+ };
90
+ /**
91
+ * Breakdown rows in first-appearance order: ungrouped segments as-is, grouped
92
+ * ones folded into one group row at the position of their first member.
93
+ * Labels come from `groups` (unknown ids fall back to the id).
94
+ */
95
+ export declare function buildBreakdown(model: ContextMeterModel, groups?: readonly ContextGroup[]): BreakdownRow[];
96
+ /** Screen-reader value text: "820,000 of 1,000,000 tokens used (82%)". */
97
+ export declare function meterValueText(model: ContextMeterModel, unit?: string): string;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * ContextMeter — pure math (DOM-free, unit-tested in tests/unit/contextMeter.test.ts).
3
+ *
4
+ * Clean-room: built from the behaviour description of a segmented
5
+ * context-window meter (stacked bar per token bucket, "820k / 1M (82%)",
6
+ * expandable breakdown, deferred buckets listed but not counted).
7
+ */
8
+ const BASE_PALETTE = [
9
+ '--color-primary',
10
+ '--color-secondary',
11
+ '--color-accent',
12
+ '--color-info',
13
+ '--color-success',
14
+ '--color-warning'
15
+ ];
16
+ /** How much of the base colour each later palette round keeps (mixed into base-100). */
17
+ const MIX_ROUNDS = [100, 60, 35];
18
+ /**
19
+ * Palette slot `i`: the six semantic theme colours, then the same six mixed
20
+ * toward the page surface (so they lighten in light themes and darken in dark
21
+ * ones), then a fainter round; cycles after 18.
22
+ */
23
+ export function segmentColor(i) {
24
+ const n = BASE_PALETTE.length;
25
+ const slot = ((Math.floor(i) % (n * MIX_ROUNDS.length)) + n * MIX_ROUNDS.length) % (n * MIX_ROUNDS.length);
26
+ const base = `var(${BASE_PALETTE[slot % n]})`;
27
+ const pct = MIX_ROUNDS[Math.floor(slot / n)];
28
+ return pct === 100 ? base : `color-mix(in oklab, ${base} ${pct}%, var(--color-base-100))`;
29
+ }
30
+ /** Non-negative finite token count. */
31
+ export function safeTokens(n) {
32
+ return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0;
33
+ }
34
+ /**
35
+ * Compact number: 950 → "950", 12_345 → "12.3k", 820_000 → "820k",
36
+ * 1_000_000 → "1M", 1_250_000 → "1.25M". At most 3 significant-ish digits,
37
+ * trailing zeros trimmed; rounding that reaches the next unit rolls over
38
+ * (999_999 → "1M").
39
+ */
40
+ export function formatCompact(value) {
41
+ if (!Number.isFinite(value))
42
+ return '—';
43
+ const sign = value < 0 ? '-' : '';
44
+ let v = Math.abs(value);
45
+ if (v < 1000)
46
+ return sign + String(Math.round(v));
47
+ const units = ['k', 'M', 'B', 'T'];
48
+ let u = -1;
49
+ while (u < units.length - 1 && v >= 1000) {
50
+ v /= 1000;
51
+ u++;
52
+ }
53
+ const round = (x) => {
54
+ const d = x < 10 ? 2 : x < 100 ? 1 : 0;
55
+ const f = 10 ** d;
56
+ return Math.round(x * f) / f;
57
+ };
58
+ let r = round(v);
59
+ if (r >= 1000 && u < units.length - 1) {
60
+ u++;
61
+ r = round(r / 1000);
62
+ }
63
+ return `${sign}${r}${units[u]}`;
64
+ }
65
+ /** Whole-percent label: 0 → "0%", 0.004 → "<1%", 0.82 → "82%", 1.05 → "105%". */
66
+ export function formatPercent(ratio) {
67
+ if (!Number.isFinite(ratio) || ratio <= 0)
68
+ return '0%';
69
+ const p = ratio * 100;
70
+ if (p < 1)
71
+ return '<1%';
72
+ // Never round a not-yet-full meter up to 100%.
73
+ if (p < 100 && Math.round(p) === 100)
74
+ return '99%';
75
+ return `${Math.round(p)}%`;
76
+ }
77
+ /**
78
+ * Totals, shares and bar widths. Deferred segments keep their place (and
79
+ * palette slot is skipped for them so bar colours stay contiguous) but count
80
+ * for nothing. When used > max the bar is clamped: widths are rescaled to
81
+ * fill exactly 100% and free space is 0.
82
+ */
83
+ export function computeContextMeter(max, segments, warnAt = 0.8) {
84
+ const cap = safeTokens(max);
85
+ let used = 0;
86
+ for (const s of segments)
87
+ if (!s.deferred)
88
+ used += safeTokens(s.tokens);
89
+ const ratio = cap > 0 ? used / cap : used > 0 ? 1 : 0;
90
+ const over = cap > 0 ? used > cap : used > 0;
91
+ // Bar denominator: the window, or the overflowing total so widths sum to 100.
92
+ const denom = Math.max(cap, used);
93
+ let slot = 0;
94
+ const out = segments.map((s, i) => {
95
+ const deferred = s.deferred === true;
96
+ const tokens = safeTokens(s.tokens);
97
+ const color = s.color ?? segmentColor(deferred ? slot : slot++);
98
+ return {
99
+ key: s.id ?? `${i}:${s.label}`,
100
+ label: s.label,
101
+ tokens,
102
+ deferred,
103
+ color,
104
+ group: s.group,
105
+ width: deferred || denom === 0 ? 0 : (tokens / denom) * 100,
106
+ share: deferred || cap === 0 ? 0 : tokens / cap
107
+ };
108
+ });
109
+ const freeWidth = denom === 0 ? 100 : Math.max(0, ((cap - used) / denom) * 100);
110
+ const tone = ratio >= 1 ? 'error' : ratio >= warnAt ? 'warning' : 'normal';
111
+ return { max: cap, used, free: Math.max(0, cap - used), ratio, freeWidth, over, tone, segments: out };
112
+ }
113
+ /**
114
+ * Breakdown rows in first-appearance order: ungrouped segments as-is, grouped
115
+ * ones folded into one group row at the position of their first member.
116
+ * Labels come from `groups` (unknown ids fall back to the id).
117
+ */
118
+ export function buildBreakdown(model, groups = []) {
119
+ const labels = new Map(groups.map((g) => [g.id, g.label]));
120
+ const rows = [];
121
+ const byGroup = new Map();
122
+ for (const seg of model.segments) {
123
+ if (seg.group == null || seg.group === '') {
124
+ rows.push({ kind: 'segment', segment: seg });
125
+ continue;
126
+ }
127
+ let row = byGroup.get(seg.group);
128
+ if (!row) {
129
+ row = {
130
+ kind: 'group',
131
+ id: seg.group,
132
+ label: labels.get(seg.group) ?? seg.group,
133
+ tokens: 0,
134
+ share: 0,
135
+ allDeferred: true,
136
+ members: []
137
+ };
138
+ byGroup.set(seg.group, row);
139
+ rows.push(row);
140
+ }
141
+ row.members.push(seg);
142
+ if (!seg.deferred) {
143
+ row.tokens += seg.tokens;
144
+ row.share += seg.share;
145
+ row.allDeferred = false;
146
+ }
147
+ }
148
+ return rows;
149
+ }
150
+ /** Screen-reader value text: "820,000 of 1,000,000 tokens used (82%)". */
151
+ export function meterValueText(model, unit = 'tokens') {
152
+ const n = (x) => x.toLocaleString('en-US');
153
+ const u = unit ? ` ${unit}` : '';
154
+ const base = `${n(model.used)} of ${n(model.max)}${u} used (${formatPercent(model.ratio)})`;
155
+ return model.over ? `${base}, over the limit` : base;
156
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Questionnaire answer-state logic — pure, DOM-free, immutable.
3
+ *
4
+ * The question shape mirrors Claude Code's AskUserQuestion tool payload
5
+ * (`{ question, header, options: { label, description }[], multiSelect }`) so a
6
+ * host can pass a tool call's `questions` straight through, and read the
7
+ * answers back as one `{ values, other? }` per question (values = the picked
8
+ * option LABELS, in option order).
9
+ *
10
+ * "Other" state: `other === undefined` means the free-text row is not ticked;
11
+ * a string (possibly empty while the user is still typing) means it is. In a
12
+ * single-select question an option pick and "Other" are mutually exclusive.
13
+ */
14
+ export interface QuestionnaireOption {
15
+ label: string;
16
+ description?: string;
17
+ /** Optional monospace preview (code, a diff, a layout sketch) shown while focused/selected. */
18
+ preview?: string;
19
+ }
20
+ export interface QuestionnaireQuestion {
21
+ question: string;
22
+ /** Short chip text (AskUserQuestion's `header`), used on the step pill. */
23
+ header?: string;
24
+ options: QuestionnaireOption[];
25
+ multiSelect?: boolean;
26
+ }
27
+ export interface QuestionnaireAnswer {
28
+ /** Selected option labels, in option order. */
29
+ values: string[];
30
+ /** Free-text "Other" answer; present = the Other row is ticked. */
31
+ other?: string;
32
+ }
33
+ export type QuestionnaireAnswers = QuestionnaireAnswer[];
34
+ /** Direction of travel between two steps: 1 forward, -1 back, 0 none. */
35
+ export type StepDirection = -1 | 0 | 1;
36
+ /** Digit keys address at most nine rows (1..9). */
37
+ export declare const MAX_DIGIT_ROWS = 9;
38
+ /** One empty answer per question. */
39
+ export declare function emptyAnswers(questions: readonly QuestionnaireQuestion[]): QuestionnaireAnswers;
40
+ /**
41
+ * Align a (possibly missing, short, long or stale) answers array to the
42
+ * questions: pads with blanks, truncates extras, drops values that are not an
43
+ * option label any more, and keeps a single-select answer to one value.
44
+ */
45
+ export declare function normalizeAnswers(questions: readonly QuestionnaireQuestion[], answers: readonly (QuestionnaireAnswer | undefined | null)[] | undefined | null): QuestionnaireAnswers;
46
+ /**
47
+ * Select an option. Single-select: replaces the answer (and unticks Other).
48
+ * Multi-select: adds it (idempotent). Unknown labels are ignored.
49
+ */
50
+ export declare function pickOption(questions: readonly QuestionnaireQuestion[], answers: QuestionnaireAnswers, index: number, label: string): QuestionnaireAnswers;
51
+ /**
52
+ * Toggle an option. Multi-select: add/remove. Single-select: picks it (a radio
53
+ * is not un-picked by pressing it again).
54
+ */
55
+ export declare function toggleOption(questions: readonly QuestionnaireQuestion[], answers: QuestionnaireAnswers, index: number, label: string): QuestionnaireAnswers;
56
+ /**
57
+ * Set the Other text. Typing ticks the Other row; in single-select it also
58
+ * clears the option pick. Pass `undefined` to untick.
59
+ */
60
+ export declare function setOther(questions: readonly QuestionnaireQuestion[], answers: QuestionnaireAnswers, index: number, text: string | undefined): QuestionnaireAnswers;
61
+ /** Tick/untick the Other row. Ticking starts with empty text; a single-select Other row is not unticked by pressing it again. */
62
+ export declare function toggleOther(questions: readonly QuestionnaireQuestion[], answers: QuestionnaireAnswers, index: number): QuestionnaireAnswers;
63
+ /** Is the Other row ticked? */
64
+ export declare function isOtherTicked(answer: QuestionnaireAnswer | undefined): boolean;
65
+ /** An answer counts once it has a picked option or non-blank Other text. */
66
+ export declare function isAnswered(answer: QuestionnaireAnswer | undefined): boolean;
67
+ /** Every question answered. */
68
+ export declare function isComplete(questions: readonly QuestionnaireQuestion[], answers: readonly QuestionnaireAnswer[]): boolean;
69
+ /** Index of the first unanswered question, or -1. */
70
+ export declare function firstUnanswered(questions: readonly QuestionnaireQuestion[], answers: readonly QuestionnaireAnswer[]): number;
71
+ /**
72
+ * The answers handed to `onComplete`: Other text trimmed, a ticked-but-blank
73
+ * Other dropped, everything aligned to the questions.
74
+ */
75
+ export declare function finalizeAnswers(questions: readonly QuestionnaireQuestion[], answers: readonly QuestionnaireAnswer[]): QuestionnaireAnswers;
76
+ /** Clamp a step index into range (0 when there are no questions). */
77
+ export declare function clampStep(step: number, count: number): number;
78
+ export declare function nextStep(step: number, count: number): number;
79
+ export declare function prevStep(step: number, count: number): number;
80
+ export declare function isLastStep(step: number, count: number): boolean;
81
+ export declare function stepDirection(from: number, to: number): StepDirection;
82
+ /** "Question 2 of 4". */
83
+ export declare function stepLabel(step: number, count: number): string;
84
+ /**
85
+ * Map a keydown `key` to a row: option index 0..N-1, `'other'` for digit N+1
86
+ * when the Other row is shown, or null. Digits are 1-based; only 1..9.
87
+ */
88
+ export declare function digitToRow(key: string, optionCount: number, allowOther: boolean): number | 'other' | null;
89
+ /** The digit hint for a row (1-based), or null past 9. */
90
+ export declare function rowDigit(row: number): string | null;
91
+ /**
92
+ * Roving-tabindex focus move over `count` rows: ArrowDown/Right +1,
93
+ * ArrowUp/Left -1 (wrapping), Home/End. Returns null for other keys.
94
+ */
95
+ export declare function moveRow(key: string, current: number, count: number): number | null;
96
+ /** The row that carries tabindex=0 in a single-select group: the picked row, else the first. */
97
+ export declare function tabStopRow(question: QuestionnaireQuestion | undefined, answer: QuestionnaireAnswer | undefined): number | 'other';
98
+ /**
99
+ * Which option's preview to show: the focused row if it has one, else the
100
+ * first selected option that has one, else null.
101
+ */
102
+ export declare function previewOption(question: QuestionnaireQuestion | undefined, answer: QuestionnaireAnswer | undefined, focusedRow: number | null): number | null;
103
+ /** Does any option in the question carry a preview? (lays out the preview pane) */
104
+ export declare function hasPreviews(question: QuestionnaireQuestion | undefined): boolean;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Questionnaire answer-state logic — pure, DOM-free, immutable.
3
+ *
4
+ * The question shape mirrors Claude Code's AskUserQuestion tool payload
5
+ * (`{ question, header, options: { label, description }[], multiSelect }`) so a
6
+ * host can pass a tool call's `questions` straight through, and read the
7
+ * answers back as one `{ values, other? }` per question (values = the picked
8
+ * option LABELS, in option order).
9
+ *
10
+ * "Other" state: `other === undefined` means the free-text row is not ticked;
11
+ * a string (possibly empty while the user is still typing) means it is. In a
12
+ * single-select question an option pick and "Other" are mutually exclusive.
13
+ */
14
+ /** Digit keys address at most nine rows (1..9). */
15
+ export const MAX_DIGIT_ROWS = 9;
16
+ const blank = () => ({ values: [] });
17
+ /** One empty answer per question. */
18
+ export function emptyAnswers(questions) {
19
+ return questions.map(blank);
20
+ }
21
+ /**
22
+ * Align a (possibly missing, short, long or stale) answers array to the
23
+ * questions: pads with blanks, truncates extras, drops values that are not an
24
+ * option label any more, and keeps a single-select answer to one value.
25
+ */
26
+ export function normalizeAnswers(questions, answers) {
27
+ return questions.map((q, i) => {
28
+ const a = answers?.[i];
29
+ if (!a)
30
+ return blank();
31
+ const labels = q.options.map((o) => o.label);
32
+ let values = labels.filter((l) => Array.isArray(a.values) && a.values.includes(l));
33
+ let other = typeof a.other === 'string' ? a.other : undefined;
34
+ if (!q.multiSelect) {
35
+ if (values.length > 1)
36
+ values = values.slice(0, 1);
37
+ if (values.length && other !== undefined)
38
+ other = undefined;
39
+ }
40
+ return other === undefined ? { values } : { values, other };
41
+ });
42
+ }
43
+ function replaceAt(answers, index, next) {
44
+ if (index < 0 || index >= answers.length)
45
+ return answers;
46
+ const copy = answers.slice();
47
+ copy[index] = next;
48
+ return copy;
49
+ }
50
+ /** Labels of `q` in option order, keeping only those in `set`. */
51
+ function ordered(q, set) {
52
+ return q.options.map((o) => o.label).filter((l) => set.has(l));
53
+ }
54
+ /**
55
+ * Select an option. Single-select: replaces the answer (and unticks Other).
56
+ * Multi-select: adds it (idempotent). Unknown labels are ignored.
57
+ */
58
+ export function pickOption(questions, answers, index, label) {
59
+ const q = questions[index];
60
+ const a = answers[index];
61
+ if (!q || !a || !q.options.some((o) => o.label === label))
62
+ return answers;
63
+ if (!q.multiSelect)
64
+ return replaceAt(answers, index, { values: [label] });
65
+ if (a.values.includes(label))
66
+ return answers;
67
+ return replaceAt(answers, index, { ...a, values: ordered(q, new Set([...a.values, label])) });
68
+ }
69
+ /**
70
+ * Toggle an option. Multi-select: add/remove. Single-select: picks it (a radio
71
+ * is not un-picked by pressing it again).
72
+ */
73
+ export function toggleOption(questions, answers, index, label) {
74
+ const q = questions[index];
75
+ const a = answers[index];
76
+ if (!q || !a)
77
+ return answers;
78
+ if (!q.multiSelect || !a.values.includes(label))
79
+ return pickOption(questions, answers, index, label);
80
+ return replaceAt(answers, index, { ...a, values: a.values.filter((v) => v !== label) });
81
+ }
82
+ /**
83
+ * Set the Other text. Typing ticks the Other row; in single-select it also
84
+ * clears the option pick. Pass `undefined` to untick.
85
+ */
86
+ export function setOther(questions, answers, index, text) {
87
+ const q = questions[index];
88
+ const a = answers[index];
89
+ if (!q || !a)
90
+ return answers;
91
+ if (text === undefined)
92
+ return replaceAt(answers, index, { values: a.values });
93
+ return replaceAt(answers, index, { values: q.multiSelect ? a.values : [], other: text });
94
+ }
95
+ /** Tick/untick the Other row. Ticking starts with empty text; a single-select Other row is not unticked by pressing it again. */
96
+ export function toggleOther(questions, answers, index) {
97
+ const a = answers[index];
98
+ if (!a)
99
+ return answers;
100
+ const q = questions[index];
101
+ // A single-select Other row behaves like a radio: pressing it again keeps it.
102
+ if (a.other !== undefined)
103
+ return q?.multiSelect ? setOther(questions, answers, index, undefined) : answers;
104
+ return setOther(questions, answers, index, '');
105
+ }
106
+ /** Is the Other row ticked? */
107
+ export function isOtherTicked(answer) {
108
+ return answer?.other !== undefined;
109
+ }
110
+ /** An answer counts once it has a picked option or non-blank Other text. */
111
+ export function isAnswered(answer) {
112
+ if (!answer)
113
+ return false;
114
+ return answer.values.length > 0 || (answer.other ?? '').trim().length > 0;
115
+ }
116
+ /** Every question answered. */
117
+ export function isComplete(questions, answers) {
118
+ return questions.every((_, i) => isAnswered(answers[i]));
119
+ }
120
+ /** Index of the first unanswered question, or -1. */
121
+ export function firstUnanswered(questions, answers) {
122
+ return questions.findIndex((_, i) => !isAnswered(answers[i]));
123
+ }
124
+ /**
125
+ * The answers handed to `onComplete`: Other text trimmed, a ticked-but-blank
126
+ * Other dropped, everything aligned to the questions.
127
+ */
128
+ export function finalizeAnswers(questions, answers) {
129
+ return normalizeAnswers(questions, answers).map((a) => {
130
+ const other = a.other?.trim();
131
+ return other ? { values: a.values, other } : { values: a.values };
132
+ });
133
+ }
134
+ /** Clamp a step index into range (0 when there are no questions). */
135
+ export function clampStep(step, count) {
136
+ if (count <= 0 || !Number.isFinite(step))
137
+ return 0;
138
+ return Math.min(Math.max(Math.trunc(step), 0), count - 1);
139
+ }
140
+ export function nextStep(step, count) {
141
+ return clampStep(step + 1, count);
142
+ }
143
+ export function prevStep(step, count) {
144
+ return clampStep(step - 1, count);
145
+ }
146
+ export function isLastStep(step, count) {
147
+ return count > 0 && clampStep(step, count) === count - 1;
148
+ }
149
+ export function stepDirection(from, to) {
150
+ return to > from ? 1 : to < from ? -1 : 0;
151
+ }
152
+ /** "Question 2 of 4". */
153
+ export function stepLabel(step, count) {
154
+ return `Question ${clampStep(step, count) + 1} of ${count}`;
155
+ }
156
+ /**
157
+ * Map a keydown `key` to a row: option index 0..N-1, `'other'` for digit N+1
158
+ * when the Other row is shown, or null. Digits are 1-based; only 1..9.
159
+ */
160
+ export function digitToRow(key, optionCount, allowOther) {
161
+ if (!/^[1-9]$/.test(key))
162
+ return null;
163
+ const n = Number(key) - 1;
164
+ if (n < Math.min(optionCount, MAX_DIGIT_ROWS))
165
+ return n;
166
+ if (allowOther && n === optionCount && n < MAX_DIGIT_ROWS)
167
+ return 'other';
168
+ return null;
169
+ }
170
+ /** The digit hint for a row (1-based), or null past 9. */
171
+ export function rowDigit(row) {
172
+ return row >= 0 && row < MAX_DIGIT_ROWS ? String(row + 1) : null;
173
+ }
174
+ /**
175
+ * Roving-tabindex focus move over `count` rows: ArrowDown/Right +1,
176
+ * ArrowUp/Left -1 (wrapping), Home/End. Returns null for other keys.
177
+ */
178
+ export function moveRow(key, current, count) {
179
+ if (count <= 0)
180
+ return null;
181
+ switch (key) {
182
+ case 'ArrowDown':
183
+ case 'ArrowRight':
184
+ return (current + 1 + count) % count;
185
+ case 'ArrowUp':
186
+ case 'ArrowLeft':
187
+ return (current - 1 + count) % count;
188
+ case 'Home':
189
+ return 0;
190
+ case 'End':
191
+ return count - 1;
192
+ default:
193
+ return null;
194
+ }
195
+ }
196
+ /** The row that carries tabindex=0 in a single-select group: the picked row, else the first. */
197
+ export function tabStopRow(question, answer) {
198
+ if (!question)
199
+ return 0;
200
+ if (answer?.other !== undefined && !question.multiSelect)
201
+ return 'other';
202
+ const picked = answer?.values[0];
203
+ const idx = picked === undefined ? -1 : question.options.findIndex((o) => o.label === picked);
204
+ return idx >= 0 ? idx : 0;
205
+ }
206
+ /**
207
+ * Which option's preview to show: the focused row if it has one, else the
208
+ * first selected option that has one, else null.
209
+ */
210
+ export function previewOption(question, answer, focusedRow) {
211
+ if (!question)
212
+ return null;
213
+ if (focusedRow !== null && question.options[focusedRow]?.preview)
214
+ return focusedRow;
215
+ for (const v of answer?.values ?? []) {
216
+ const i = question.options.findIndex((o) => o.label === v);
217
+ if (i >= 0 && question.options[i].preview)
218
+ return i;
219
+ }
220
+ return null;
221
+ }
222
+ /** Does any option in the question carry a preview? (lays out the preview pane) */
223
+ export function hasPreviews(question) {
224
+ return !!question?.options.some((o) => o.preview);
225
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Reveal ticker — the pacing core behind AgentLog / AgentTaskList.
3
+ *
4
+ * Counts how many units of a streaming log are visible. Two modes:
5
+ *
6
+ * timer one unit per tick: `startDelay` before the first, then
7
+ * `stepInterval` between the rest, or `delayFor(i)` per unit
8
+ * (the dwell before unit `i` appears) when given.
9
+ * controlled pass `revealed` and the timer is off: real events drive
10
+ * the count, clamped to [0, total].
11
+ *
12
+ * `onComplete` fires exactly once each time the count reaches `total`
13
+ * (total > 0). Dropping back below total (total grew, `replay()`, a lower
14
+ * controlled value) re-arms it.
15
+ *
16
+ * Framework-free and DOM-free, with an injectable clock, so
17
+ * tests/unit/revealTicker.test.ts drives it with fake timers. The Svelte
18
+ * rune wrapper is createRevealTicker in revealTicker.svelte.ts.
19
+ *
20
+ * Behaviour adapted from BoardUI's agent-log `useRevealTicker`:
21
+ * MIT License — Copyright (c) 2026 Mertcan Dundar Esmergul (BoardUI)
22
+ * Differences: framework-free class, an in-flight tick survives option
23
+ * updates that do not change the next unit (a log whose `total` grows while
24
+ * streaming does not stall), and switching from controlled back to timer
25
+ * mode resumes from the controlled count instead of jumping back.
26
+ */
27
+ export declare const DEFAULT_STEP_INTERVAL = 850;
28
+ export declare const DEFAULT_START_DELAY = 320;
29
+ export interface RevealCoreOptions {
30
+ /** How many units the log has. */
31
+ total: number;
32
+ /** ms between units after the first (timer mode). */
33
+ stepInterval?: number;
34
+ /** ms before the first unit (timer mode). */
35
+ startDelay?: number;
36
+ /** Controlled count — when defined, the timer is off. */
37
+ revealed?: number;
38
+ /** Dwell before unit `index` appears; overrides startDelay/stepInterval. */
39
+ delayFor?: (index: number) => number;
40
+ /** false pauses the timer (no effect in controlled mode). Default true. */
41
+ run?: boolean;
42
+ }
43
+ export interface RevealClock {
44
+ setTimeout(fn: () => void, ms: number): unknown;
45
+ clearTimeout(id: unknown): void;
46
+ }
47
+ export interface RevealCoreHooks {
48
+ /** Called whenever the visible count changes. */
49
+ onChange?: (revealed: number) => void;
50
+ /** Called once per completion. */
51
+ onComplete?: () => void;
52
+ clock?: RevealClock;
53
+ }
54
+ /** The delay before unit `index` (pure; exported for tests and hosts). */
55
+ export declare function revealDelay(index: number, opts: Pick<RevealCoreOptions, 'stepInterval' | 'startDelay' | 'delayFor'>): number;
56
+ export declare class RevealCore {
57
+ #private;
58
+ constructor(opts: RevealCoreOptions, hooks?: RevealCoreHooks);
59
+ get total(): number;
60
+ get controlled(): boolean;
61
+ /** Units currently visible. */
62
+ get revealed(): number;
63
+ get complete(): boolean;
64
+ /** True while a tick is scheduled. */
65
+ get pending(): boolean;
66
+ /** Begin scheduling (call on mount — never during SSR). */
67
+ start(): void;
68
+ /** Merge new options (any subset) and reconcile. */
69
+ update(next: Partial<RevealCoreOptions>): void;
70
+ /** Back to zero and run again (timer mode). */
71
+ replay(): void;
72
+ /** Stop the timer for good (call on unmount). */
73
+ destroy(): void;
74
+ }