@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,209 @@
1
+ /**
2
+ * Center-pane bodies for Craft, Motion Gallery, and Composer.
3
+ * Pure: given snapshot + nav + optional composer draft, return lines.
4
+ */
5
+
6
+ import {
7
+ COMPOSER_FIELDS,
8
+ composerPreview,
9
+ type ComposerDraft,
10
+ } from "../../../motion/composer.ts";
11
+ import { filterMotions, GALLERY_CATEGORIES, previewStrip } from "../../../motion/gallery.ts";
12
+ import { getMotion } from "../../../motion/catalog.ts";
13
+ import { STRUCTURAL_PRESET_NAMES } from "../../../config/types.ts";
14
+ import {
15
+ appearanceDisplayName,
16
+ getStructuralPreset,
17
+ } from "../../../config/structural-presets.ts";
18
+ import { config } from "../../core/state.ts";
19
+ import type { DeckNavState, DeckSessionSnapshot, DeckSkillRow } from "./types.ts";
20
+
21
+ export function filterSkillRows(skills: readonly DeckSkillRow[], query: string): DeckSkillRow[] {
22
+ const q = query.trim().toLowerCase();
23
+ if (!q) return [...skills];
24
+ return skills.filter((skill) =>
25
+ `${skill.name} ${skill.category} ${skill.description}`.toLowerCase().includes(q),
26
+ );
27
+ }
28
+
29
+ export function appearanceLines(
30
+ snapshot: DeckSessionSnapshot,
31
+ state: DeckNavState,
32
+ ): string[] {
33
+ const selected = STRUCTURAL_PRESET_NAMES[state.selectedAppearance];
34
+ const selectedDef = selected ? getStructuralPreset(selected) : null;
35
+ const lines = [
36
+ `Active: ${appearanceDisplayName(snapshot.appearanceBase)}`,
37
+ `Layout ${config.preset} · motion ${snapshot.motionLevel}`,
38
+ selectedDef ? selectedDef.description : "Enter applies the structural base to Signal.",
39
+ ];
40
+ const cursor = state.selectedAppearance;
41
+ for (let i = 0; i < STRUCTURAL_PRESET_NAMES.length; i++) {
42
+ const name = STRUCTURAL_PRESET_NAMES[i]!;
43
+ const preset = getStructuralPreset(name);
44
+ const marker = i === cursor ? "→" : " ";
45
+ const star = name === snapshot.appearanceBase ? "*" : " ";
46
+ lines.push(`${marker}${star} ${preset.displayName.padEnd(14)} ${name}`);
47
+ }
48
+ return lines;
49
+ }
50
+
51
+ export function motionGalleryLines(
52
+ snapshot: DeckSessionSnapshot,
53
+ state: DeckNavState,
54
+ width: number,
55
+ composer: ComposerDraft | null,
56
+ tick = Date.now(),
57
+ ): string[] {
58
+ if (state.composerOpen && composer) {
59
+ return composerLines(composer, state, width, tick);
60
+ }
61
+ const query = state.route === "motion" ? state.searchQuery : "";
62
+ const motions = filterMotions(query);
63
+ const cursor = Math.min(state.selectedMotion, Math.max(0, motions.length - 1));
64
+ const selected = motions[cursor];
65
+ const counts = GALLERY_CATEGORIES.map((category) => {
66
+ const n = motions.filter((entry) => entry.category === category).length;
67
+ return n > 0 ? `${category} ${n}` : "";
68
+ }).filter(Boolean);
69
+ const frameTick = Math.floor(tick / (selected ? (selected.generator?.intervalMs ?? 100) : 100));
70
+ const lines = [
71
+ `${motions.length} motions · ${counts.join(" · ")}`,
72
+ `assign ${state.assignEvent} · live ${snapshot.signalMotion}`,
73
+ selected
74
+ ? previewStrip(selected, frameTick, Math.min(28, width - 2))
75
+ : "No motions match",
76
+ "↑↓ select · t event · e composer · enter apply",
77
+ ];
78
+ const window = 8;
79
+ const start = Math.max(0, cursor - 3);
80
+ const end = Math.min(motions.length, start + window);
81
+ for (let i = start; i < end; i++) {
82
+ const def = motions[i]!;
83
+ const marker = i === cursor ? "→" : " ";
84
+ lines.push(`${marker} ${def.category.padEnd(10)} ${def.name}`);
85
+ }
86
+ if (selected) {
87
+ lines.push("");
88
+ lines.push(selected.description);
89
+ }
90
+ return lines;
91
+ }
92
+
93
+ function composerLines(
94
+ draft: ComposerDraft,
95
+ state: DeckNavState,
96
+ width: number,
97
+ tick = Date.now(),
98
+ ): string[] {
99
+ const frameTick = Math.floor(tick / draft.intervalMs);
100
+ const field = COMPOSER_FIELDS[state.composerField] ?? "intervalMs";
101
+ const lines = [
102
+ `COMPOSER · ${draft.name}`,
103
+ composerPreview(draft, frameTick, Math.min(28, width - 2)),
104
+ `Assign to ${draft.assignEvent}`,
105
+ "←→ nudge · ↑↓ field · enter apply · esc back",
106
+ ];
107
+ for (const name of COMPOSER_FIELDS) {
108
+ const marker = name === field ? "→" : " ";
109
+ const value =
110
+ name === "geometry"
111
+ ? draft.geometry
112
+ : name === "intervalMs"
113
+ ? `${draft.intervalMs}ms`
114
+ : name === "trail"
115
+ ? String(draft.trail)
116
+ : name === "direction"
117
+ ? draft.direction
118
+ : name === "ease"
119
+ ? draft.ease
120
+ : draft.assignEvent;
121
+ lines.push(`${marker} ${name}: ${value}`);
122
+ }
123
+ return lines;
124
+ }
125
+
126
+ export function skillsWorkbenchLines(
127
+ snapshot: DeckSessionSnapshot,
128
+ state: DeckNavState,
129
+ width: number,
130
+ ): string[] {
131
+ const query = state.route === "skills" ? state.searchQuery : "";
132
+ const rows = filterSkillRows(snapshot.skills, query);
133
+ const cursor = Math.min(state.selectedSkill, Math.max(0, rows.length - 1));
134
+ const selected = rows[cursor];
135
+ if (state.skillCreate) {
136
+ return [
137
+ "NEW SKILL",
138
+ `Name: ${state.skillCreateName}_`,
139
+ "Writes ~/.pi/agent/skills/<name>/SKILL.md from the standard template.",
140
+ "type a name · enter create · esc cancel",
141
+ ];
142
+ }
143
+ const lines = [
144
+ `WORKBENCH · ${snapshot.skillsTotal} skills · ${snapshot.skillsWarnings} warnings`,
145
+ "↑↓ select · enter insert · n new skill",
146
+ ];
147
+ if (rows.length === 0) {
148
+ lines.push(query ? "No skills match this search" : "No skills loaded");
149
+ return lines;
150
+ }
151
+ const start = Math.max(0, cursor - 4);
152
+ const end = Math.min(rows.length, start + 8);
153
+ for (let i = start; i < end; i++) {
154
+ const skill = rows[i]!;
155
+ const marker = i === cursor ? "→" : " ";
156
+ const mark = skill.status === "ok" ? "✓" : skill.status === "fail" ? "✗" : "!";
157
+ const spark = "#".repeat(Math.min(6, skill.usage)) + "·".repeat(Math.max(0, 6 - Math.min(6, skill.usage)));
158
+ lines.push(`${marker}${mark} ${skill.name} · ${skill.category} ${spark}`);
159
+ }
160
+ if (selected) {
161
+ lines.push("");
162
+ lines.push(`${selected.name} [${selected.status}]`);
163
+ lines.push(selected.description || "No description");
164
+ }
165
+ return lines;
166
+ }
167
+
168
+ export function ideasLines(snapshot: DeckSessionSnapshot, state: DeckNavState): string[] {
169
+ const lines = [
170
+ `${snapshot.ideaCount} ideas · ${snapshot.queueCount} queued`,
171
+ "Capture with # or /ideas",
172
+ ];
173
+ if (snapshot.ideas.length === 0) {
174
+ lines.push("No captured ideas");
175
+ return lines;
176
+ }
177
+ const cursor = Math.min(state.selectedIdea, snapshot.ideas.length - 1);
178
+ snapshot.ideas.forEach((idea, i) => {
179
+ const marker = i === cursor ? "→" : " ";
180
+ lines.push(`${marker}[${idea.reviewStatus}] ${idea.text}`);
181
+ });
182
+ return lines;
183
+ }
184
+
185
+ export function guardrailLines(snapshot: DeckSessionSnapshot): string[] {
186
+ const lines = [
187
+ `Policy: ${snapshot.policyEnabled ? "ENABLED" : "OFF"} (${snapshot.policyRuleCount} rules)`,
188
+ snapshot.policySummary,
189
+ ];
190
+ if (snapshot.guardrailRules.length === 0) {
191
+ lines.push("No declarative rules in wishcraft.policy");
192
+ return lines;
193
+ }
194
+ for (const rule of snapshot.guardrailRules) {
195
+ lines.push(`${rule.action} ${rule.tool} · ${rule.reason}`);
196
+ }
197
+ return lines;
198
+ }
199
+
200
+ export function selectedGalleryMotion(state: DeckNavState) {
201
+ const motions = filterMotions(state.route === "motion" ? state.searchQuery : "");
202
+ return motions[Math.min(state.selectedMotion, Math.max(0, motions.length - 1))] ?? getMotion("ember-relay");
203
+ }
204
+
205
+ export function filteredSkillCount(snapshot: DeckSessionSnapshot, state: DeckNavState): number {
206
+ if (state.route !== "skills") return snapshot.skills.length;
207
+ return filterSkillRows(snapshot.skills, state.searchQuery).length;
208
+ }
209
+
@@ -0,0 +1,37 @@
1
+ import type { DeckRoute, DeckRouteDef } from "./types.ts";
2
+ import { DECK_ROUTES } from "./types.ts";
3
+
4
+ export const DECK_ROUTE_DEFS: readonly DeckRouteDef[] = [
5
+ { id: "home", label: "Home", jumpKey: "h", description: "Session overview and next intent" },
6
+ { id: "signal", label: "Signal", jumpKey: "s", description: "Lane layout and live activity" },
7
+ { id: "skills", label: "Skills", jumpKey: "k", description: "Catalog and health diagnostics" },
8
+ { id: "ideas", label: "Ideas", jumpKey: "i", description: "Captured intents and queue" },
9
+ { id: "guardrails", label: "Guardrails", jumpKey: "r", description: "Policy rules and enforcement" },
10
+ { id: "shell", label: "Shell", jumpKey: "l", description: "Terminal and bash mode" },
11
+ { id: "usage", label: "Usage", jumpKey: "u", description: "Context and token metrics" },
12
+ { id: "appearance", label: "Appearance", jumpKey: "a", description: "Presets, palette, and chrome" },
13
+ { id: "motion", label: "Motion", jumpKey: "m", description: "Animation gallery and sensitivity" },
14
+ { id: "shortcuts", label: "Shortcuts", jumpKey: "c", description: "Keyboard navigation reference" },
15
+ { id: "diagnostics", label: "Diagnostics", jumpKey: "d", description: "Environment and capability checks" },
16
+ ];
17
+
18
+ export function isDeckRoute(value: string): value is DeckRoute {
19
+ return (DECK_ROUTES as readonly string[]).includes(value);
20
+ }
21
+
22
+ export function parseDeckRouteArg(args: string | undefined): DeckRoute {
23
+ const token = args?.trim().toLowerCase().split(/\s+/)[0] ?? "";
24
+ if (!token || token === "home" || token === "deck") return "home";
25
+ if (token === "settings" || token === "config") return "appearance";
26
+ if (isDeckRoute(token)) return token;
27
+ return "home";
28
+ }
29
+
30
+ export function deckRouteByJump(key: string): DeckRoute | null {
31
+ const match = DECK_ROUTE_DEFS.find((route) => route.jumpKey === key);
32
+ return match?.id ?? null;
33
+ }
34
+
35
+ export function deckRouteIndex(route: DeckRoute): number {
36
+ return DECK_ROUTE_DEFS.findIndex((entry) => entry.id === route);
37
+ }
@@ -0,0 +1,104 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { effectiveAppearanceMix, resolveAppearanceMix } from "../../../config/appearance.ts";
3
+ import { config } from "../../core/state.ts";
4
+ import type { RuntimeState } from "../../core/types.ts";
5
+ import { buildSegmentContext } from "../../core/segment-context.ts";
6
+ import { getQueueContext } from "../../queue/queue-context.ts";
7
+ import { loadSkillCatalog } from "../../skills/skill-registry.ts";
8
+ import { collectSkillDoctorInputs, diagnoseSkills } from "../../skills/skill-doctor.ts";
9
+ import { parsePolicySettings } from "../../hooks/policy-config.ts";
10
+ import { readSettings } from "../../settings/settings-io.ts";
11
+ import { describePolicy } from "../../../motion/accessibility.ts";
12
+ import type { DeckSessionSnapshot } from "./types.ts";
13
+
14
+ export function buildDeckSessionSnapshot(rt: RuntimeState, ctx: any): DeckSessionSnapshot {
15
+ const theme = { fg: (_color: string, text: string) => text } as Theme;
16
+ let segmentCtx;
17
+ try {
18
+ segmentCtx = buildSegmentContext(rt, ctx, theme);
19
+ } catch {
20
+ segmentCtx = null;
21
+ }
22
+
23
+ const queue = rt.queueStore.summarize(
24
+ getQueueContext(ctx),
25
+ rt.powerlineCompacting,
26
+ );
27
+ const cwd = ctx.cwd ?? process.cwd();
28
+ const skills = loadSkillCatalog(cwd);
29
+ const doctorInputs = collectSkillDoctorInputs(cwd);
30
+ const doctor = diagnoseSkills(
31
+ doctorInputs.entries,
32
+ doctorInputs.usage,
33
+ doctorInputs.contents,
34
+ );
35
+ const warnings = doctor.filter((row) => row.status !== "ok").length;
36
+ const settings = readSettings(cwd);
37
+ const policy = parsePolicySettings(settings.wishcraft);
38
+ const appearance = resolveAppearanceMix(
39
+ effectiveAppearanceMix(config.appearance, config.preset),
40
+ );
41
+
42
+ const model = segmentCtx?.model;
43
+ const modelLabel = model?.name ?? model?.id ?? "no model";
44
+ const branch = segmentCtx?.git.branch;
45
+ const branchLabel = branch ?? "no branch";
46
+
47
+ const recentActivity: string[] = [];
48
+ if (rt.signal.activity && rt.signal.activity !== "ready") {
49
+ recentActivity.push(rt.signal.activity);
50
+ }
51
+ if (queue.leadingText) {
52
+ recentActivity.push(queue.leadingText);
53
+ }
54
+ if (rt.lastUserPrompt) {
55
+ recentActivity.push(rt.lastUserPrompt.slice(0, 48));
56
+ }
57
+
58
+ return {
59
+ modelLabel,
60
+ branchLabel,
61
+ contextPercent: Math.round(segmentCtx?.contextPercent ?? 0),
62
+ contextTokens: segmentCtx?.contextTokens ?? 0,
63
+ contextWindow: segmentCtx?.contextWindow ?? 0,
64
+ signalActivity: rt.signal.activity,
65
+ signalMotion: rt.signal.motionId,
66
+ queueCount: queue.queueCount,
67
+ ideaCount: queue.ideaCount,
68
+ skillsTotal: skills.length,
69
+ skillsWarnings: warnings,
70
+ policyEnabled: policy.enabled,
71
+ policyRuleCount: policy.rules.length,
72
+ shellName: rt.shellSession?.state.shellName ?? null,
73
+ bashModeActive: rt.bashModeActive,
74
+ appearanceBase: appearance.base,
75
+ recentActivity: recentActivity.slice(0, 5),
76
+ nextIntent: queue.leadingText,
77
+ motionLevel: config.motionLevel,
78
+ policySummary: describePolicy(rt.motionPolicy),
79
+ skills: skills.slice(0, 24).map((skill) => {
80
+ const row = doctor.find((entry) => entry.skill === skill.name);
81
+ const usage = doctorInputs.usage.get(skill.name);
82
+ return {
83
+ name: skill.name,
84
+ category: skill.category,
85
+ status: row?.status ?? (skill.warning ? "warn" : "ok"),
86
+ description: skill.description.slice(0, 72),
87
+ usage: usage?.count ?? 0,
88
+ };
89
+ }),
90
+ ideas: rt.queueStore
91
+ .list()
92
+ .filter((item) => item.intent === "idea")
93
+ .slice(0, 12)
94
+ .map((item) => ({
95
+ text: item.text.slice(0, 64),
96
+ reviewStatus: item.reviewStatus ?? "idea",
97
+ })),
98
+ guardrailRules: policy.rules.slice(0, 8).map((rule) => ({
99
+ action: rule.action,
100
+ tool: rule.tool,
101
+ reason: rule.action === "deny" ? rule.reason : rule.context.slice(0, 48),
102
+ })),
103
+ };
104
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * src/extension/ui/deck/types.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Types for the Wishcraft Deck control surface.
5
+ * ---------------------------------------------------------------------------
6
+ */
7
+
8
+ export const DECK_ROUTES = [
9
+ "home",
10
+ "signal",
11
+ "skills",
12
+ "ideas",
13
+ "guardrails",
14
+ "shell",
15
+ "usage",
16
+ "appearance",
17
+ "motion",
18
+ "shortcuts",
19
+ "diagnostics",
20
+ ] as const;
21
+
22
+ export type DeckRoute = (typeof DECK_ROUTES)[number];
23
+
24
+ export interface DeckRouteDef {
25
+ id: DeckRoute;
26
+ label: string;
27
+ jumpKey: string;
28
+ description: string;
29
+ }
30
+
31
+ export interface DeckSessionSnapshot {
32
+ modelLabel: string;
33
+ branchLabel: string;
34
+ contextPercent: number;
35
+ contextTokens: number;
36
+ contextWindow: number;
37
+ signalActivity: string;
38
+ signalMotion: string;
39
+ queueCount: number;
40
+ ideaCount: number;
41
+ skillsTotal: number;
42
+ skillsWarnings: number;
43
+ policyEnabled: boolean;
44
+ policyRuleCount: number;
45
+ shellName: string | null;
46
+ bashModeActive: boolean;
47
+ appearanceBase: string;
48
+ recentActivity: string[];
49
+ nextIntent: string | null;
50
+ motionLevel: string;
51
+ policySummary: string;
52
+ skills: DeckSkillRow[];
53
+ ideas: DeckIdeaRow[];
54
+ guardrailRules: DeckGuardrailRow[];
55
+ }
56
+
57
+ export interface DeckSkillRow {
58
+ name: string;
59
+ category: string;
60
+ status: string;
61
+ description: string;
62
+ usage: number;
63
+ }
64
+
65
+ export interface DeckIdeaRow {
66
+ text: string;
67
+ reviewStatus: string;
68
+ }
69
+
70
+ export interface DeckGuardrailRow {
71
+ action: string;
72
+ tool: string;
73
+ reason: string;
74
+ }
75
+
76
+ export interface DeckNavState {
77
+ route: DeckRoute;
78
+ selectedNav: number;
79
+ searchOpen: boolean;
80
+ searchQuery: string;
81
+ pendingJump: string | null;
82
+ /** Cursor into STRUCTURAL_PRESET_NAMES on the Appearance route. */
83
+ selectedAppearance: number;
84
+ selectedMotion: number;
85
+ selectedSkill: number;
86
+ selectedIdea: number;
87
+ composerOpen: boolean;
88
+ composerField: number;
89
+ assignEvent: import("../../../motion/types.ts").MotionEvent;
90
+ skillCreate: boolean;
91
+ skillCreateName: string;
92
+ }
@@ -56,26 +56,30 @@ async function activatePowerlineMenuAction(
56
56
  }
57
57
  }
58
58
 
59
- /** Main powerline menu: three top-level overlays, Status drills down. */
59
+ import { openWishcraftDeck } from "./deck/index.ts";
60
+
61
+ /** Main powerline / Wishcraft Deck menu: opens the Deck on Alt+P */
60
62
  export async function showPowerlineMainMenu(
61
63
  rt: RuntimeState,
62
64
  ctx: any,
63
65
  ): Promise<void> {
64
66
  rt.currentCtx = ctx;
65
- while (true) {
66
- const top = await pickPowerlineMenuNode(
67
- ctx,
68
- "Powerline",
69
- assertPowerlineMenuBounds(),
70
- );
71
- if (!top) return;
72
- if (top.children?.length) {
73
- const child = await pickPowerlineMenuNode(ctx, top.label, top.children);
74
- if (!child) continue;
75
- await activatePowerlineMenuAction(rt, ctx, child.id);
76
- continue;
77
- }
78
- await activatePowerlineMenuAction(rt, ctx, top.id);
67
+ await openWishcraftDeck(rt, ctx, "home");
68
+ }
69
+
70
+ /** Classic Navigate / Configure / Status overlays (`/signal menu`). */
71
+ export async function showPowerlineClassicMenu(
72
+ rt: RuntimeState,
73
+ ctx: any,
74
+ ): Promise<void> {
75
+ rt.currentCtx = ctx;
76
+ const nodes = assertPowerlineMenuBounds();
77
+ const picked = await pickPowerlineMenuNode(ctx, "Signal", nodes);
78
+ if (!picked) return;
79
+ if (picked.children?.length) {
80
+ const child = await pickPowerlineMenuNode(ctx, picked.label, picked.children);
81
+ if (child) await activatePowerlineMenuAction(rt, ctx, child.id);
79
82
  return;
80
83
  }
84
+ await activatePowerlineMenuAction(rt, ctx, picked.id);
81
85
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * src/extension/ui/signal-layout.ts
3
+ * ---------------------------------------------------------------------------
4
+ * 3-Lane Motion-Aware Signal Powerline Renderer.
5
+ * Left: Model / Git · Center: Live Activity & Tools · Right: Context / Queue
6
+ * ---------------------------------------------------------------------------
7
+ */
8
+
9
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import type { PresetDef, SegmentContext } from "../../config/types.ts";
11
+ import { renderSegmentWithWidth } from "./layout.ts";
12
+ import { getSeparator } from "../../theme/separators.ts";
13
+ import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
14
+
15
+ export interface SignalRenderResult {
16
+ content: string;
17
+ leftLaneContent: string;
18
+ centerLaneContent: string;
19
+ rightLaneContent: string;
20
+ }
21
+
22
+ /**
23
+ * Computes the 3-lane Signal powerline with active motion sweeps.
24
+ */
25
+ export function computeSignal3LaneLayout(
26
+ ctx: SegmentContext,
27
+ presetDef: PresetDef,
28
+ availableWidth: number,
29
+ motionGlyph: string = "✦",
30
+ ): SignalRenderResult {
31
+ const separatorStyle = presetDef.separator ?? "powerline-thin";
32
+ const sepDef = getSeparator(separatorStyle);
33
+ const sep = sepDef.left;
34
+ const sepAnsi = getFgAnsiCode("sep");
35
+ const reset = colorEnabled() ? ansi.reset : "";
36
+
37
+ // 1. Left Lane: Identity (model, git, path)
38
+ const leftIds: any[] = presetDef.leftSegments ?? ["model", "path", "git"];
39
+ const leftParts: string[] = [];
40
+ for (const id of leftIds) {
41
+ const { content, visible } = renderSegmentWithWidth(id, ctx);
42
+ if (visible && content) leftParts.push(content);
43
+ }
44
+
45
+ // 2. Center Lane: Live Activity & Motion Conduit
46
+ const centerIds: any[] = ["thinking", "shell_mode", "subagents"];
47
+ const centerParts: string[] = [];
48
+ for (const id of centerIds) {
49
+ const { content, visible } = renderSegmentWithWidth(id, ctx);
50
+ if (visible && content) centerParts.push(content);
51
+ }
52
+
53
+ // 3. Right Lane: Metrics & Context (context_pct, queue, cost, tps)
54
+ const rightIds: any[] = presetDef.rightSegments ?? ["cost", "context_pct", "queue"];
55
+ const rightParts: string[] = [];
56
+ for (const id of rightIds) {
57
+ const { content, visible } = renderSegmentWithWidth(id, ctx);
58
+ if (visible && content) rightParts.push(content);
59
+ }
60
+
61
+ const leftText = leftParts.join(` ${sepAnsi}${sep}${reset} `);
62
+ const rightText = rightParts.join(` ${sepAnsi}${sep}${reset} `);
63
+
64
+ // Dynamic conduit sweep pulse in center lane
65
+ const centerText = centerParts.length > 0
66
+ ? `━━━╾${motionGlyph}╼━━━ ${centerParts.join(" ")}`
67
+ : `━━━╾${motionGlyph}╼━━━`;
68
+
69
+ const fullLine = ` ${leftText} ${sepAnsi}${sep}${reset} ${centerText} ${sepAnsi}${sep}${reset} ${rightText} `;
70
+
71
+ return {
72
+ content: fullLine,
73
+ leftLaneContent: leftText,
74
+ centerLaneContent: centerText,
75
+ rightLaneContent: rightText,
76
+ };
77
+ }
@@ -3,13 +3,14 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  import type { SegmentContext } from "../../config/types.ts";
5
5
  import { getPreset } from "../../config/presets.ts";
6
+ import { effectiveAppearanceMix, resolveAppearanceMix } from "../../config/appearance.ts";
6
7
  import {
7
8
  collectHiddenExtensionStatusKeys,
8
9
  getNotificationExtensionStatuses,
9
10
  } from "../../config/powerline-config.ts";
10
11
  import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
12
+ import { hasNerdFonts } from "../../theme/icons.ts";
11
13
 
12
- import { computeResponsiveLayout } from "./layout.ts";
13
14
  import { getQueueContext } from "../queue/queue-context.ts";
14
15
  import { buildSegmentContext } from "../core/segment-context.ts";
15
16
  import {
@@ -19,6 +20,8 @@ import {
19
20
  } from "../core/constants.ts";
20
21
  import { config } from "../core/state.ts";
21
22
  import type { RuntimeState } from "../core/types.ts";
23
+ import { prefersAsciiGlyphs } from "../../motion/index.ts";
24
+ import { renderSignal } from "../../signal/render.ts";
22
25
 
23
26
  /**
24
27
  * Get cached responsive layout or compute fresh one.
@@ -71,7 +74,22 @@ export function getResponsiveLayout(
71
74
  }
72
75
 
73
76
  rt.lastLayoutWidth = width;
74
- rt.lastLayoutResult = computeResponsiveLayout(segmentCtx, presetDef, width);
77
+ const appearance = resolveAppearanceMix(
78
+ effectiveAppearanceMix(config.appearance, config.preset),
79
+ );
80
+ rt.lastLayoutResult = renderSignal(
81
+ segmentCtx,
82
+ presetDef,
83
+ rt.signal,
84
+ width,
85
+ {
86
+ separatorStyle: config.separator ?? presetDef.separator,
87
+ signal: appearance.signal,
88
+ ascii: prefersAsciiGlyphs(rt.motionPolicy, hasNerdFonts()),
89
+ layout: config.layout,
90
+ disabledSegments: config.disabledSegments,
91
+ },
92
+ );
75
93
  rt.lastLayoutTimestamp = now;
76
94
  rt.layoutDirty = false;
77
95
  rt.forceNextLayoutRecompute = false;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * First-class accessibility policy. Environment flags plus an optional
3
+ * persisted motion level become a MotionPolicy the scheduler already understands.
4
+ */
5
+
6
+ import { detectEnvironment, motionLevelFromEnv } from "../theme/detect.ts";
7
+ import { DEFAULT_MOTION_POLICY, type MotionLevel, type MotionPolicy } from "./types.ts";
8
+
9
+ export type { MotionLevel, MotionPolicy };
10
+ import { effectiveLevel, targetFps } from "./policy.ts";
11
+
12
+ export const MOTION_LEVELS: readonly MotionLevel[] = [
13
+ "full",
14
+ "reduced",
15
+ "functional",
16
+ "off",
17
+ ];
18
+
19
+ export function isMotionLevel(value: unknown): value is MotionLevel {
20
+ return typeof value === "string" && (MOTION_LEVELS as readonly string[]).includes(value);
21
+ }
22
+
23
+ /**
24
+ * Build a policy from the host environment, then overlay a saved level.
25
+ * Screen-reader mode always forces off. An explicit WISHCRAFT_MOTION env
26
+ * wins over the saved level.
27
+ */
28
+ export function policyFromEnvironment(
29
+ env: NodeJS.ProcessEnv = process.env,
30
+ persistedLevel?: MotionLevel,
31
+ ): MotionPolicy {
32
+ const detected = detectEnvironment(env);
33
+ const fromEnv = motionLevelFromEnv(env);
34
+ let level: MotionLevel = fromEnv ?? persistedLevel ?? "full";
35
+ if (detected.screenReader) level = "off";
36
+ return {
37
+ level,
38
+ toggles: { ...DEFAULT_MOTION_POLICY.toggles },
39
+ noColor: detected.noColor,
40
+ lowColor: detected.lowColor || detected.dumb,
41
+ screenReader: detected.screenReader,
42
+ reducedMotion: detected.reducedMotion,
43
+ };
44
+ }
45
+
46
+ /** Idle FPS for the current policy with no consumers must be 0. */
47
+ export function idleFps(policy: MotionPolicy): number {
48
+ return targetFps(policy, [], []);
49
+ }
50
+
51
+ export function describePolicy(policy: MotionPolicy): string {
52
+ const level = effectiveLevel(policy);
53
+ const bits = [`motion ${level}`];
54
+ if (policy.noColor) bits.push("NO_COLOR");
55
+ if (policy.lowColor) bits.push("low-color");
56
+ if (policy.screenReader) bits.push("screen-reader");
57
+ if (policy.reducedMotion) bits.push("reduced-motion host");
58
+ return bits.join(" · ");
59
+ }