@groeponline/pi-wishcraft 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +16 -7
  3. package/ROADMAP.md +41 -260
  4. package/docs/commands.md +6 -2
  5. package/docs/configuration.md +21 -0
  6. package/docs/design/accessibility.md +71 -0
  7. package/docs/design/deck-layout.md +69 -0
  8. package/docs/design/motion-gallery.md +80 -0
  9. package/docs/design/motion-system.md +119 -0
  10. package/docs/design/presets.md +196 -0
  11. package/docs/design/regression-testing.md +54 -0
  12. package/docs/design/responsive.md +53 -0
  13. package/docs/design/signal.md +68 -0
  14. package/docs/design/theme-contract.md +132 -0
  15. package/docs/design/vnext-overview.md +55 -0
  16. package/docs/design/vnext-release-plan.md +212 -0
  17. package/docs/index.md +19 -1
  18. package/docs/segments.md +1 -1
  19. package/package.json +2 -1
  20. package/src/config/appearance.ts +230 -0
  21. package/src/config/parse.ts +41 -0
  22. package/src/config/presets.ts +184 -0
  23. package/src/config/structural-presets.ts +555 -0
  24. package/src/config/tokens.ts +154 -0
  25. package/src/config/types.ts +210 -2
  26. package/src/extension/commands/commands.ts +36 -12
  27. package/src/extension/commands/powerline-completions.ts +4 -0
  28. package/src/extension/commands/queue-commands.ts +6 -0
  29. package/src/extension/core/segment-context.ts +7 -1
  30. package/src/extension/core/state.ts +14 -0
  31. package/src/extension/core/types.ts +7 -0
  32. package/src/extension/session/session-lifecycle.ts +20 -0
  33. package/src/extension/settings/appearance-write.ts +135 -0
  34. package/src/extension/settings/wishcraft-config-items.ts +119 -0
  35. package/src/extension/settings/wishcraft-config.ts +28 -114
  36. package/src/extension/skills/skill-manager.ts +5 -0
  37. package/src/extension/ui/deck/component.ts +358 -0
  38. package/src/extension/ui/deck/index.ts +34 -0
  39. package/src/extension/ui/deck/render.ts +260 -0
  40. package/src/extension/ui/deck/route-bodies.ts +209 -0
  41. package/src/extension/ui/deck/routes.ts +37 -0
  42. package/src/extension/ui/deck/session-snapshot.ts +104 -0
  43. package/src/extension/ui/deck/types.ts +92 -0
  44. package/src/extension/ui/powerline-menu-view.ts +19 -15
  45. package/src/extension/ui/signal-layout.ts +77 -0
  46. package/src/extension/ui/status-line-renderers.ts +20 -2
  47. package/src/motion/accessibility.ts +59 -0
  48. package/src/motion/catalog-extra.ts +429 -0
  49. package/src/motion/catalog.ts +336 -0
  50. package/src/motion/composer.ts +147 -0
  51. package/src/motion/frames.ts +84 -0
  52. package/src/motion/gallery.ts +77 -0
  53. package/src/motion/index.ts +78 -0
  54. package/src/motion/policy.ts +128 -0
  55. package/src/motion/scheduler.ts +159 -0
  56. package/src/motion/types.ts +132 -0
  57. package/src/render/timer.ts +1 -0
  58. package/src/signal/controller.ts +123 -0
  59. package/src/signal/integration.ts +42 -0
  60. package/src/signal/render.ts +178 -0
  61. package/src/theme/detect.ts +48 -0
  62. package/src/theme/tokens/index.ts +9 -0
  63. package/src/theme/tokens/mapping.ts +62 -0
  64. package/src/theme/tokens/types.ts +39 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * index.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Public surface of the motion engine. One scheduler, semantic events, data
5
+ * driven motion definitions, and an accessibility policy.
6
+ * ---------------------------------------------------------------------------
7
+ */
8
+
9
+ export type {
10
+ MotionChannel,
11
+ MotionColorRole,
12
+ MotionDef,
13
+ MotionEvent,
14
+ MotionGenerator,
15
+ MotionGeometry,
16
+ MotionLevel,
17
+ MotionLoop,
18
+ MotionPolicy,
19
+ MotionToggles,
20
+ } from "./types.ts";
21
+ export { DEFAULT_MOTION_POLICY } from "./types.ts";
22
+
23
+ export {
24
+ CADENCE_MS,
25
+ CHANNEL_MATRIX,
26
+ MOTION_CATALOG,
27
+ PREVIEW_INTERVAL_MS,
28
+ channelsForEvent,
29
+ defaultMotionFor,
30
+ eventUsesChannel,
31
+ getMotion,
32
+ isContinuous,
33
+ } from "./catalog.ts";
34
+
35
+ export {
36
+ allowedChannels,
37
+ allowsColorTransition,
38
+ cadenceFor,
39
+ describeMotionEvent,
40
+ effectiveLevel,
41
+ prefersAsciiGlyphs,
42
+ targetFps,
43
+ } from "./policy.ts";
44
+
45
+ export {
46
+ frameAt,
47
+ frameAtElapsed,
48
+ framesOf,
49
+ lanternGlow,
50
+ sweepPosition,
51
+ trailGlyph,
52
+ } from "./frames.ts";
53
+
54
+ export { MotionScheduler } from "./scheduler.ts";
55
+ export type { MotionConsumer, MotionSchedulerOptions } from "./scheduler.ts";
56
+
57
+ export {
58
+ filterMotions,
59
+ groupMotions,
60
+ previewStrip,
61
+ GALLERY_CATEGORIES,
62
+ } from "./gallery.ts";
63
+ export {
64
+ draftFromMotion,
65
+ motionFromDraft,
66
+ nudgeComposer,
67
+ composerPreview,
68
+ COMPOSER_FIELDS,
69
+ COMPOSER_EVENTS,
70
+ } from "./composer.ts";
71
+ export type { ComposerDraft, ComposerField } from "./composer.ts";
72
+ export {
73
+ policyFromEnvironment,
74
+ isMotionLevel,
75
+ MOTION_LEVELS,
76
+ describePolicy,
77
+ idleFps,
78
+ } from "./accessibility.ts";
@@ -0,0 +1,128 @@
1
+ /**
2
+ * policy.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Accessibility and performance policy for motion. Pure functions: the
5
+ * scheduler asks which channels may run, how fast, and whether a frame is
6
+ * worth painting at all.
7
+ * ---------------------------------------------------------------------------
8
+ */
9
+
10
+ import { CADENCE_MS, CHANNEL_MATRIX, PREVIEW_INTERVAL_MS } from "./catalog.ts";
11
+ import type { MotionChannel, MotionEvent, MotionLevel, MotionPolicy, MotionToggles } from "./types.ts";
12
+
13
+ const CHANNEL_TOGGLE: Record<MotionChannel, keyof MotionToggles> = {
14
+ workingGlyph: "state",
15
+ signal: "signal",
16
+ deckTransient: "transitions",
17
+ panelIndicator: "state",
18
+ borderEmphasis: "transitions",
19
+ ambient: "ambient",
20
+ };
21
+
22
+ /**
23
+ * Channels an event is allowed to drive under the current policy.
24
+ *
25
+ * - screen-reader mode and level "off" allow nothing
26
+ * - "functional" keeps only the channels that report state
27
+ * - "reduced" drops continuous sweeps and decorative ambient
28
+ * - a reduced-motion host preference behaves like "reduced" unless the user
29
+ * explicitly asked for full motion
30
+ */
31
+ export function allowedChannels(
32
+ event: MotionEvent,
33
+ policy: MotionPolicy,
34
+ ): MotionChannel[] {
35
+ if (policy.screenReader || policy.level === "off") return [];
36
+
37
+ const effective = effectiveLevel(policy);
38
+ return CHANNEL_MATRIX[event].filter((channel) => {
39
+ if (effective === "functional") {
40
+ return channel === "workingGlyph" || channel === "panelIndicator";
41
+ }
42
+ if (effective === "reduced") {
43
+ if (channel === "ambient" || channel === "signal") return false;
44
+ }
45
+ return policy.toggles[CHANNEL_TOGGLE[channel]];
46
+ });
47
+ }
48
+
49
+ /** A reduced-motion host preference downgrades full motion, never upgrades it. */
50
+ export function effectiveLevel(policy: MotionPolicy): MotionLevel {
51
+ if (policy.level === "off" || policy.screenReader) return "off";
52
+ if (policy.reducedMotion && policy.level === "full") return "reduced";
53
+ return policy.level;
54
+ }
55
+
56
+ export function cadenceFor(
57
+ channel: MotionChannel,
58
+ policy: MotionPolicy,
59
+ preview = false,
60
+ ): number {
61
+ if (preview) return PREVIEW_INTERVAL_MS;
62
+ const range = CADENCE_MS[channel];
63
+ if (effectiveLevel(policy) === "reduced") return range.max;
64
+ return Math.round((range.min + range.max) / 2);
65
+ }
66
+
67
+ /**
68
+ * Frames per second the shared scheduler should aim for.
69
+ *
70
+ * `consumers` are the channels something is actually subscribed to. An event
71
+ * that permits a channel nobody renders still costs 0 FPS.
72
+ */
73
+ export function targetFps(
74
+ policy: MotionPolicy,
75
+ allowed: readonly MotionChannel[],
76
+ consumers?: readonly MotionChannel[],
77
+ ): number {
78
+ if (policy.screenReader || policy.level === "off") return 0;
79
+ const live = consumers
80
+ ? allowed.filter((channel) => consumers.includes(channel))
81
+ : allowed;
82
+ if (live.length === 0) return 0;
83
+ if (live.length === 1 && live[0] === "ambient") return 3;
84
+ if (effectiveLevel(policy) === "reduced") return 3;
85
+ return 10;
86
+ }
87
+
88
+ /** Color transitions are suppressed without color; glyph motion may stay. */
89
+ export function allowsColorTransition(policy: MotionPolicy): boolean {
90
+ return !policy.noColor && !policy.screenReader && policy.level !== "off";
91
+ }
92
+
93
+ /** Glyph motion falls back to ASCII when Nerd glyphs are unavailable. */
94
+ export function prefersAsciiGlyphs(policy: MotionPolicy, hasNerdFonts: boolean): boolean {
95
+ return !hasNerdFonts || policy.lowColor;
96
+ }
97
+
98
+ /** Stable status text used when motion is off or a screen reader is active. */
99
+ export function describeMotionEvent(event: MotionEvent, tool?: string): string {
100
+ switch (event) {
101
+ case "idle":
102
+ return "idle";
103
+ case "thinking":
104
+ return "thinking";
105
+ case "streaming":
106
+ return "streaming";
107
+ case "tool.start":
108
+ return tool ? `running ${tool}` : "running tool";
109
+ case "tool.end":
110
+ return tool ? `${tool} done` : "tool done";
111
+ case "idea.capture":
112
+ return "idea captured";
113
+ case "skill.insert":
114
+ return "skill inserted";
115
+ case "policy.deny":
116
+ return "blocked by policy";
117
+ case "repair":
118
+ return "repairing";
119
+ case "compact":
120
+ return "compacting context";
121
+ case "success":
122
+ return "success";
123
+ case "warning":
124
+ return "warning";
125
+ case "error":
126
+ return "error";
127
+ }
128
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * scheduler.ts
3
+ * ---------------------------------------------------------------------------
4
+ * One clock for every animation in Wishcraft. Components subscribe a consumer
5
+ * per channel and receive tick numbers; they never own a timer themselves.
6
+ *
7
+ * Built on the existing coalescing timer, so a frame is a single scheduled
8
+ * render rather than an always-on interval. When the last consumer leaves, the
9
+ * timer is cancelled: an idle session costs zero frames.
10
+ * ---------------------------------------------------------------------------
11
+ */
12
+
13
+ import { createCoalescingTimer, type RenderScheduler } from "../render/timer.ts";
14
+ import { CADENCE_MS, PREVIEW_INTERVAL_MS } from "./catalog.ts";
15
+ import type { MotionChannel } from "./types.ts";
16
+
17
+ export interface MotionConsumer {
18
+ /** Stable id; re-subscribing with the same id replaces the consumer. */
19
+ id: string;
20
+ channel: MotionChannel;
21
+ /** Gallery previews may run hotter than production channels. */
22
+ preview?: boolean;
23
+ /** Interval override in ms; clamped to the channel's documented band. */
24
+ intervalMs?: number;
25
+ /** Finite motions stop themselves after this many ticks. */
26
+ maxTicks?: number;
27
+ onTick(tick: number, now: number): void;
28
+ onDone?(): void;
29
+ }
30
+
31
+ export interface MotionSchedulerOptions {
32
+ /** Ask the host to repaint after a batch of ticks. */
33
+ requestRender(): void;
34
+ /** Injectable for tests. Defaults to the shared coalescing timer. */
35
+ createTimer?(render: () => void, defaultDelayMs: number): RenderScheduler;
36
+ /** Injectable clock for tests. */
37
+ now?(): number;
38
+ }
39
+
40
+ const DEFAULT_DELAY_MS = 90;
41
+
42
+ export class MotionScheduler {
43
+ private readonly consumers = new Map<string, MotionConsumer>();
44
+ private readonly ticks = new Map<string, number>();
45
+ private readonly due = new Map<string, number>();
46
+ private readonly timer: RenderScheduler;
47
+ private readonly now: () => number;
48
+ private readonly requestRender: () => void;
49
+ private armed = false;
50
+
51
+ constructor(options: MotionSchedulerOptions) {
52
+ this.now = options.now ?? (() => Date.now());
53
+ this.requestRender = options.requestRender;
54
+ const create = options.createTimer ?? createCoalescingTimer;
55
+ this.timer = create(() => this.tick(), DEFAULT_DELAY_MS);
56
+ }
57
+
58
+ /** Subscribe a consumer and return its unsubscribe function. */
59
+ subscribe(consumer: MotionConsumer): () => void {
60
+ this.consumers.set(consumer.id, consumer);
61
+ this.ticks.set(consumer.id, 0);
62
+ this.due.set(consumer.id, this.now() + intervalFor(consumer));
63
+ this.arm();
64
+ return () => this.release(consumer.id);
65
+ }
66
+
67
+ /** Number of subscribed consumers. Zero means the clock is stopped. */
68
+ get activeCount(): number {
69
+ return this.consumers.size;
70
+ }
71
+
72
+ /** True while a frame is scheduled. */
73
+ get running(): boolean {
74
+ return this.armed;
75
+ }
76
+
77
+ /** Channels something is currently rendering. */
78
+ activeChannels(): MotionChannel[] {
79
+ return [...new Set([...this.consumers.values()].map((consumer) => consumer.channel))];
80
+ }
81
+
82
+ /** Delay until the next frame, or null when nothing is subscribed. */
83
+ nextDelay(): number | null {
84
+ if (this.consumers.size === 0) return null;
85
+ const now = this.now();
86
+ let soonest = Number.POSITIVE_INFINITY;
87
+ for (const consumer of this.consumers.values()) {
88
+ const due = this.due.get(consumer.id) ?? now;
89
+ soonest = Math.min(soonest, Math.max(0, due - now));
90
+ }
91
+ return soonest === Number.POSITIVE_INFINITY ? null : soonest;
92
+ }
93
+
94
+ /** Drop every consumer and cancel the clock. */
95
+ dispose(): void {
96
+ this.consumers.clear();
97
+ this.ticks.clear();
98
+ this.due.clear();
99
+ this.stop();
100
+ }
101
+
102
+ private release(id: string): void {
103
+ const consumer = this.consumers.get(id);
104
+ this.consumers.delete(id);
105
+ this.ticks.delete(id);
106
+ this.due.delete(id);
107
+ consumer?.onDone?.();
108
+ if (this.consumers.size === 0) this.stop();
109
+ }
110
+
111
+ private arm(): void {
112
+ const delay = this.nextDelay();
113
+ if (delay === null) return;
114
+ this.armed = true;
115
+ this.timer.schedule(delay);
116
+ }
117
+
118
+ private stop(): void {
119
+ this.armed = false;
120
+ this.timer.cancel();
121
+ }
122
+
123
+ private tick(): void {
124
+ this.armed = false;
125
+ if (this.consumers.size === 0) return;
126
+
127
+ const now = this.now();
128
+ let painted = 0;
129
+ const finished: string[] = [];
130
+
131
+ for (const consumer of this.consumers.values()) {
132
+ const due = this.due.get(consumer.id) ?? now;
133
+ if (now < due) continue;
134
+
135
+ const tick = (this.ticks.get(consumer.id) ?? 0) + 1;
136
+ this.ticks.set(consumer.id, tick);
137
+ this.due.set(consumer.id, now + intervalFor(consumer));
138
+ consumer.onTick(tick, now);
139
+ painted += 1;
140
+
141
+ if (consumer.maxTicks !== undefined && tick >= consumer.maxTicks) {
142
+ finished.push(consumer.id);
143
+ }
144
+ }
145
+
146
+ for (const id of finished) this.release(id);
147
+ if (painted > 0) this.requestRender();
148
+ if (this.consumers.size > 0) this.arm();
149
+ }
150
+ }
151
+
152
+ function intervalFor(consumer: MotionConsumer): number {
153
+ if (consumer.preview) return PREVIEW_INTERVAL_MS;
154
+ const band = CADENCE_MS[consumer.channel];
155
+ if (consumer.intervalMs === undefined) {
156
+ return Math.round((band.min + band.max) / 2);
157
+ }
158
+ return Math.min(band.max, Math.max(band.min, consumer.intervalMs));
159
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * types.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Semantic motion vocabulary for Wishcraft vNext. Events describe what the
5
+ * agent is doing; channels describe where that shows up. An event never has to
6
+ * use every channel.
7
+ * ---------------------------------------------------------------------------
8
+ */
9
+
10
+ export type MotionEvent =
11
+ | "idle"
12
+ | "thinking"
13
+ | "streaming"
14
+ | "tool.start"
15
+ | "tool.end"
16
+ | "idea.capture"
17
+ | "skill.insert"
18
+ | "policy.deny"
19
+ | "repair"
20
+ | "compact"
21
+ | "success"
22
+ | "warning"
23
+ | "error";
24
+
25
+ export type MotionChannel =
26
+ | "workingGlyph"
27
+ | "signal"
28
+ | "deckTransient"
29
+ | "panelIndicator"
30
+ | "borderEmphasis"
31
+ | "ambient";
32
+
33
+ export type MotionLevel = "full" | "reduced" | "functional" | "off";
34
+
35
+ export type MotionLoop = "while-active" | "finite" | "ambient";
36
+
37
+ export type MotionGeometry =
38
+ | "linear"
39
+ | "orbit"
40
+ | "wave"
41
+ | "bloom"
42
+ | "liquid"
43
+ | "ember"
44
+ | "stitch"
45
+ | "refract"
46
+ | "heat"
47
+ | "write"
48
+ | "path";
49
+
50
+ /**
51
+ * Token role a motion paints with. Kept as a string union rather than a
52
+ * concrete palette so the motion engine has no dependency on the theme layer.
53
+ */
54
+ export type MotionColorRole =
55
+ | "text"
56
+ | "textMuted"
57
+ | "primary"
58
+ | "secondary"
59
+ | "accent"
60
+ | "success"
61
+ | "warning"
62
+ | "error"
63
+ | "motionDim"
64
+ | "motionHot"
65
+ | "motionTrail";
66
+
67
+ export interface MotionGenerator {
68
+ geometry: MotionGeometry;
69
+ radius?: number;
70
+ trail?: number;
71
+ direction?: "forward" | "reverse";
72
+ clockwise?: boolean;
73
+ intervalMs: number;
74
+ ease: "linear" | "pulse" | "breathe";
75
+ }
76
+
77
+ export interface MotionDef {
78
+ id: string;
79
+ name: string;
80
+ category: "wishcraft" | "matrix" | "procedural" | "classic" | "custom";
81
+ kind: "frames" | "generator";
82
+ /** Channels this motion is allowed to drive. */
83
+ channels: MotionChannel[];
84
+ colorRole: MotionColorRole;
85
+ /** Used when Nerd fonts are unavailable or glyphs are forced to ASCII. */
86
+ fallbackGlyph: string;
87
+ loop: MotionLoop;
88
+ frames?: string[];
89
+ generator?: MotionGenerator;
90
+ description: string;
91
+ }
92
+
93
+ export interface MotionToggles {
94
+ /** Decorative ambient motion while nothing is happening. */
95
+ ambient: boolean;
96
+ /** State animations: working glyph and panel indicators. */
97
+ state: boolean;
98
+ /** Deck transitions and transient toasts. */
99
+ transitions: boolean;
100
+ /** The animated Signal sweep. */
101
+ signal: boolean;
102
+ /** Cursor effects. Off by default. */
103
+ cursor: boolean;
104
+ }
105
+
106
+ export interface MotionPolicy {
107
+ level: MotionLevel;
108
+ toggles: MotionToggles;
109
+ /** NO_COLOR is set: glyph motion may stay, color transitions must not. */
110
+ noColor: boolean;
111
+ /** Terminal cannot do truecolor; degrade gracefully. */
112
+ lowColor: boolean;
113
+ /** Screen-reader mode: motion off, status as stable text. */
114
+ screenReader: boolean;
115
+ /** Host reports a reduced-motion preference. */
116
+ reducedMotion: boolean;
117
+ }
118
+
119
+ export const DEFAULT_MOTION_POLICY: MotionPolicy = {
120
+ level: "full",
121
+ toggles: {
122
+ ambient: true,
123
+ state: true,
124
+ transitions: true,
125
+ signal: true,
126
+ cursor: false,
127
+ },
128
+ noColor: false,
129
+ lowColor: false,
130
+ screenReader: false,
131
+ reducedMotion: false,
132
+ };
@@ -31,6 +31,7 @@ export function createCoalescingTimer(render: () => void, defaultDelayMs: number
31
31
  render();
32
32
  }
33
33
  }, delayMs);
34
+ activeTimer.unref?.();
34
35
  },
35
36
  cancel() {
36
37
  if (activeTimer !== null) {
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Signal motion controller.
3
+ *
4
+ * It owns no timer. A controller leases one `signal` consumer from the shared
5
+ * MotionScheduler while Pi is active and releases it immediately at rest.
6
+ */
7
+
8
+ import {
9
+ allowedChannels,
10
+ defaultMotionFor,
11
+ getMotion,
12
+ type MotionEvent,
13
+ type MotionPolicy,
14
+ type MotionScheduler,
15
+ } from "../motion/index.ts";
16
+
17
+ export interface SignalRuntime {
18
+ event: MotionEvent;
19
+ motionId: string;
20
+ tick: number;
21
+ startedAt: number;
22
+ activity: string;
23
+ active: boolean;
24
+ release: (() => void) | null;
25
+ }
26
+
27
+ export function createSignalRuntime(now = Date.now()): SignalRuntime {
28
+ return {
29
+ event: "idle",
30
+ motionId: defaultMotionFor("idle"),
31
+ tick: 0,
32
+ startedAt: now,
33
+ activity: "ready",
34
+ active: false,
35
+ release: null,
36
+ };
37
+ }
38
+
39
+ export interface SetSignalEventOptions {
40
+ activity?: string;
41
+ motionId?: string;
42
+ /** Finite events stop after this many frames. */
43
+ maxTicks?: number;
44
+ }
45
+
46
+ export function setSignalEvent(
47
+ runtime: SignalRuntime,
48
+ scheduler: MotionScheduler,
49
+ policy: MotionPolicy,
50
+ event: MotionEvent,
51
+ options: SetSignalEventOptions = {},
52
+ ): void {
53
+ runtime.release?.();
54
+ runtime.release = null;
55
+ runtime.event = event;
56
+ runtime.motionId = options.motionId ?? defaultMotionFor(event);
57
+ runtime.tick = 0;
58
+ runtime.startedAt = Date.now();
59
+ runtime.activity = options.activity ?? activityForEvent(event);
60
+ runtime.active = event !== "idle";
61
+
62
+ if (
63
+ event === "idle" ||
64
+ !allowedChannels(event, policy).includes("signal")
65
+ ) {
66
+ runtime.active = false;
67
+ return;
68
+ }
69
+
70
+ const def = getMotion(runtime.motionId);
71
+ runtime.release = scheduler.subscribe({
72
+ id: "signal-live",
73
+ channel: "signal",
74
+ intervalMs: def?.generator?.intervalMs,
75
+ maxTicks: options.maxTicks,
76
+ onTick(tick) {
77
+ runtime.tick = tick;
78
+ },
79
+ onDone() {
80
+ runtime.release = null;
81
+ if (options.maxTicks !== undefined) runtime.active = false;
82
+ },
83
+ });
84
+ }
85
+
86
+ export function stopSignal(
87
+ runtime: SignalRuntime,
88
+ scheduler: MotionScheduler,
89
+ policy: MotionPolicy,
90
+ ): void {
91
+ setSignalEvent(runtime, scheduler, policy, "idle");
92
+ }
93
+
94
+ export function activityForEvent(event: MotionEvent): string {
95
+ switch (event) {
96
+ case "idle":
97
+ return "ready";
98
+ case "thinking":
99
+ return "thinking";
100
+ case "streaming":
101
+ return "streaming";
102
+ case "tool.start":
103
+ return "tool";
104
+ case "tool.end":
105
+ return "tool done";
106
+ case "idea.capture":
107
+ return "idea captured";
108
+ case "skill.insert":
109
+ return "skill inserted";
110
+ case "policy.deny":
111
+ return "blocked";
112
+ case "repair":
113
+ return "repairing";
114
+ case "compact":
115
+ return "compacting";
116
+ case "success":
117
+ return "done";
118
+ case "warning":
119
+ return "warning";
120
+ case "error":
121
+ return "error";
122
+ }
123
+ }
@@ -0,0 +1,42 @@
1
+ import type { MotionEvent } from "../motion/index.ts";
2
+ import { effectiveAppearanceMix, resolveAppearanceMix } from "../config/appearance.ts";
3
+ import type { AppearanceMixConfig } from "../config/types.ts";
4
+ import { config } from "../extension/core/state.ts";
5
+ import type { RuntimeState } from "../extension/core/types.ts";
6
+ import { setSignalEvent, stopSignal } from "./controller.ts";
7
+
8
+ export function dispatchSignalEvent(
9
+ rt: RuntimeState,
10
+ appearance: AppearanceMixConfig,
11
+ event: MotionEvent,
12
+ activity?: string,
13
+ ): void {
14
+ const resolved = resolveAppearanceMix(
15
+ effectiveAppearanceMix(appearance, config.preset),
16
+ );
17
+ setSignalEvent(rt.signal, rt.motionScheduler, rt.motionPolicy, event, {
18
+ activity,
19
+ motionId: resolved.motion[event] ?? resolved.signal.animation,
20
+ maxTicks: isFiniteEvent(event) ? 6 : undefined,
21
+ });
22
+ rt.lastLayoutResult = null;
23
+ rt.tuiRef?.requestRender();
24
+ }
25
+
26
+ export function settleSignal(rt: RuntimeState): void {
27
+ stopSignal(rt.signal, rt.motionScheduler, rt.motionPolicy);
28
+ rt.lastLayoutResult = null;
29
+ rt.tuiRef?.requestRender();
30
+ }
31
+
32
+ function isFiniteEvent(event: MotionEvent): boolean {
33
+ return (
34
+ event === "tool.end" ||
35
+ event === "idea.capture" ||
36
+ event === "skill.insert" ||
37
+ event === "policy.deny" ||
38
+ event === "success" ||
39
+ event === "warning" ||
40
+ event === "error"
41
+ );
42
+ }