@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,358 @@
1
+ import { matchesKey } from "@earendil-works/pi-tui";
2
+ import { STRUCTURAL_PRESET_NAMES } from "../../../config/types.ts";
3
+ import {
4
+ COMPOSER_FIELDS,
5
+ cycleAssignEvent,
6
+ draftFromMotion,
7
+ nudgeComposer,
8
+ type ComposerDraft,
9
+ type ComposerField,
10
+ } from "../../../motion/composer.ts";
11
+ import { filterMotions } from "../../../motion/gallery.ts";
12
+ import {
13
+ applyAppearanceBase,
14
+ applyMotionAssignment,
15
+ } from "../../settings/appearance-write.ts";
16
+ import {
17
+ insertSkillBody,
18
+ loadSkillCatalog,
19
+ readSkillBody,
20
+ } from "../../skills/skill-registry.ts";
21
+ import { writeSkillFromTemplate } from "../../skills/skill-templates.ts";
22
+ import {
23
+ applyOverlayQueryKey,
24
+ isOverlayPrintable,
25
+ } from "../overlay-chrome.ts";
26
+ import type { RuntimeState } from "../../core/types.ts";
27
+ import { appearanceDisplayName } from "../../../config/structural-presets.ts";
28
+ import { filterSkillRows, selectedGalleryMotion } from "./route-bodies.ts";
29
+ import { buildDeckSessionSnapshot } from "./session-snapshot.ts";
30
+ import { deckRouteByJump, deckRouteIndex } from "./routes.ts";
31
+ import { filterDeckRoutes, renderDeckFrame } from "./render.ts";
32
+ import { DECK_ROUTE_DEFS } from "./routes.ts";
33
+ import type { DeckNavState, DeckRoute } from "./types.ts";
34
+
35
+ function appearanceIndex(base: string): number {
36
+ const idx = (STRUCTURAL_PRESET_NAMES as readonly string[]).indexOf(base);
37
+ return idx >= 0 ? idx : 0;
38
+ }
39
+
40
+ export function createDeckNavState(
41
+ route: DeckRoute,
42
+ selectedAppearance = 0,
43
+ ): DeckNavState {
44
+ return {
45
+ route,
46
+ selectedNav: deckRouteIndex(route),
47
+ searchOpen: false,
48
+ searchQuery: "",
49
+ pendingJump: null,
50
+ selectedAppearance,
51
+ selectedMotion: 0,
52
+ selectedSkill: 0,
53
+ selectedIdea: 0,
54
+ composerOpen: false,
55
+ composerField: 0,
56
+ assignEvent: "streaming",
57
+ skillCreate: false,
58
+ skillCreateName: "",
59
+ };
60
+ }
61
+
62
+ export function createDeckComponent(
63
+ rt: RuntimeState,
64
+ ctx: any,
65
+ initialRoute: DeckRoute,
66
+ theme: import("@earendil-works/pi-coding-agent").Theme,
67
+ done: () => void,
68
+ ) {
69
+ const refreshSnapshot = () => buildDeckSessionSnapshot(rt, ctx);
70
+ let state = createDeckNavState(
71
+ initialRoute,
72
+ appearanceIndex(refreshSnapshot().appearanceBase),
73
+ );
74
+ let composer: ComposerDraft | null = null;
75
+
76
+ const setRoute = (route: DeckRoute): void => {
77
+ const jumpingToAppearance = route === "appearance" && state.route !== "appearance";
78
+ state = {
79
+ ...state,
80
+ route,
81
+ selectedNav: deckRouteIndex(route),
82
+ composerOpen: false,
83
+ searchOpen: false,
84
+ searchQuery: "",
85
+ selectedAppearance: jumpingToAppearance
86
+ ? appearanceIndex(refreshSnapshot().appearanceBase)
87
+ : state.selectedAppearance,
88
+ };
89
+ composer = null;
90
+ };
91
+
92
+ const notify = (ok: boolean, okText: string, failText: string) => {
93
+ ctx.ui.notify(ok ? okText : failText, ok ? "info" : "warning");
94
+ };
95
+
96
+ return {
97
+ focused: true,
98
+ invalidate() {},
99
+ render(width: number) {
100
+ return renderDeckFrame(
101
+ theme,
102
+ width,
103
+ refreshSnapshot(),
104
+ state,
105
+ rt.resolvedShortcuts,
106
+ composer,
107
+ Date.now(),
108
+ );
109
+ },
110
+ handleInput(data: string) {
111
+ if (matchesKey(data, "escape")) {
112
+ if (state.composerOpen) {
113
+ state = { ...state, composerOpen: false };
114
+ composer = null;
115
+ return;
116
+ }
117
+ if (state.skillCreate) {
118
+ state = { ...state, skillCreate: false, skillCreateName: "" };
119
+ return;
120
+ }
121
+ if (state.searchOpen) {
122
+ state = { ...state, searchOpen: false, searchQuery: "" };
123
+ return;
124
+ }
125
+ done();
126
+ return;
127
+ }
128
+
129
+ if (data === "/" && !state.composerOpen) {
130
+ state = { ...state, searchOpen: true };
131
+ return;
132
+ }
133
+
134
+ if (state.searchOpen) {
135
+ handleSearch(data);
136
+ return;
137
+ }
138
+
139
+ if (data === "g" && !state.composerOpen) {
140
+ state = { ...state, pendingJump: "g" };
141
+ return;
142
+ }
143
+
144
+ if (state.pendingJump === "g" && data.length === 1 && isOverlayPrintable(data)) {
145
+ const route = deckRouteByJump(data);
146
+ state = { ...state, pendingJump: null };
147
+ if (route) setRoute(route);
148
+ return;
149
+ }
150
+
151
+ state = { ...state, pendingJump: null };
152
+
153
+ if (state.composerOpen && composer) {
154
+ handleComposer(data);
155
+ return;
156
+ }
157
+
158
+ if (state.route === "appearance") {
159
+ if (handleList(data, "selectedAppearance", STRUCTURAL_PRESET_NAMES.length)) return;
160
+ if (matchesKey(data, "enter")) {
161
+ const name = STRUCTURAL_PRESET_NAMES[state.selectedAppearance];
162
+ if (name) {
163
+ const ok = applyAppearanceBase(rt, ctx.cwd ?? process.cwd(), name);
164
+ const label = appearanceDisplayName(name);
165
+ notify(ok, `Appearance: ${label}`, `Appearance: ${label} (not persisted)`);
166
+ }
167
+ return;
168
+ }
169
+ }
170
+
171
+ if (state.route === "motion") {
172
+ const count = filterMotions(state.searchQuery).length;
173
+ if (handleList(data, "selectedMotion", count)) return;
174
+ if (data === "t") {
175
+ state = { ...state, assignEvent: cycleAssignEvent(state.assignEvent) };
176
+ return;
177
+ }
178
+ if (data === "e") {
179
+ const motion = selectedGalleryMotion(state);
180
+ if (motion) {
181
+ composer = draftFromMotion(motion, state.assignEvent);
182
+ state = { ...state, composerOpen: true, composerField: 0 };
183
+ }
184
+ return;
185
+ }
186
+ if (matchesKey(data, "enter")) {
187
+ const motion = selectedGalleryMotion(state);
188
+ if (motion) {
189
+ const ok = applyMotionAssignment(
190
+ rt,
191
+ ctx.cwd ?? process.cwd(),
192
+ state.assignEvent,
193
+ motion.id,
194
+ );
195
+ notify(
196
+ ok,
197
+ `Motion ${state.assignEvent}: ${motion.id}`,
198
+ `Motion ${motion.id} (not persisted)`,
199
+ );
200
+ }
201
+ return;
202
+ }
203
+ }
204
+
205
+ if (state.route === "skills") {
206
+ if (state.skillCreate) {
207
+ if (matchesKey(data, "enter")) {
208
+ const name = state.skillCreateName.trim();
209
+ if (!name) return;
210
+ try {
211
+ const { filePath } = writeSkillFromTemplate(name, "standard");
212
+ state = { ...state, skillCreate: false, skillCreateName: "" };
213
+ ctx.ui.notify(`Created ${name}. ${filePath}`, "info");
214
+ } catch (error) {
215
+ ctx.ui.notify(
216
+ error instanceof Error ? error.message : String(error),
217
+ "warning",
218
+ );
219
+ }
220
+ return;
221
+ }
222
+ if (matchesKey(data, "backspace")) {
223
+ state = { ...state, skillCreateName: state.skillCreateName.slice(0, -1) };
224
+ return;
225
+ }
226
+ if (isOverlayPrintable(data)) {
227
+ state = { ...state, skillCreateName: state.skillCreateName + data };
228
+ return;
229
+ }
230
+ return;
231
+ }
232
+ if (data === "n") {
233
+ state = { ...state, skillCreate: true, skillCreateName: "" };
234
+ return;
235
+ }
236
+ const snapshot = refreshSnapshot();
237
+ const rows = filterSkillRows(snapshot.skills, state.searchQuery);
238
+ if (handleList(data, "selectedSkill", rows.length)) return;
239
+ if (matchesKey(data, "enter")) {
240
+ const selected = rows[state.selectedSkill];
241
+ if (!selected) return;
242
+ const cwd = ctx.cwd ?? process.cwd();
243
+ const entry = loadSkillCatalog(cwd).find((skill) => skill.name === selected.name);
244
+ if (!entry) {
245
+ ctx.ui.notify(`Skill ${selected.name} not found`, "warning");
246
+ return;
247
+ }
248
+ insertSkillBody(ctx, entry.name, readSkillBody(entry.filePath));
249
+ ctx.ui.notify(`Inserted skill ${entry.name}`, "info");
250
+ done();
251
+ return;
252
+ }
253
+ }
254
+
255
+ if (state.route === "ideas") {
256
+ if (handleList(data, "selectedIdea", refreshSnapshot().ideas.length)) return;
257
+ }
258
+
259
+ if (matchesKey(data, "up")) {
260
+ const next = Math.max(0, state.selectedNav - 1);
261
+ setRoute(DECK_ROUTE_DEFS[next]?.id ?? state.route);
262
+ return;
263
+ }
264
+ if (matchesKey(data, "down")) {
265
+ const next = Math.min(DECK_ROUTE_DEFS.length - 1, state.selectedNav + 1);
266
+ setRoute(DECK_ROUTE_DEFS[next]?.id ?? state.route);
267
+ return;
268
+ }
269
+
270
+ if (data === "?") {
271
+ setRoute("shortcuts");
272
+ }
273
+ },
274
+ dispose() {},
275
+ };
276
+
277
+ function handleList(
278
+ data: string,
279
+ key: "selectedAppearance" | "selectedMotion" | "selectedSkill" | "selectedIdea",
280
+ length: number,
281
+ ): boolean {
282
+ if (length <= 0) return false;
283
+ if (matchesKey(data, "up")) {
284
+ state = { ...state, [key]: Math.max(0, state[key] - 1) };
285
+ return true;
286
+ }
287
+ if (matchesKey(data, "down")) {
288
+ state = { ...state, [key]: Math.min(length - 1, state[key] + 1) };
289
+ return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ function handleSearch(data: string): void {
295
+ const next = applyOverlayQueryKey(state.searchQuery, data);
296
+ if (next !== null) {
297
+ state = { ...state, searchQuery: next, selectedMotion: 0, selectedSkill: 0 };
298
+ if (state.route !== "motion" && state.route !== "skills") {
299
+ const matches = filterDeckRoutes(next);
300
+ if (matches.length === 1) {
301
+ state = { ...state, searchOpen: false, searchQuery: "" };
302
+ setRoute(matches[0]!);
303
+ }
304
+ }
305
+ return;
306
+ }
307
+ if (matchesKey(data, "enter")) {
308
+ if (state.route === "motion" || state.route === "skills") {
309
+ state = { ...state, searchOpen: false };
310
+ return;
311
+ }
312
+ const matches = filterDeckRoutes(state.searchQuery);
313
+ if (matches[0]) {
314
+ state = { ...state, searchOpen: false, searchQuery: "" };
315
+ setRoute(matches[0]);
316
+ }
317
+ }
318
+ }
319
+
320
+ function handleComposer(data: string): void {
321
+ if (!composer) return;
322
+ if (matchesKey(data, "up")) {
323
+ state = { ...state, composerField: Math.max(0, state.composerField - 1) };
324
+ return;
325
+ }
326
+ if (matchesKey(data, "down")) {
327
+ state = {
328
+ ...state,
329
+ composerField: Math.min(COMPOSER_FIELDS.length - 1, state.composerField + 1),
330
+ };
331
+ return;
332
+ }
333
+ const field = COMPOSER_FIELDS[state.composerField] as ComposerField;
334
+ if (matchesKey(data, "left")) {
335
+ composer = nudgeComposer(composer, field, -1);
336
+ return;
337
+ }
338
+ if (matchesKey(data, "right")) {
339
+ composer = nudgeComposer(composer, field, 1);
340
+ return;
341
+ }
342
+ if (matchesKey(data, "enter")) {
343
+ const ok = applyMotionAssignment(
344
+ rt,
345
+ ctx.cwd ?? process.cwd(),
346
+ composer.assignEvent,
347
+ composer.id,
348
+ );
349
+ notify(
350
+ ok,
351
+ `Motion ${composer.assignEvent}: ${composer.id}`,
352
+ `Motion ${composer.id} (not persisted)`,
353
+ );
354
+ state = { ...state, composerOpen: false, assignEvent: composer.assignEvent };
355
+ composer = null;
356
+ }
357
+ }
358
+ }
@@ -0,0 +1,34 @@
1
+ import type { RuntimeState } from "../../core/types.ts";
2
+ import { createDeckComponent } from "./component.ts";
3
+ import type { DeckRoute } from "./types.ts";
4
+
5
+ export type { DeckRoute } from "./types.ts";
6
+ export { DECK_ROUTES } from "./types.ts";
7
+ export { parseDeckRouteArg, deckRouteByJump } from "./routes.ts";
8
+ export { renderDeckFrame, filterDeckRoutes, deckFooter } from "./render.ts";
9
+ export { buildDeckSessionSnapshot } from "./session-snapshot.ts";
10
+
11
+ /** Open the unified Wishcraft Deck at the requested route. */
12
+ export async function openWishcraftDeck(
13
+ rt: RuntimeState,
14
+ ctx: any,
15
+ route: DeckRoute = "home",
16
+ ): Promise<void> {
17
+ if (!rt.enabled || !ctx.hasUI) {
18
+ ctx.ui.notify("Signal UI is disabled", "info");
19
+ return;
20
+ }
21
+ rt.currentCtx = ctx;
22
+
23
+ await ctx.ui.custom(
24
+ (_tui: any, theme: any, _keybindings: any, done: () => void) =>
25
+ createDeckComponent(rt, ctx, route, theme, done),
26
+ {
27
+ overlay: true,
28
+ overlayOptions: () => ({
29
+ verticalAlign: "center",
30
+ horizontalAlign: "center",
31
+ }),
32
+ },
33
+ );
34
+ }
@@ -0,0 +1,260 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { MOTION_CATALOG } from "../../../motion/catalog.ts";
4
+ import { appearanceDisplayName } from "../../../config/structural-presets.ts";
5
+ import { PRESETS } from "../../../config/presets.ts";
6
+ import { config } from "../../core/state.ts";
7
+ import type { PowerlineShortcuts } from "../../core/types.ts";
8
+ import type { ComposerDraft } from "../../../motion/composer.ts";
9
+ import { DECK_ROUTE_DEFS } from "./routes.ts";
10
+ import type { DeckNavState, DeckRoute, DeckSessionSnapshot } from "./types.ts";
11
+ import {
12
+ appearanceLines,
13
+ guardrailLines,
14
+ ideasLines,
15
+ motionGalleryLines,
16
+ skillsWorkbenchLines,
17
+ } from "./route-bodies.ts";
18
+
19
+ function bar(percent: number, width: number): string {
20
+ const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width);
21
+ return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
22
+ }
23
+
24
+ function routeTitle(route: DeckRoute): string {
25
+ return DECK_ROUTE_DEFS.find((entry) => entry.id === route)?.label ?? "Home";
26
+ }
27
+
28
+ export function deckFooter(state: DeckNavState): string {
29
+ if (state.searchOpen) return `/ ${state.searchQuery}_`;
30
+ if (state.composerOpen) return "←→ nudge · ↑↓ field · enter apply · esc back";
31
+ if (state.skillCreate) return "type a name · enter create · esc cancel";
32
+ switch (state.route) {
33
+ case "appearance":
34
+ return "↑↓ select base · enter apply · / Search · g h Home · Esc Close";
35
+ case "motion":
36
+ return "↑↓ motion · t event · e composer · enter apply · Esc Close";
37
+ case "skills":
38
+ return "↑↓ skill · enter insert · n new · / filter · Esc Close";
39
+ case "ideas":
40
+ return "↑↓ idea · / Search · g h Home · Esc Close";
41
+ default:
42
+ return "/ Search g h Home g s Signal g i Ideas ? Help Esc Close";
43
+ }
44
+ }
45
+
46
+ export function renderDeckFrame(
47
+ theme: Theme,
48
+ width: number,
49
+ snapshot: DeckSessionSnapshot,
50
+ state: DeckNavState,
51
+ shortcuts: PowerlineShortcuts,
52
+ composer: ComposerDraft | null = null,
53
+ tick = Date.now(),
54
+ ): string[] {
55
+ const inner = Math.max(40, width - 2);
56
+ const border = (text: string) => theme.fg("dim", text);
57
+ const wrap = (text: string) =>
58
+ `${border("│")}${truncateToWidth(text, inner, "…", true)}${border("│")}`;
59
+
60
+ const leftW = Math.max(14, Math.floor(inner * 0.18));
61
+ const rightW = Math.max(18, Math.floor(inner * 0.24));
62
+ const centerW = Math.max(20, inner - leftW - rightW - 4);
63
+
64
+ const header = ` ◈ ${snapshot.modelLabel} ${snapshot.branchLabel} context ${snapshot.contextPercent}% ${snapshot.signalActivity.toUpperCase()} `;
65
+ const lines: string[] = [];
66
+ lines.push(border(`╭${"─".repeat(inner)}╮`));
67
+ lines.push(wrap(theme.fg("accent", theme.bold(truncateToWidth(header, inner, "…", true)))));
68
+ lines.push(border(`├${"─".repeat(inner)}┤`));
69
+
70
+ const nav = navLines(snapshot, state, theme, leftW);
71
+ const center = centerRouteBody(snapshot, state, theme, centerW, shortcuts, composer, tick);
72
+ const right = rightRail(snapshot, theme, rightW);
73
+
74
+ const rowCount = Math.max(nav.length, center.length, right.length, 8);
75
+ for (let i = 0; i < rowCount; i++) {
76
+ const left = padCol(nav[i] ?? "", leftW);
77
+ const mid = padCol(center[i] ?? "", centerW);
78
+ const rightCol = padCol(right[i] ?? "", rightW);
79
+ lines.push(wrap(`${left} ${mid} ${rightCol}`));
80
+ }
81
+
82
+ lines.push(border(`├${"─".repeat(inner)}┤`));
83
+ const footer = deckFooter(state);
84
+ lines.push(wrap(theme.fg("dim", truncateToWidth(footer, inner, "…", true))));
85
+ lines.push(border(`╰${"─".repeat(inner)}╯`));
86
+ return lines;
87
+ }
88
+
89
+ function navLines(
90
+ snapshot: DeckSessionSnapshot,
91
+ state: DeckNavState,
92
+ theme: Theme,
93
+ width: number,
94
+ ): string[] {
95
+ const lines: string[] = [theme.fg("accent", "NAVIGATION")];
96
+ for (const route of DECK_ROUTE_DEFS) {
97
+ const active = route.id === state.route;
98
+ const marker = active ? "◉" : "◇";
99
+ let suffix = "";
100
+ if (route.id === "skills") suffix = ` ${snapshot.skillsTotal}`;
101
+ if (route.id === "ideas") suffix = ` ${snapshot.ideaCount}`;
102
+ lines.push(
103
+ theme.fg(
104
+ active ? "accent" : "muted",
105
+ truncateToWidth(`${marker} ${route.label}${suffix}`, width, "…", true),
106
+ ),
107
+ );
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ function centerRouteBody(
113
+ snapshot: DeckSessionSnapshot,
114
+ state: DeckNavState,
115
+ theme: Theme,
116
+ width: number,
117
+ shortcuts: PowerlineShortcuts,
118
+ composer: ComposerDraft | null = null,
119
+ tick = Date.now(),
120
+ ): string[] {
121
+ const title = theme.fg("accent", `ACTIVE ROUTE: ${routeTitle(state.route).toUpperCase()}`);
122
+ const body: string[] = [title, ""];
123
+ switch (state.route) {
124
+ case "home":
125
+ body.push(theme.fg("text", "CURRENT SESSION"));
126
+ body.push(
127
+ theme.fg(
128
+ "accent",
129
+ truncateToWidth(`◆ ${snapshot.signalActivity}`, width, "…", true),
130
+ ),
131
+ );
132
+ body.push(
133
+ truncateToWidth(
134
+ `${bar(snapshot.contextPercent, Math.min(18, width - 12))} ${snapshot.contextPercent}% (${formatK(snapshot.contextTokens)} / ${formatK(snapshot.contextWindow)})`,
135
+ width,
136
+ "…",
137
+ true,
138
+ ),
139
+ );
140
+ body.push(
141
+ truncateToWidth(
142
+ `${appearanceDisplayName(snapshot.appearanceBase)} · ${snapshot.signalMotion} · ${snapshot.motionLevel}`,
143
+ width,
144
+ "…",
145
+ true,
146
+ ),
147
+ );
148
+ body.push("");
149
+ body.push(theme.fg("text", "NEXT INTENT"));
150
+ body.push(
151
+ truncateToWidth(snapshot.nextIntent ?? "No queued intent", width, "…", true),
152
+ );
153
+ break;
154
+ case "signal":
155
+ body.push("THREE LANES");
156
+ body.push("Identity · Activity · Context");
157
+ body.push(`Layout ${config.preset} · placement ${config.placement}`);
158
+ body.push(
159
+ `Base ${appearanceDisplayName(snapshot.appearanceBase)} · motion ${snapshot.signalMotion}`,
160
+ );
161
+ body.push(`Level ${snapshot.motionLevel} · activity ${snapshot.signalActivity}`);
162
+ body.push("Use /signal preset · placement · doctor");
163
+ break;
164
+ case "skills":
165
+ body.push(...skillsWorkbenchLines(snapshot, state, width));
166
+ break;
167
+ case "ideas":
168
+ body.push(...ideasLines(snapshot, state));
169
+ break;
170
+ case "guardrails":
171
+ body.push(...guardrailLines(snapshot));
172
+ break;
173
+ case "shell":
174
+ body.push(`Bash mode: ${snapshot.bashModeActive ? "on" : "off"}`);
175
+ body.push(`Shell: ${snapshot.shellName ?? "not started"}`);
176
+ body.push("Toggle with /bash-mode");
177
+ break;
178
+ case "usage":
179
+ body.push(
180
+ `Context ${snapshot.contextPercent}% · ${formatK(snapshot.contextTokens)} tokens`,
181
+ );
182
+ body.push("Open /usage for detailed overlay");
183
+ break;
184
+ case "appearance":
185
+ body.push(...appearanceLines(snapshot, state));
186
+ break;
187
+ case "motion":
188
+ body.push(...motionGalleryLines(snapshot, state, width, composer, tick));
189
+ break;
190
+ case "shortcuts":
191
+ body.push(`Menu: ${shortcuts.menu ?? "alt+p"}`);
192
+ body.push(`Queue: ${shortcuts.queueOpen ?? "ctrl+alt+q"}`);
193
+ body.push(`Info: ${shortcuts.info ?? "alt+i"}`);
194
+ body.push("g <key> jumps inside the Deck");
195
+ break;
196
+ case "diagnostics":
197
+ body.push("Run /signal doctor for the full environment report");
198
+ body.push(snapshot.policySummary);
199
+ body.push(`Preset: ${config.preset} · base ${snapshot.appearanceBase}`);
200
+ body.push(`Catalog: ${MOTION_CATALOG.length} motions`);
201
+ break;
202
+ }
203
+ return body.map((line) => truncateToWidth(line, width, "…", true));
204
+ }
205
+
206
+ function rightRail(snapshot: DeckSessionSnapshot, theme: Theme, width: number): string[] {
207
+ const lines = [theme.fg("accent", "ACTIVITY FEED"), ""];
208
+ if (snapshot.recentActivity.length === 0) {
209
+ lines.push(theme.fg("muted", "No recent activity"));
210
+ } else {
211
+ for (const item of snapshot.recentActivity) {
212
+ lines.push(truncateToWidth(`· ${item}`, width, "…", true));
213
+ }
214
+ }
215
+ lines.push("");
216
+ lines.push(theme.fg("accent", "SKILLS HEALTH"));
217
+ lines.push(`✓ ${snapshot.skillsTotal - snapshot.skillsWarnings} healthy`);
218
+ if (snapshot.skillsWarnings > 0) {
219
+ lines.push(theme.fg("warning", `! ${snapshot.skillsWarnings} warnings`));
220
+ }
221
+ lines.push("");
222
+ lines.push(theme.fg("accent", "GUARDRAILS"));
223
+ lines.push(
224
+ truncateToWidth(
225
+ `Policy: ${snapshot.policyEnabled ? "ON" : "OFF"} (${snapshot.policyRuleCount})`,
226
+ width,
227
+ "…",
228
+ true,
229
+ ),
230
+ );
231
+ return lines;
232
+ }
233
+
234
+ function padCol(text: string, width: number): string {
235
+ const plain = stripAnsi(text);
236
+ if (plain.length >= width) return truncateToWidth(text, width, "…", true);
237
+ return text + " ".repeat(width - plain.length);
238
+ }
239
+
240
+ function stripAnsi(text: string): string {
241
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
242
+ }
243
+
244
+ function formatK(value: number): string {
245
+ if (value >= 1000) return `${Math.round(value / 1000)}k`;
246
+ return String(value);
247
+ }
248
+
249
+ export function filterDeckRoutes(query: string): DeckRoute[] {
250
+ const q = query.trim().toLowerCase();
251
+ if (!q) return DECK_ROUTE_DEFS.map((route) => route.id);
252
+ return DECK_ROUTE_DEFS.filter((route) => {
253
+ const hay = `${route.label} ${route.id} ${route.description}`.toLowerCase();
254
+ return hay.includes(q);
255
+ }).map((route) => route.id);
256
+ }
257
+
258
+ export function layoutPresetNames(): string[] {
259
+ return Object.keys(PRESETS);
260
+ }