im-pickle-rick 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.
Files changed (128) hide show
  1. package/README.md +242 -0
  2. package/bin.js +3 -0
  3. package/dist/pickle +0 -0
  4. package/dist/worker-executor.js +207 -0
  5. package/package.json +53 -0
  6. package/src/games/GameSidebarManager.test.ts +64 -0
  7. package/src/games/GameSidebarManager.ts +78 -0
  8. package/src/games/gameboy/GameboyView.test.ts +25 -0
  9. package/src/games/gameboy/GameboyView.ts +100 -0
  10. package/src/games/gameboy/gameboy-polyfills.ts +313 -0
  11. package/src/games/index.test.ts +9 -0
  12. package/src/games/index.ts +4 -0
  13. package/src/games/snake/SnakeGame.test.ts +35 -0
  14. package/src/games/snake/SnakeGame.ts +145 -0
  15. package/src/games/snake/SnakeView.test.ts +25 -0
  16. package/src/games/snake/SnakeView.ts +290 -0
  17. package/src/index.test.ts +24 -0
  18. package/src/index.ts +141 -0
  19. package/src/services/commands/worker.test.ts +14 -0
  20. package/src/services/commands/worker.ts +262 -0
  21. package/src/services/config/index.ts +2 -0
  22. package/src/services/config/settings.test.ts +42 -0
  23. package/src/services/config/settings.ts +220 -0
  24. package/src/services/config/state.test.ts +88 -0
  25. package/src/services/config/state.ts +130 -0
  26. package/src/services/config/types.ts +39 -0
  27. package/src/services/execution/index.ts +1 -0
  28. package/src/services/execution/pickle-source.test.ts +88 -0
  29. package/src/services/execution/pickle-source.ts +264 -0
  30. package/src/services/execution/prompt.test.ts +93 -0
  31. package/src/services/execution/prompt.ts +322 -0
  32. package/src/services/execution/sequential.test.ts +91 -0
  33. package/src/services/execution/sequential.ts +422 -0
  34. package/src/services/execution/worker-client.ts +94 -0
  35. package/src/services/execution/worker-executor.ts +41 -0
  36. package/src/services/execution/worker.test.ts +73 -0
  37. package/src/services/git/branch.test.ts +147 -0
  38. package/src/services/git/branch.ts +128 -0
  39. package/src/services/git/diff.test.ts +113 -0
  40. package/src/services/git/diff.ts +323 -0
  41. package/src/services/git/index.ts +4 -0
  42. package/src/services/git/pr.test.ts +104 -0
  43. package/src/services/git/pr.ts +192 -0
  44. package/src/services/git/worktree.test.ts +99 -0
  45. package/src/services/git/worktree.ts +141 -0
  46. package/src/services/providers/base.test.ts +86 -0
  47. package/src/services/providers/base.ts +438 -0
  48. package/src/services/providers/codex.test.ts +39 -0
  49. package/src/services/providers/codex.ts +208 -0
  50. package/src/services/providers/gemini.test.ts +40 -0
  51. package/src/services/providers/gemini.ts +169 -0
  52. package/src/services/providers/index.test.ts +28 -0
  53. package/src/services/providers/index.ts +41 -0
  54. package/src/services/providers/opencode.test.ts +64 -0
  55. package/src/services/providers/opencode.ts +228 -0
  56. package/src/services/providers/types.ts +44 -0
  57. package/src/skills/code-implementer.md +105 -0
  58. package/src/skills/code-researcher.md +78 -0
  59. package/src/skills/implementation-planner.md +105 -0
  60. package/src/skills/plan-reviewer.md +100 -0
  61. package/src/skills/prd-drafter.md +123 -0
  62. package/src/skills/research-reviewer.md +79 -0
  63. package/src/skills/ruthless-refactorer.md +52 -0
  64. package/src/skills/ticket-manager.md +135 -0
  65. package/src/types/index.ts +2 -0
  66. package/src/types/rpc.ts +14 -0
  67. package/src/types/tasks.ts +50 -0
  68. package/src/types.d.ts +9 -0
  69. package/src/ui/common.ts +28 -0
  70. package/src/ui/components/FilePickerView.test.ts +79 -0
  71. package/src/ui/components/FilePickerView.ts +161 -0
  72. package/src/ui/components/MultiLineInput.test.ts +27 -0
  73. package/src/ui/components/MultiLineInput.ts +233 -0
  74. package/src/ui/components/SessionChip.test.ts +69 -0
  75. package/src/ui/components/SessionChip.ts +481 -0
  76. package/src/ui/components/ToyboxSidebar.test.ts +36 -0
  77. package/src/ui/components/ToyboxSidebar.ts +329 -0
  78. package/src/ui/components/refactor_plan.md +35 -0
  79. package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
  80. package/src/ui/controllers/DashboardController.ts +650 -0
  81. package/src/ui/dashboard.test.ts +43 -0
  82. package/src/ui/dashboard.ts +309 -0
  83. package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
  84. package/src/ui/dialogs/DashboardDialog.ts +399 -0
  85. package/src/ui/dialogs/Dialog.test.ts +50 -0
  86. package/src/ui/dialogs/Dialog.ts +241 -0
  87. package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
  88. package/src/ui/dialogs/DialogSidebar.ts +71 -0
  89. package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
  90. package/src/ui/dialogs/DiffViewDialog.ts +510 -0
  91. package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
  92. package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
  93. package/src/ui/dialogs/test-utils.ts +232 -0
  94. package/src/ui/file-picker-utils.test.ts +71 -0
  95. package/src/ui/file-picker-utils.ts +200 -0
  96. package/src/ui/input-chrome.test.ts +62 -0
  97. package/src/ui/input-chrome.ts +172 -0
  98. package/src/ui/logger.test.ts +68 -0
  99. package/src/ui/logger.ts +45 -0
  100. package/src/ui/mock-factory.ts +6 -0
  101. package/src/ui/spinner.test.ts +65 -0
  102. package/src/ui/spinner.ts +41 -0
  103. package/src/ui/test-setup.ts +300 -0
  104. package/src/ui/theme.test.ts +23 -0
  105. package/src/ui/theme.ts +16 -0
  106. package/src/ui/views/LandingView.integration.test.ts +21 -0
  107. package/src/ui/views/LandingView.test.ts +24 -0
  108. package/src/ui/views/LandingView.ts +221 -0
  109. package/src/ui/views/LogView.test.ts +24 -0
  110. package/src/ui/views/LogView.ts +277 -0
  111. package/src/ui/views/ToyboxView.test.ts +46 -0
  112. package/src/ui/views/ToyboxView.ts +323 -0
  113. package/src/utils/clipboard.test.ts +86 -0
  114. package/src/utils/clipboard.ts +100 -0
  115. package/src/utils/index.test.ts +68 -0
  116. package/src/utils/index.ts +95 -0
  117. package/src/utils/persona.test.ts +12 -0
  118. package/src/utils/persona.ts +8 -0
  119. package/src/utils/project-root.test.ts +38 -0
  120. package/src/utils/project-root.ts +22 -0
  121. package/src/utils/resources.test.ts +64 -0
  122. package/src/utils/resources.ts +92 -0
  123. package/src/utils/search.test.ts +48 -0
  124. package/src/utils/search.ts +103 -0
  125. package/src/utils/session-tracker.test.ts +46 -0
  126. package/src/utils/session-tracker.ts +67 -0
  127. package/src/utils/spinner.test.ts +54 -0
  128. package/src/utils/spinner.ts +87 -0
@@ -0,0 +1,200 @@
1
+ import { CliRenderer, InputRenderable, InputRenderableEvents, BoxRenderable, Renderable, SyntaxStyle, RGBA } from "@opentui/core";
2
+ import { FilePickerView } from "./components/FilePickerView.js";
3
+ import { recursiveSearch } from "../utils/search.js";
4
+ import { THEME } from "./theme.js";
5
+
6
+ export interface FilePickerState {
7
+ activePicker: FilePickerView | null;
8
+ justClosed?: boolean;
9
+ }
10
+
11
+ /**
12
+ * Regex to match @-prefixed file paths.
13
+ * Matches patterns like @file/path or @src/ui/FilePickerView.ts
14
+ */
15
+ const FILE_PATH_REGEX = /@([^\s@]+)/g;
16
+
17
+ // Unique reference for our highlights
18
+ const FILE_PATH_HL_REF = 9999;
19
+
20
+ /**
21
+ * Sets up a file picker that triggers when " @" is typed in the input.
22
+ * Also handles atomic deletion of @-prefixed file paths with backspace/delete.
23
+ * And adds syntax highlighting to @-prefixed file paths.
24
+ * Returns a cleanup function.
25
+ */
26
+ type PickerPositionValue = number | string;
27
+ type PickerPositionResolver = () => number;
28
+
29
+ export function setupFilePicker(
30
+ renderer: CliRenderer,
31
+ input: InputRenderable,
32
+ container: BoxRenderable | (Renderable & { add: (r: Renderable) => void, remove: (id: string) => void }),
33
+ state: FilePickerState,
34
+ options: { bottom?: PickerPositionValue | PickerPositionResolver, left?: PickerPositionValue, width?: PickerPositionValue } = {}
35
+ ) {
36
+ const resolveBottom = (): number => {
37
+ if (typeof options.bottom === "function") {
38
+ return options.bottom();
39
+ }
40
+ if (typeof options.bottom === "number") {
41
+ return options.bottom;
42
+ }
43
+ return 5;
44
+ };
45
+ // Setup syntax highlighting for file paths
46
+ const syntaxStyle = SyntaxStyle.create();
47
+ const filePathStyleId = syntaxStyle.registerStyle("filepath", {
48
+ fg: RGBA.fromHex(THEME.accent),
49
+ bold: true,
50
+ });
51
+ input.syntaxStyle = syntaxStyle;
52
+
53
+ const cleanupPicker = () => {
54
+ if (state.activePicker) {
55
+ state.activePicker.destroy();
56
+ container.remove(state.activePicker.id);
57
+ state.activePicker = null;
58
+
59
+ // Set a brief grace period to prevent auto-submission on Enter
60
+ state.justClosed = true;
61
+ setTimeout(() => {
62
+ state.justClosed = false;
63
+ }, 50);
64
+
65
+ input.focus();
66
+ renderer.requestRender();
67
+ }
68
+ };
69
+
70
+ /**
71
+ * Update highlights for all @-prefixed file paths in the input
72
+ */
73
+ const updateFilePathHighlights = () => {
74
+ // Clear existing file path highlights
75
+ input.removeHighlightsByRef(FILE_PATH_HL_REF);
76
+
77
+ const value = input.value;
78
+ if (!value || filePathStyleId === null) return;
79
+
80
+ // Find all @-prefixed file paths and highlight them
81
+ let match;
82
+ FILE_PATH_REGEX.lastIndex = 0;
83
+ while ((match = FILE_PATH_REGEX.exec(value)) !== null) {
84
+ input.addHighlightByCharRange({
85
+ start: match.index,
86
+ end: match.index + match[0].length,
87
+ styleId: filePathStyleId,
88
+ hlRef: FILE_PATH_HL_REF,
89
+ });
90
+ }
91
+ };
92
+
93
+ /**
94
+ * Check if the cursor is at the end of an @-prefixed file path and delete it atomically.
95
+ * Returns true if a path was deleted, false otherwise.
96
+ */
97
+ const tryAtomicDelete = (): boolean => {
98
+ // Only delete atomically when picker is not active
99
+ if (state.activePicker) return false;
100
+
101
+ const value = input.value;
102
+ if (value.length === 0) return false;
103
+
104
+ // Find all @-prefixed file paths in the text
105
+ const matches: { start: number; end: number; text: string }[] = [];
106
+ let match;
107
+ FILE_PATH_REGEX.lastIndex = 0;
108
+ while ((match = FILE_PATH_REGEX.exec(value)) !== null) {
109
+ matches.push({
110
+ start: match.index,
111
+ end: match.index + match[0].length,
112
+ text: match[0],
113
+ });
114
+ }
115
+
116
+ // Check if value ends with a @-prefixed path and delete the entire path
117
+ for (const m of matches) {
118
+ if (m.end === value.length) {
119
+ // Delete the entire @-prefixed path
120
+ const newValue = value.slice(0, m.start);
121
+ input.value = newValue;
122
+ updateFilePathHighlights();
123
+ renderer.requestRender();
124
+ return true;
125
+ }
126
+ }
127
+
128
+ return false;
129
+ };
130
+
131
+ // Override the input's deleteCharBackward method to handle atomic deletion
132
+ const originalDeleteCharBackward = input.deleteCharBackward.bind(input);
133
+ input.deleteCharBackward = (): boolean => {
134
+ // Try atomic deletion first (when picker is closed and cursor is at end of @-path)
135
+ if (tryAtomicDelete()) {
136
+ return true;
137
+ }
138
+ // Otherwise, use the default behavior
139
+ return originalDeleteCharBackward();
140
+ };
141
+
142
+ input.on(InputRenderableEvents.INPUT, async (value: string) => {
143
+ // Update highlights whenever input changes
144
+ updateFilePathHighlights();
145
+
146
+ const triggerMatch = value.match(/(?:^| )@([^ ]*)$/);
147
+ if (triggerMatch) {
148
+ const query = triggerMatch[1];
149
+ const { files } = await recursiveSearch(process.cwd(), query);
150
+ const normalizedFiles = files.map((f) => f.replace(process.cwd() + "/", ""));
151
+
152
+ if (normalizedFiles.length > 0) {
153
+ const pickerBottom = resolveBottom();
154
+ if (!state.activePicker) {
155
+ state.activePicker = new FilePickerView(
156
+ renderer,
157
+ normalizedFiles,
158
+ {
159
+ onSelect: (item) => {
160
+ // Keep the @ prefix when inserting the file path
161
+ const newValue = value.replace(/(^| )@[^ ]*$/, "$1@" + item);
162
+ input.value = newValue;
163
+ updateFilePathHighlights();
164
+ cleanupPicker();
165
+ },
166
+ onCancel: () => cleanupPicker(),
167
+ },
168
+ {
169
+ position: "absolute",
170
+ bottom: pickerBottom,
171
+ left: options.left ?? 0,
172
+ width: options.width ?? "100%",
173
+ maxHeight: 10,
174
+ zIndex: 2000,
175
+ }
176
+ );
177
+ container.add(state.activePicker);
178
+ } else {
179
+ (state.activePicker as any).bottom = pickerBottom;
180
+ state.activePicker.updateItems(normalizedFiles);
181
+ }
182
+ } else {
183
+ cleanupPicker();
184
+ }
185
+ renderer.requestRender();
186
+ } else {
187
+ cleanupPicker();
188
+ renderer.requestRender();
189
+ }
190
+ });
191
+
192
+ // Return enhanced cleanup
193
+ const originalCleanup = cleanupPicker;
194
+ return () => {
195
+ // Restore original deleteCharBackward method
196
+ input.deleteCharBackward = originalDeleteCharBackward;
197
+ syntaxStyle.destroy();
198
+ originalCleanup();
199
+ };
200
+ }
@@ -0,0 +1,62 @@
1
+ import { expect, test, describe, mock } from "bun:test";
2
+ import { buildVerticalBar, createCtrlCExitHandler } from "./input-chrome.js";
3
+ import { THEME } from "./theme.js";
4
+ import type { CliRenderer, TextRenderable, KeyEvent } from "@opentui/core";
5
+
6
+ describe("Input Chrome Utilities", () => {
7
+ describe("buildVerticalBar", () => {
8
+ test("should build a bar of correct height", () => {
9
+ expect(buildVerticalBar(1)).toBe("┃");
10
+ expect(buildVerticalBar(3)).toBe("┃\n┃\n┃");
11
+ expect(buildVerticalBar(0)).toBe("");
12
+ });
13
+
14
+ test("should handle undefined or percentage as default height 5", () => {
15
+ const expected = "┃\n┃\n┃\n┃\n┃";
16
+ expect(buildVerticalBar(undefined)).toBe(expected);
17
+ expect(buildVerticalBar("100%")).toBe(expected);
18
+ });
19
+ });
20
+
21
+ describe("createCtrlCExitHandler", () => {
22
+ test("should show hint on first Ctrl+C and exit on second", () => {
23
+ const mockRenderer = {
24
+ destroy: mock(() => {}),
25
+ requestRender: mock(() => {}),
26
+ } as unknown as CliRenderer;
27
+
28
+ const mockHintText = {
29
+ content: "",
30
+ fg: "",
31
+ } as unknown as TextRenderable;
32
+
33
+ const originalExit = process.exit;
34
+ Object.defineProperty(process, 'exit', {
35
+ value: mock(() => {}),
36
+ configurable: true
37
+ });
38
+
39
+ const handler = createCtrlCExitHandler({
40
+ renderer: mockRenderer,
41
+ hintText: mockHintText,
42
+ originalContent: "Original",
43
+ });
44
+
45
+ // First Ctrl+C
46
+ const handled1 = handler({ ctrl: true, name: "c" } as KeyEvent);
47
+ expect(handled1).toBe(true);
48
+ expect(mockHintText.content).toBe("Press Ctrl+C again to exit" as any);
49
+ expect(mockHintText.fg).toBe(THEME.warning as any);
50
+ expect(mockRenderer.requestRender).toHaveBeenCalled();
51
+ expect(mockRenderer.destroy).not.toHaveBeenCalled();
52
+
53
+ // Second Ctrl+C (immediate)
54
+ const handled2 = handler({ ctrl: true, name: "c" } as KeyEvent);
55
+ expect(handled2).toBe(true);
56
+ expect(mockRenderer.destroy).toHaveBeenCalled();
57
+ expect(process.exit).toHaveBeenCalledWith(0);
58
+
59
+ Object.defineProperty(process, 'exit', { value: originalExit });
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,172 @@
1
+ import {
2
+ CliRenderer,
3
+ BoxRenderable,
4
+ TextRenderable,
5
+ RGBA,
6
+ KeyEvent,
7
+ } from "@opentui/core";
8
+ import { createMultiGradientText, capitalizeProvider } from "../utils/index.js";
9
+ import { getConfiguredProvider, getConfiguredModel } from "../services/providers/index.js";
10
+ import { THEME } from "./theme.js";
11
+
12
+ const DOUBLE_TAP_THRESHOLD = 1000; // ms
13
+
14
+ /**
15
+ * Builds a vertical decorative bar string of the specified height
16
+ * Accepts number or percentage string (percentage strings default to 5)
17
+ */
18
+ export function buildVerticalBar(height: number | `${number}%` | undefined): string {
19
+ const h = typeof height === "number" ? height : 5;
20
+ if (h <= 0) return "";
21
+ return Array.from({ length: h }, () => "┃").join("\n");
22
+ }
23
+
24
+ /**
25
+ * Mouse hover/press handler factory for input containers
26
+ */
27
+ export function createInputContainerMouseHandler(
28
+ container: BoxRenderable,
29
+ focusTarget?: { focus: () => void }
30
+ ) {
31
+ return (event: { type: string }) => {
32
+ if (event.type === "click" && focusTarget) {
33
+ focusTarget.focus();
34
+ }
35
+ switch (event.type) {
36
+ case "over":
37
+ container.backgroundColor = "#2d372d";
38
+ break;
39
+ case "out":
40
+ container.backgroundColor = THEME.surface;
41
+ break;
42
+ case "down":
43
+ container.backgroundColor = "#1a241a";
44
+ break;
45
+ case "up":
46
+ container.backgroundColor = "#2d372d";
47
+ break;
48
+ }
49
+ };
50
+ }
51
+
52
+ const PROVIDER_GRADIENT_COLORS = [
53
+ RGBA.fromHex("#1b5e20"),
54
+ RGBA.fromHex("#43a047"),
55
+ RGBA.fromHex("#76ff03"),
56
+ ];
57
+
58
+ /**
59
+ * Creates a metadata row with provider gradient text
60
+ * Returns { row, providerLabel, modelLabel, updateProvider }
61
+ */
62
+ export function createProviderMetadataRow(
63
+ renderer: CliRenderer,
64
+ idPrefix: string
65
+ ) {
66
+ const row = new BoxRenderable(renderer, {
67
+ id: `${idPrefix}-metadata-row`,
68
+ width: "100%",
69
+ height: 1,
70
+ flexDirection: "row",
71
+ justifyContent: "flex-start",
72
+ gap: 1,
73
+ });
74
+
75
+ const pickleLabel = new TextRenderable(renderer, {
76
+ id: `${idPrefix}-meta-l`,
77
+ content: "Pickle",
78
+ fg: THEME.green,
79
+ });
80
+ row.add(pickleLabel);
81
+
82
+ const providerLabel = new TextRenderable(renderer, {
83
+ id: `${idPrefix}-meta-m`,
84
+ content: createMultiGradientText("Loading...", PROVIDER_GRADIENT_COLORS),
85
+ });
86
+ row.add(providerLabel);
87
+
88
+ const modelLabel = new TextRenderable(renderer, {
89
+ id: `${idPrefix}-meta-r`,
90
+ content: "",
91
+ fg: THEME.dim,
92
+ });
93
+ row.add(modelLabel);
94
+
95
+ // Fetch and update provider info asynchronously
96
+ Promise.all([getConfiguredProvider(), getConfiguredModel()])
97
+ .then(([provider, model]) => {
98
+ const displayProvider = capitalizeProvider(provider || "gemini");
99
+ providerLabel.content = createMultiGradientText(
100
+ displayProvider,
101
+ PROVIDER_GRADIENT_COLORS
102
+ );
103
+ modelLabel.content = model ? `(${model})` : "";
104
+ renderer.requestRender();
105
+ })
106
+ .catch(() => {
107
+ providerLabel.content = createMultiGradientText(
108
+ "Gemini",
109
+ PROVIDER_GRADIENT_COLORS
110
+ );
111
+ modelLabel.content = "";
112
+ renderer.requestRender();
113
+ });
114
+
115
+ return {
116
+ row,
117
+ pickleLabel,
118
+ providerLabel,
119
+ modelLabel,
120
+ };
121
+ }
122
+
123
+ export interface CtrlCExitHandlerOptions {
124
+ /** The renderer to use for requestRender and destroy */
125
+ renderer: CliRenderer;
126
+ /** The text renderable to show the hint in */
127
+ hintText: TextRenderable;
128
+ /** The original content to restore after timeout */
129
+ originalContent: string | ReturnType<typeof createMultiGradientText>;
130
+ /** Optional callback to check if handler should be skipped (e.g., picker active) */
131
+ shouldSkip?: () => boolean;
132
+ }
133
+
134
+ /**
135
+ * Creates a Ctrl+C double-tap exit handler.
136
+ * Returns a keypress handler function that can be attached to renderer.keyInput.on("keypress", ...)
137
+ */
138
+ export function createCtrlCExitHandler(options: CtrlCExitHandlerOptions) {
139
+ const { renderer, hintText, originalContent, shouldSkip } = options;
140
+ let lastCtrlCTime = 0;
141
+ let ctrlCHintTimeout: ReturnType<typeof setTimeout> | null = null;
142
+
143
+ return (key: KeyEvent): boolean => {
144
+ if (shouldSkip?.()) return false;
145
+
146
+ if (key.ctrl && key.name === "c") {
147
+ const now = Date.now();
148
+ if (now - lastCtrlCTime < DOUBLE_TAP_THRESHOLD) {
149
+ // Double Ctrl+C - exit
150
+ if (ctrlCHintTimeout) clearTimeout(ctrlCHintTimeout);
151
+ renderer.destroy();
152
+ process.exit(0);
153
+ } else {
154
+ // First Ctrl+C - show hint
155
+ lastCtrlCTime = now;
156
+ hintText.content = "Press Ctrl+C again to exit";
157
+ hintText.fg = THEME.warning;
158
+ renderer.requestRender();
159
+
160
+ // Clear hint after threshold
161
+ if (ctrlCHintTimeout) clearTimeout(ctrlCHintTimeout);
162
+ ctrlCHintTimeout = setTimeout(() => {
163
+ hintText.content = originalContent;
164
+ hintText.fg = THEME.dim;
165
+ renderer.requestRender();
166
+ }, DOUBLE_TAP_THRESHOLD);
167
+ }
168
+ return true;
169
+ }
170
+ return false;
171
+ };
172
+ }
@@ -0,0 +1,68 @@
1
+ import { mock, expect, test, describe, beforeEach } from "bun:test";
2
+
3
+ export const mockAppendFile = mock(async () => {});
4
+ export const mockMkdir = mock(async () => {});
5
+
6
+ mock.module("node:fs/promises", () => ({
7
+ appendFile: mockAppendFile,
8
+ mkdir: mockMkdir,
9
+ }));
10
+
11
+ // Force reload of logger
12
+ const { logInfo, logSuccess, logError, logWarn, logDebug } = await import("./logger.js?t=" + Date.now());
13
+
14
+ describe("UI Logger", () => {
15
+ beforeEach(() => {
16
+ mockAppendFile.mockClear();
17
+ mockMkdir.mockClear();
18
+ });
19
+
20
+ const waitForAppend = () => new Promise<void>((resolve) => {
21
+ const check = () => {
22
+ if (mockAppendFile.mock.calls.length > 0) {
23
+ resolve();
24
+ } else {
25
+ setTimeout(check, 10);
26
+ }
27
+ };
28
+ check();
29
+ });
30
+
31
+ test("logInfo should write [INFO] prefix to log file", async () => {
32
+ logInfo("Test message");
33
+ await waitForAppend();
34
+
35
+ expect(mockMkdir).toHaveBeenCalled();
36
+ expect(mockAppendFile).toHaveBeenCalled();
37
+ const content = (mockAppendFile.mock.calls[0] as any[])[1];
38
+ expect(content).toContain("[INFO] Test message\n");
39
+ });
40
+
41
+ test("logSuccess should write [SUCCESS] prefix", async () => {
42
+ logSuccess("Great success");
43
+ await waitForAppend();
44
+ expect(mockAppendFile).toHaveBeenCalled();
45
+ expect((mockAppendFile.mock.calls[0] as any[])[1]).toContain("[SUCCESS] Great success\n");
46
+ });
47
+
48
+ test("logError should write [ERROR] prefix", async () => {
49
+ logError("Terrible failure");
50
+ await waitForAppend();
51
+ expect(mockAppendFile).toHaveBeenCalled();
52
+ expect((mockAppendFile.mock.calls[0] as any[])[1]).toContain("[ERROR] Terrible failure\n");
53
+ });
54
+
55
+ test("logWarn should write [WARN] prefix", async () => {
56
+ logWarn("Be careful");
57
+ await waitForAppend();
58
+ expect(mockAppendFile).toHaveBeenCalled();
59
+ expect((mockAppendFile.mock.calls[0] as any[])[1]).toContain("[WARN] Be careful\n");
60
+ });
61
+
62
+ test("logDebug should write [DEBUG] prefix", async () => {
63
+ logDebug("Secret stuff");
64
+ await waitForAppend();
65
+ expect(mockAppendFile).toHaveBeenCalled();
66
+ expect((mockAppendFile.mock.calls[0] as any[])[1]).toContain("[DEBUG] Secret stuff\n");
67
+ });
68
+ });
@@ -0,0 +1,45 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const LOG_DIR = join(process.cwd(), ".pickle");
5
+ const LOG_FILE = join(LOG_DIR, "cli.log");
6
+
7
+ async function writeLog(line: string): Promise<void> {
8
+ try {
9
+ await mkdir(LOG_DIR, { recursive: true });
10
+ await appendFile(LOG_FILE, line + "\n", "utf-8");
11
+ } catch (err) {
12
+ // Only surface write issues when debugging; otherwise stay quiet
13
+ if (process.env.DEBUG) {
14
+ console.error("[logger] failed to write log:", err);
15
+ }
16
+ }
17
+ }
18
+
19
+ // Simple wrappers that write to log file; echo to console only in DEBUG
20
+ export function logInfo(msg: string) {
21
+ void writeLog(`[INFO] ${msg}`);
22
+ if (process.env.DEBUG) console.log(`[INFO] ${msg}`);
23
+ }
24
+
25
+ export function logSuccess(msg: string) {
26
+ void writeLog(`[SUCCESS] ${msg}`);
27
+ if (process.env.DEBUG) console.log(`[SUCCESS] ${msg}`);
28
+ }
29
+
30
+ export function logError(msg: string) {
31
+ void writeLog(`[ERROR] ${msg}`);
32
+ if (process.env.DEBUG) console.error(`[ERROR] ${msg}`);
33
+ }
34
+
35
+ export function logWarn(msg: string) {
36
+ void writeLog(`[WARN] ${msg}`);
37
+ if (process.env.DEBUG) console.warn(`[WARN] ${msg}`);
38
+ }
39
+
40
+ export function logDebug(msg: string) {
41
+ if (process.env.DEBUG) {
42
+ console.debug(`[DEBUG] ${msg}`);
43
+ }
44
+ void writeLog(`[DEBUG] ${msg}`);
45
+ }
@@ -0,0 +1,6 @@
1
+ import { mock } from "bun:test";
2
+ import { MockCliRenderer } from "./test-setup.js";
3
+
4
+ export const createMockRenderer = () => {
5
+ return new MockCliRenderer();
6
+ };
@@ -0,0 +1,65 @@
1
+ import { expect, test, describe, mock, beforeEach, afterEach } from "bun:test";
2
+ import { ProgressSpinner } from "./spinner.js";
3
+
4
+ // Mock logger to avoid side effects during spinner tests
5
+ mock.module("./logger.js", () => ({
6
+ logInfo: mock(() => {}),
7
+ logSuccess: mock(() => {}),
8
+ logError: mock(() => {}),
9
+ }));
10
+
11
+ describe("ProgressSpinner", () => {
12
+ const originalWrite = process.stdout.write;
13
+ let capturedOutput = "";
14
+
15
+ beforeEach(() => {
16
+ capturedOutput = "";
17
+ // @ts-ignore - mocking stdout.write
18
+ process.stdout.write = mock((str: string | Uint8Array) => {
19
+ capturedOutput += str.toString();
20
+ return true;
21
+ });
22
+ });
23
+
24
+ afterEach(() => {
25
+ process.stdout.write = originalWrite;
26
+ });
27
+
28
+ test("should initialize with label", () => {
29
+ const spinner = new ProgressSpinner("Loading");
30
+ spinner.updateStep("Step 1");
31
+ expect(capturedOutput).toBe("\rLoading: Step 1");
32
+ });
33
+
34
+ test("should initialize with active settings", () => {
35
+ const spinner = new ProgressSpinner("Tasks", ["A", "B"]);
36
+ spinner.updateStep("Running");
37
+ expect(capturedOutput).toBe("\rTasks [A, B]: Running");
38
+ });
39
+
40
+ test("success() should clear line and stop", () => {
41
+ const spinner = new ProgressSpinner("Build");
42
+ spinner.updateStep("compiling");
43
+ spinner.success("Done");
44
+ expect(capturedOutput).toContain("\rBuild: compiling");
45
+ expect(capturedOutput).toContain("\n");
46
+ });
47
+
48
+ test("error() should clear line and stop", () => {
49
+ const spinner = new ProgressSpinner("Build");
50
+ spinner.updateStep("compiling");
51
+ spinner.error("Fail");
52
+ expect(capturedOutput).toContain("\rBuild: compiling");
53
+ expect(capturedOutput).toContain("\n");
54
+ });
55
+
56
+ test("stop() should only write newline if active", () => {
57
+ const spinner = new ProgressSpinner("Idle");
58
+ spinner.stop();
59
+ expect(capturedOutput).toBe("");
60
+
61
+ spinner.updateStep("Work");
62
+ spinner.stop();
63
+ expect(capturedOutput).toContain("\n");
64
+ });
65
+ });
@@ -0,0 +1,41 @@
1
+ import { logInfo, logSuccess, logError } from "./logger.js";
2
+
3
+ export class ProgressSpinner {
4
+ private active = false;
5
+ private currentLabel: string;
6
+
7
+ constructor(label: string, activeSettings?: string[]) {
8
+ this.currentLabel = label;
9
+ if (activeSettings && activeSettings.length > 0) {
10
+ this.currentLabel += ` [${activeSettings.join(", ")}]`;
11
+ }
12
+ }
13
+
14
+ updateStep(step: string) {
15
+ this.active = true;
16
+ // In a real TUI, this would update a specific line.
17
+ // For now, we just print if it's a significant change or debug
18
+ // To avoid spamming stdout, we might throttle this or only show significant updates
19
+ // For this implementation, we'll keep it simple:
20
+ process.stdout.write(`\r${this.currentLabel}: ${step}`);
21
+ }
22
+
23
+ success(msg = "Done") {
24
+ this.active = false;
25
+ process.stdout.write("\n"); // Clear line end
26
+ logSuccess(`${this.currentLabel}: ${msg}`);
27
+ }
28
+
29
+ error(msg = "Failed") {
30
+ this.active = false;
31
+ process.stdout.write("\n"); // Clear line end
32
+ logError(`${this.currentLabel}: ${msg}`);
33
+ }
34
+
35
+ stop() {
36
+ if (this.active) {
37
+ process.stdout.write("\n");
38
+ this.active = false;
39
+ }
40
+ }
41
+ }