@hadooppei/hwcode 0.1.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.
@@ -0,0 +1,292 @@
1
+ import { homedir, tmpdir } from "node:os";
2
+
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ import {
10
+ canonicalizeWorkspaceRoot,
11
+ findExternalPathReferences,
12
+ isPathInsideRoot,
13
+ resolveToolPath,
14
+ } from "../lib/workflow-guard.ts";
15
+ import { getWorkingDirectory } from "../lib/working-directory.ts";
16
+
17
+ const STATE_TYPE = "hwcode-workflow-state";
18
+ const AUDIT_TYPE = "hwcode-workflow-external-approval";
19
+ const FILE_PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
20
+
21
+ type WorkflowMode = "vibe" | "sdd";
22
+
23
+ interface ActiveWorkflowState {
24
+ version: 1;
25
+ active: true;
26
+ mode: WorkflowMode;
27
+ root: string;
28
+ activatedAt: string;
29
+ }
30
+
31
+ interface InactiveWorkflowState {
32
+ version: 1;
33
+ active: false;
34
+ deactivatedAt: string;
35
+ }
36
+
37
+ type WorkflowState = ActiveWorkflowState | InactiveWorkflowState;
38
+
39
+ interface GitState {
40
+ initialized: boolean;
41
+ dirty: boolean;
42
+ status: string;
43
+ }
44
+
45
+ function isWorkflowState(value: unknown): value is WorkflowState {
46
+ if (!value || typeof value !== "object") return false;
47
+ const data = value as Record<string, unknown>;
48
+ if (data.version !== 1 || typeof data.active !== "boolean") return false;
49
+ if (data.active) {
50
+ return (data.mode === "vibe" || data.mode === "sdd")
51
+ && typeof data.root === "string"
52
+ && typeof data.activatedAt === "string";
53
+ }
54
+ return typeof data.deactivatedAt === "string";
55
+ }
56
+
57
+ function restoreState(ctx: ExtensionContext): WorkflowState | undefined {
58
+ for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
59
+ if (entry.type !== "custom" || entry.customType !== STATE_TYPE || !isWorkflowState(entry.data)) continue;
60
+ return entry.data;
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ function modeLabel(mode: WorkflowMode): string {
66
+ return mode === "vibe" ? "HWCode Vibe" : "HWCode SDD";
67
+ }
68
+
69
+ function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
70
+ if (ctx.hasUI) ctx.ui.notify(message, level);
71
+ }
72
+
73
+ async function confirmWorkspace(mode: WorkflowMode, root: string, ctx: ExtensionCommandContext): Promise<boolean> {
74
+ if (!ctx.hasUI) return false;
75
+ return ctx.ui.confirm(
76
+ `Start ${modeLabel(mode)}?`,
77
+ [
78
+ `Project root: ${root}`,
79
+ "",
80
+ "All project work in this session will be limited to this root.",
81
+ "Paths inside it are allowed silently. Each external-path tool call requires separate approval.",
82
+ ].join("\n"),
83
+ );
84
+ }
85
+
86
+ async function inspectOrInitializeGit(
87
+ pi: ExtensionAPI,
88
+ root: string,
89
+ ctx: ExtensionCommandContext,
90
+ ): Promise<GitState | undefined> {
91
+ const gitVersion = await pi.exec("git", ["--version"], { cwd: root });
92
+ if (gitVersion.code !== 0) {
93
+ notify(ctx, "HWCode SDD requires Git, but the git executable is unavailable.", "error");
94
+ return undefined;
95
+ }
96
+
97
+ let initialized = false;
98
+ let topLevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: root });
99
+ if (topLevel.code !== 0) {
100
+ if (!ctx.hasUI || !(await ctx.ui.confirm(
101
+ "Initialize Git repository?",
102
+ `HWCode SDD requires the current directory to be a Git repository root.\n\nRun git init in:\n${root}`,
103
+ ))) return undefined;
104
+
105
+ const init = await pi.exec("git", ["init"], { cwd: root });
106
+ if (init.code !== 0) {
107
+ notify(ctx, `Git initialization failed: ${init.stderr.trim() || init.stdout.trim()}`, "error");
108
+ return undefined;
109
+ }
110
+ initialized = true;
111
+ topLevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: root });
112
+ }
113
+
114
+ if (topLevel.code !== 0) {
115
+ notify(ctx, "Unable to determine the Git repository root.", "error");
116
+ return undefined;
117
+ }
118
+
119
+ const gitRoot = canonicalizeWorkspaceRoot(topLevel.stdout.trim());
120
+ if (gitRoot !== root) {
121
+ notify(
122
+ ctx,
123
+ `HWCode SDD must start at the Git repository root. Restart it from: ${gitRoot}`,
124
+ "error",
125
+ );
126
+ return undefined;
127
+ }
128
+
129
+ const statusResult = await pi.exec(
130
+ "git",
131
+ ["status", "--porcelain=v1", "--untracked-files=all"],
132
+ { cwd: root },
133
+ );
134
+ if (statusResult.code !== 0) {
135
+ notify(ctx, `Unable to inspect Git status: ${statusResult.stderr.trim()}`, "error");
136
+ return undefined;
137
+ }
138
+
139
+ const status = statusResult.stdout.trim();
140
+ return { initialized, dirty: status.length > 0, status };
141
+ }
142
+
143
+ function activationPrompt(mode: WorkflowMode, root: string, git: GitState | undefined, request: string): string {
144
+ const context = [
145
+ `The ${modeLabel(mode)} workflow has been activated by its project command.`,
146
+ `Locked project root: ${root}`,
147
+ ];
148
+ if (git) {
149
+ context.push(
150
+ `Git repository: ${git.initialized ? "initialized now" : "already present"}; working tree: ${git.dirty ? "has changes" : "clean"}.`,
151
+ );
152
+ if (git.status) context.push(`Current porcelain status:\n${git.status}`);
153
+ }
154
+ if (request) context.push(`Initial user request:\n${request}`);
155
+ return `/skill:hwcode-${mode} ${context.join("\n\n")}`;
156
+ }
157
+
158
+ function workflowSystemPrompt(state: ActiveWorkflowState): string {
159
+ const common = [
160
+ `HWCODE WORKFLOW ACTIVE: ${state.mode.toUpperCase()}`,
161
+ `The sole project root for this session is ${state.root}.`,
162
+ "Keep all reads, writes, commands, generated files, and project work inside that root by default.",
163
+ "An external-path approval applies only to the exact tool call that requested it. Never evade the guard through indirection, symlinks, subprocesses, alternate tools, or encoded commands.",
164
+ "If external storage such as /tmp is genuinely useful, explain why and let the tool-call approval request obtain user consent.",
165
+ ];
166
+ if (state.mode === "sdd") {
167
+ common.push(
168
+ "Follow the SDD phase gates. Do not implement production behavior before requirements, design, test plan, and tasks are complete and explicitly approved.",
169
+ "Develop tests before implementation with a red-green-refactor loop. Return to specification when expected behavior or a test scenario is uncertain.",
170
+ "Never create a Git commit unless the user explicitly approves that commit.",
171
+ );
172
+ }
173
+ return common.join("\n");
174
+ }
175
+
176
+ export default function (pi: ExtensionAPI) {
177
+ let activeState: ActiveWorkflowState | undefined;
178
+
179
+ async function activate(mode: WorkflowMode, args: string, ctx: ExtensionCommandContext): Promise<void> {
180
+ if (!ctx.isIdle()) {
181
+ notify(ctx, `Wait for the current response to finish before starting ${modeLabel(mode)}.`, "warning");
182
+ return;
183
+ }
184
+
185
+ const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
186
+ if (!(await confirmWorkspace(mode, root, ctx))) {
187
+ notify(ctx, `${modeLabel(mode)} was not started.`, "warning");
188
+ return;
189
+ }
190
+
191
+ const git = mode === "sdd" ? await inspectOrInitializeGit(pi, root, ctx) : undefined;
192
+ if (mode === "sdd" && !git) return;
193
+
194
+ activeState = {
195
+ version: 1,
196
+ active: true,
197
+ mode,
198
+ root,
199
+ activatedAt: new Date().toISOString(),
200
+ };
201
+ pi.appendEntry<WorkflowState>(STATE_TYPE, activeState);
202
+ notify(ctx, `${modeLabel(mode)} activated. Project root locked to ${root}.`);
203
+ pi.sendUserMessage(activationPrompt(mode, root, git, args.trim()), { expandPromptTemplates: true });
204
+ }
205
+
206
+ pi.registerCommand("hwcode-vibe", {
207
+ description: "Start the directory-locked HWCode Vibe workflow",
208
+ handler: async (args, ctx) => activate("vibe", args, ctx),
209
+ });
210
+
211
+ pi.registerCommand("hwcode-sdd", {
212
+ description: "Start the Git-root-locked HWCode spec-driven workflow",
213
+ handler: async (args, ctx) => activate("sdd", args, ctx),
214
+ });
215
+
216
+ pi.on("session_start", async (_event, ctx) => {
217
+ const restored = restoreState(ctx);
218
+ if (!restored?.active) {
219
+ activeState = undefined;
220
+ return;
221
+ }
222
+
223
+ const currentRoot = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
224
+ if (currentRoot !== restored.root) {
225
+ activeState = undefined;
226
+ pi.appendEntry<WorkflowState>(STATE_TYPE, {
227
+ version: 1,
228
+ active: false,
229
+ deactivatedAt: new Date().toISOString(),
230
+ });
231
+ notify(ctx, "Stored HWCode workflow disabled because the current directory changed.", "warning");
232
+ return;
233
+ }
234
+
235
+ activeState = restored;
236
+ notify(ctx, `${modeLabel(restored.mode)} restored. Root: ${restored.root}`);
237
+ });
238
+
239
+ pi.on("before_agent_start", async (event) => {
240
+ if (!activeState) return undefined;
241
+ return { systemPrompt: `${event.systemPrompt}\n\n${workflowSystemPrompt(activeState)}` };
242
+ });
243
+
244
+ pi.on("tool_call", async (event, ctx) => {
245
+ if (!activeState) return undefined;
246
+
247
+ const input = event.input as Record<string, unknown>;
248
+ let external: Array<{ raw: string; resolved: string }> = [];
249
+ if (FILE_PATH_TOOLS.has(event.toolName) && typeof input.path === "string") {
250
+ const resolved = resolveToolPath(activeState.root, input.path, homedir());
251
+ if (!isPathInsideRoot(activeState.root, resolved)) external = [{ raw: input.path, resolved }];
252
+ } else if (event.toolName === "bash" && typeof input.command === "string") {
253
+ external = findExternalPathReferences(input.command, activeState.root, {
254
+ home: homedir(),
255
+ tmpdir: tmpdir(),
256
+ });
257
+ }
258
+
259
+ if (external.length === 0) return undefined;
260
+ const details = external.map((entry) => `• ${entry.raw} → ${entry.resolved}`).join("\n");
261
+ if (!ctx.hasUI) {
262
+ return {
263
+ block: true,
264
+ reason: `External path requires interactive one-call approval:\n${details}`,
265
+ };
266
+ }
267
+
268
+ const allowed = await ctx.ui.confirm(
269
+ "Allow external path for this call?",
270
+ [
271
+ `${modeLabel(activeState.mode)} is locked to ${activeState.root}.`,
272
+ "This tool call references paths outside that root:",
273
+ "",
274
+ details,
275
+ "",
276
+ "Approval applies only to this tool call.",
277
+ ].join("\n"),
278
+ );
279
+ if (!allowed) {
280
+ return { block: true, reason: `User denied external path access:\n${details}` };
281
+ }
282
+
283
+ pi.appendEntry(AUDIT_TYPE, {
284
+ mode: activeState.mode,
285
+ root: activeState.root,
286
+ toolName: event.toolName,
287
+ external,
288
+ approvedAt: new Date().toISOString(),
289
+ });
290
+ return undefined;
291
+ });
292
+ }
@@ -0,0 +1,49 @@
1
+ export const DEFAULT_HIDDEN_COMMANDS = [
2
+ "export",
3
+ "import",
4
+ "share",
5
+ "name",
6
+ "changelog",
7
+ "fork",
8
+ "clone",
9
+ "trust",
10
+ "reload",
11
+ "review",
12
+ "welcome",
13
+ "llama",
14
+ "skill:hwcode-vibe",
15
+ "skill:hwcode-sdd",
16
+ ] as const;
17
+
18
+ interface ProjectSettings {
19
+ hwcode?: {
20
+ hiddenCommands?: unknown;
21
+ };
22
+ }
23
+
24
+ export interface CommandSuggestion {
25
+ value: string;
26
+ }
27
+
28
+ export function normalizeCommandName(command: string): string {
29
+ return command.trim().replace(/^\/+/, "").toLowerCase();
30
+ }
31
+
32
+ export function parseHiddenCommands(settingsText: string): Set<string> {
33
+ const settings = JSON.parse(settingsText) as ProjectSettings;
34
+ const configured = settings.hwcode?.hiddenCommands;
35
+ if (configured === undefined) return new Set(DEFAULT_HIDDEN_COMMANDS);
36
+ if (!Array.isArray(configured) || configured.some((entry) => typeof entry !== "string")) {
37
+ throw new Error("hwcode.hiddenCommands must be an array of command-name strings");
38
+ }
39
+ return new Set(configured.map(normalizeCommandName).filter(Boolean));
40
+ }
41
+
42
+ export function filterCommandSuggestions<T extends CommandSuggestion>(
43
+ items: T[],
44
+ prefix: string,
45
+ hiddenCommands: ReadonlySet<string>,
46
+ ): T[] {
47
+ if (!prefix.startsWith("/")) return items;
48
+ return items.filter((item) => !hiddenCommands.has(normalizeCommandName(item.value)));
49
+ }
@@ -0,0 +1,264 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ type Point = readonly [x: number, y: number];
4
+ type Stroke = readonly Point[];
5
+
6
+ interface VectorGlyph {
7
+ strokes: readonly Stroke[];
8
+ }
9
+
10
+ const point = (x: number, y: number): Point => [x, y];
11
+
12
+ function path(...coordinates: number[]): Point[] {
13
+ const points: Point[] = [];
14
+ for (let index = 0; index < coordinates.length; index += 2) {
15
+ points.push(point(coordinates[index], coordinates[index + 1]));
16
+ }
17
+ return points;
18
+ }
19
+
20
+ function cubic(start: Point, control1: Point, control2: Point, end: Point, steps = 18): Point[] {
21
+ const points: Point[] = [];
22
+ for (let step = 0; step <= steps; step++) {
23
+ const t = step / steps;
24
+ const inverse = 1 - t;
25
+ points.push(point(
26
+ inverse ** 3 * start[0]
27
+ + 3 * inverse ** 2 * t * control1[0]
28
+ + 3 * inverse * t ** 2 * control2[0]
29
+ + t ** 3 * end[0],
30
+ inverse ** 3 * start[1]
31
+ + 3 * inverse ** 2 * t * control1[1]
32
+ + 3 * inverse * t ** 2 * control2[1]
33
+ + t ** 3 * end[1],
34
+ ));
35
+ }
36
+ return points;
37
+ }
38
+
39
+ function arc(
40
+ centerX: number,
41
+ centerY: number,
42
+ radiusX: number,
43
+ radiusY: number,
44
+ startDegrees: number,
45
+ endDegrees: number,
46
+ steps = 28,
47
+ ): Point[] {
48
+ const points: Point[] = [];
49
+ for (let step = 0; step <= steps; step++) {
50
+ const degrees = startDegrees + (endDegrees - startDegrees) * (step / steps);
51
+ const radians = degrees * Math.PI / 180;
52
+ points.push(point(
53
+ centerX + Math.cos(radians) * radiusX,
54
+ centerY + Math.sin(radians) * radiusY,
55
+ ));
56
+ }
57
+ return points;
58
+ }
59
+
60
+ function joinPaths(...parts: readonly Point[][]): Point[] {
61
+ return parts.flatMap((part, index) => index === 0 ? part : part.slice(1));
62
+ }
63
+
64
+ // Normalized monoline glyphs. They are rasterized at the density selected for the
65
+ // current terminal instead of being enlarged from a fixed low-resolution bitmap.
66
+ const VECTOR_FONT: Record<string, VectorGlyph> = {
67
+ A: { strokes: [path(1, 11, 4, 1, 7, 11), path(2.2, 7, 5.8, 7)] },
68
+ B: { strokes: [
69
+ path(1, 1, 1, 11),
70
+ joinPaths(path(1, 1, 4.4, 1), cubic(point(4.4, 1), point(7.4, 1), point(7.4, 6), point(4.4, 6)), path(4.4, 6, 1, 6)),
71
+ joinPaths(path(1, 6, 4.7, 6), cubic(point(4.7, 6), point(7.6, 6), point(7.6, 11), point(4.7, 11)), path(4.7, 11, 1, 11)),
72
+ ] },
73
+ C: { strokes: [arc(4, 6, 3.2, 5, -45, -315)] },
74
+ D: { strokes: [
75
+ path(1, 1, 1, 11),
76
+ joinPaths(
77
+ cubic(point(1, 1), point(5.2, 1), point(7, 2.6), point(7, 6)),
78
+ cubic(point(7, 6), point(7, 9.4), point(5.2, 11), point(1, 11)),
79
+ ),
80
+ ] },
81
+ E: { strokes: [path(7, 1, 1, 1, 1, 11, 7, 11), path(1, 6, 6, 6)] },
82
+ F: { strokes: [path(7, 1, 1, 1, 1, 11), path(1, 6, 6, 6)] },
83
+ G: { strokes: [arc(4, 6, 3.2, 5, -45, -315), path(7.1, 6, 4.4, 6), path(7.1, 6, 7.1, 9)] },
84
+ H: { strokes: [path(1, 1, 1, 11), path(7, 1, 7, 11), path(1, 6, 7, 6)] },
85
+ I: { strokes: [path(1, 1, 7, 1), path(4, 1, 4, 11), path(1, 11, 7, 11)] },
86
+ J: { strokes: [
87
+ path(1, 1, 7, 1),
88
+ joinPaths(path(7, 1, 7, 8), arc(4, 8, 3, 3, 0, 150, 18)),
89
+ ] },
90
+ K: { strokes: [path(1, 1, 1, 11), path(7, 1, 1, 6, 7, 11)] },
91
+ L: { strokes: [path(1, 1, 1, 11, 7, 11)] },
92
+ M: { strokes: [path(1, 11, 1, 1, 4, 6, 7, 1, 7, 11)] },
93
+ N: { strokes: [path(1, 11, 1, 1, 7, 11, 7, 1)] },
94
+ O: { strokes: [arc(4, 6, 3.2, 5, 0, 360, 36)] },
95
+ P: { strokes: [
96
+ path(1, 11, 1, 1),
97
+ joinPaths(path(1, 1, 4.4, 1), cubic(point(4.4, 1), point(7.5, 1), point(7.5, 6), point(4.4, 6)), path(4.4, 6, 1, 6)),
98
+ ] },
99
+ Q: { strokes: [arc(4, 6, 3.2, 5, 0, 360, 36), path(4.8, 8.2, 7.4, 11.3)] },
100
+ R: { strokes: [
101
+ path(1, 11, 1, 1),
102
+ joinPaths(path(1, 1, 4.4, 1), cubic(point(4.4, 1), point(7.5, 1), point(7.5, 6), point(4.4, 6)), path(4.4, 6, 1, 6)),
103
+ path(4.3, 6, 7.2, 11),
104
+ ] },
105
+ S: { strokes: [joinPaths(
106
+ cubic(point(7.1, 2), point(5.6, 0.4), point(1, 0.5), point(1, 3.5)),
107
+ cubic(point(1, 3.5), point(1, 5.7), point(7, 5.4), point(7, 8.5)),
108
+ cubic(point(7, 8.5), point(7, 11.5), point(2.3, 11.8), point(0.8, 10)),
109
+ )] },
110
+ T: { strokes: [path(0.7, 1, 7.3, 1), path(4, 1, 4, 11)] },
111
+ U: { strokes: [path(1, 1, 1, 8), arc(4, 8, 3, 3, 180, 0, 22), path(7, 8, 7, 1)] },
112
+ V: { strokes: [path(0.8, 1, 4, 11, 7.2, 1)] },
113
+ W: { strokes: [path(0.5, 1, 2, 11, 4, 6, 6, 11, 7.5, 1)] },
114
+ X: { strokes: [path(0.8, 1, 7.2, 11), path(7.2, 1, 0.8, 11)] },
115
+ Y: { strokes: [path(0.8, 1, 4, 6, 7.2, 1), path(4, 6, 4, 11)] },
116
+ Z: { strokes: [path(0.8, 1, 7.2, 1, 0.8, 11, 7.2, 11)] },
117
+ };
118
+
119
+ export const THEME_COLORS: readonly ThemeColor[] = [
120
+ "accent", "border", "borderAccent", "borderMuted", "success", "error", "warning",
121
+ "muted", "dim", "text", "thinkingText", "searchMatchText", "userMessageText",
122
+ "customMessageText", "customMessageLabel", "toolTitle", "toolOutput", "mdHeading",
123
+ "mdLink", "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote",
124
+ "mdQuoteBorder", "mdHr", "mdListBullet", "toolDiffAdded", "toolDiffRemoved",
125
+ "toolDiffContext", "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable",
126
+ "syntaxString", "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation",
127
+ "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh",
128
+ "thinkingXhigh", "thinkingMax", "bashMode",
129
+ ];
130
+
131
+ const THEME_COLOR_SET = new Set<string>(THEME_COLORS);
132
+ const GLYPH_WIDTH = 8;
133
+ const GLYPH_HEIGHT = 12;
134
+ const GLYPH_GAP = 1;
135
+ const BRAILLE_COLUMNS = 2;
136
+ const BRAILLE_ROWS = 4;
137
+ const GLYPH_CACHE = new Map<string, string[]>();
138
+ const BRAILLE_DOTS = [
139
+ [0, 0, 0x01], [0, 1, 0x02], [0, 2, 0x04],
140
+ [1, 0, 0x08], [1, 1, 0x10], [1, 2, 0x20],
141
+ [0, 3, 0x40], [1, 3, 0x80],
142
+ ] as const;
143
+
144
+ export function isThemeColor(value: unknown): value is ThemeColor {
145
+ return typeof value === "string" && THEME_COLOR_SET.has(value);
146
+ }
147
+
148
+ export interface PixelTextResult {
149
+ lines: string[];
150
+ height: number;
151
+ scale: number;
152
+ }
153
+
154
+ function segmentDistanceSquared(x: number, y: number, start: Point, end: Point, scale: number): number {
155
+ const startX = start[0] * scale;
156
+ const startY = start[1] * scale;
157
+ const deltaX = (end[0] - start[0]) * scale;
158
+ const deltaY = (end[1] - start[1]) * scale;
159
+ const lengthSquared = deltaX * deltaX + deltaY * deltaY;
160
+ const projection = lengthSquared === 0
161
+ ? 0
162
+ : Math.max(0, Math.min(1, ((x - startX) * deltaX + (y - startY) * deltaY) / lengthSquared));
163
+ const nearestX = startX + projection * deltaX;
164
+ const nearestY = startY + projection * deltaY;
165
+ return (x - nearestX) ** 2 + (y - nearestY) ** 2;
166
+ }
167
+
168
+ function rasterizeGlyph(letter: string, scale: number): boolean[][] {
169
+ const width = GLYPH_WIDTH * scale;
170
+ const height = GLYPH_HEIGHT * scale;
171
+ const bitmap = Array.from({ length: height }, () => Array<boolean>(width).fill(false));
172
+ const glyph = VECTOR_FONT[letter] ?? VECTOR_FONT.X;
173
+ const radius = Math.max(0.72, scale * 0.48);
174
+ const radiusSquared = radius * radius;
175
+
176
+ for (const stroke of glyph.strokes) {
177
+ for (let index = 0; index < stroke.length - 1; index++) {
178
+ const start = stroke[index];
179
+ const end = stroke[index + 1];
180
+ const minX = Math.max(0, Math.floor(Math.min(start[0], end[0]) * scale - radius));
181
+ const maxX = Math.min(width - 1, Math.ceil(Math.max(start[0], end[0]) * scale + radius));
182
+ const minY = Math.max(0, Math.floor(Math.min(start[1], end[1]) * scale - radius));
183
+ const maxY = Math.min(height - 1, Math.ceil(Math.max(start[1], end[1]) * scale + radius));
184
+
185
+ for (let y = minY; y <= maxY; y++) {
186
+ for (let x = minX; x <= maxX; x++) {
187
+ if (segmentDistanceSquared(x + 0.5, y + 0.5, start, end, scale) <= radiusSquared) {
188
+ bitmap[y][x] = true;
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ return bitmap;
196
+ }
197
+
198
+ function encodeBraille(bitmap: readonly boolean[][]): string[] {
199
+ const height = bitmap.length;
200
+ const width = bitmap[0]?.length ?? 0;
201
+ const lines: string[] = [];
202
+
203
+ for (let originY = 0; originY < height; originY += BRAILLE_ROWS) {
204
+ let line = "";
205
+ for (let originX = 0; originX < width; originX += BRAILLE_COLUMNS) {
206
+ let mask = 0;
207
+ for (const [offsetX, offsetY, dot] of BRAILLE_DOTS) {
208
+ if (bitmap[originY + offsetY]?.[originX + offsetX]) mask |= dot;
209
+ }
210
+ line += mask === 0 ? " " : String.fromCodePoint(0x2800 + mask);
211
+ }
212
+ lines.push(line);
213
+ }
214
+
215
+ return lines;
216
+ }
217
+
218
+ function renderGlyph(letter: string, scale: number): string[] {
219
+ const cacheKey = `${letter}:${scale}`;
220
+ const cached = GLYPH_CACHE.get(cacheKey);
221
+ if (cached) return cached;
222
+ const rendered = encodeBraille(rasterizeGlyph(letter, scale));
223
+ GLYPH_CACHE.set(cacheKey, rendered);
224
+ return rendered;
225
+ }
226
+
227
+ export function renderPixelText(
228
+ text: string,
229
+ colors: readonly ThemeColor[],
230
+ theme: Theme,
231
+ availableWidth: number,
232
+ availableHeight: number,
233
+ ): PixelTextResult {
234
+ const letters = [...text.toUpperCase()];
235
+ const widthPerScale = letters.length * (GLYPH_WIDTH / BRAILLE_COLUMNS)
236
+ + Math.max(0, letters.length - 1) * GLYPH_GAP;
237
+ const heightPerScale = GLYPH_HEIGHT / BRAILLE_ROWS;
238
+ const widthScale = Math.floor((availableWidth - 4) / widthPerScale);
239
+ const heightScale = Math.floor(availableHeight / heightPerScale);
240
+ const scale = Math.min(widthScale, heightScale);
241
+
242
+ if (scale < 1) {
243
+ const leftPadding = " ".repeat(Math.max(0, Math.floor((availableWidth - letters.length) / 2)));
244
+ const line = letters
245
+ .map((letter, index) => theme.fg(colors[index % colors.length] ?? "accent", letter))
246
+ .join("");
247
+ return { lines: [`${leftPadding}${line}`], height: 1, scale: 0 };
248
+ }
249
+
250
+ const glyphs = letters.map((letter) => renderGlyph(letter, scale));
251
+ const gap = " ".repeat(GLYPH_GAP * scale);
252
+ const renderedWidth = widthPerScale * scale;
253
+ const leftPadding = " ".repeat(Math.max(0, Math.floor((availableWidth - renderedWidth) / 2)));
254
+ const height = heightPerScale * scale;
255
+ const lines: string[] = [];
256
+
257
+ for (let row = 0; row < height; row++) {
258
+ const segments = glyphs.map((glyph, index) =>
259
+ theme.fg(colors[index % colors.length] ?? "accent", glyph[row]));
260
+ lines.push(`${leftPadding}${segments.join(gap)}`);
261
+ }
262
+
263
+ return { lines, height, scale };
264
+ }
@@ -0,0 +1,18 @@
1
+ function isPlainKittyEnter(data: string): boolean {
2
+ const match = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/);
3
+ if (!match) return false;
4
+ const codepoint = Number(match[1]);
5
+ const modifier = match[4] === undefined ? 1 : Number(match[4]);
6
+ const eventType = match[5] === undefined ? 1 : Number(match[5]);
7
+ return (codepoint === 13 || codepoint === 57414) && modifier === 1 && eventType !== 3;
8
+ }
9
+
10
+ export function isSubmitKey(data: string): boolean {
11
+ if (data === "\r" || data === "\x1bOM") return true;
12
+ if (/^\x1b\[27;1;13~$/.test(data)) return true;
13
+ return isPlainKittyEnter(data);
14
+ }
15
+
16
+ export function shouldDismissWelcomeOnSubmit(data: string, editorText: string): boolean {
17
+ return editorText.trim().length > 0 && isSubmitKey(data);
18
+ }