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.
- package/README.md +242 -0
- package/bin.js +3 -0
- package/dist/pickle +0 -0
- package/dist/worker-executor.js +207 -0
- package/package.json +53 -0
- package/src/games/GameSidebarManager.test.ts +64 -0
- package/src/games/GameSidebarManager.ts +78 -0
- package/src/games/gameboy/GameboyView.test.ts +25 -0
- package/src/games/gameboy/GameboyView.ts +100 -0
- package/src/games/gameboy/gameboy-polyfills.ts +313 -0
- package/src/games/index.test.ts +9 -0
- package/src/games/index.ts +4 -0
- package/src/games/snake/SnakeGame.test.ts +35 -0
- package/src/games/snake/SnakeGame.ts +145 -0
- package/src/games/snake/SnakeView.test.ts +25 -0
- package/src/games/snake/SnakeView.ts +290 -0
- package/src/index.test.ts +24 -0
- package/src/index.ts +141 -0
- package/src/services/commands/worker.test.ts +14 -0
- package/src/services/commands/worker.ts +262 -0
- package/src/services/config/index.ts +2 -0
- package/src/services/config/settings.test.ts +42 -0
- package/src/services/config/settings.ts +220 -0
- package/src/services/config/state.test.ts +88 -0
- package/src/services/config/state.ts +130 -0
- package/src/services/config/types.ts +39 -0
- package/src/services/execution/index.ts +1 -0
- package/src/services/execution/pickle-source.test.ts +88 -0
- package/src/services/execution/pickle-source.ts +264 -0
- package/src/services/execution/prompt.test.ts +93 -0
- package/src/services/execution/prompt.ts +322 -0
- package/src/services/execution/sequential.test.ts +91 -0
- package/src/services/execution/sequential.ts +422 -0
- package/src/services/execution/worker-client.ts +94 -0
- package/src/services/execution/worker-executor.ts +41 -0
- package/src/services/execution/worker.test.ts +73 -0
- package/src/services/git/branch.test.ts +147 -0
- package/src/services/git/branch.ts +128 -0
- package/src/services/git/diff.test.ts +113 -0
- package/src/services/git/diff.ts +323 -0
- package/src/services/git/index.ts +4 -0
- package/src/services/git/pr.test.ts +104 -0
- package/src/services/git/pr.ts +192 -0
- package/src/services/git/worktree.test.ts +99 -0
- package/src/services/git/worktree.ts +141 -0
- package/src/services/providers/base.test.ts +86 -0
- package/src/services/providers/base.ts +438 -0
- package/src/services/providers/codex.test.ts +39 -0
- package/src/services/providers/codex.ts +208 -0
- package/src/services/providers/gemini.test.ts +40 -0
- package/src/services/providers/gemini.ts +169 -0
- package/src/services/providers/index.test.ts +28 -0
- package/src/services/providers/index.ts +41 -0
- package/src/services/providers/opencode.test.ts +64 -0
- package/src/services/providers/opencode.ts +228 -0
- package/src/services/providers/types.ts +44 -0
- package/src/skills/code-implementer.md +105 -0
- package/src/skills/code-researcher.md +78 -0
- package/src/skills/implementation-planner.md +105 -0
- package/src/skills/plan-reviewer.md +100 -0
- package/src/skills/prd-drafter.md +123 -0
- package/src/skills/research-reviewer.md +79 -0
- package/src/skills/ruthless-refactorer.md +52 -0
- package/src/skills/ticket-manager.md +135 -0
- package/src/types/index.ts +2 -0
- package/src/types/rpc.ts +14 -0
- package/src/types/tasks.ts +50 -0
- package/src/types.d.ts +9 -0
- package/src/ui/common.ts +28 -0
- package/src/ui/components/FilePickerView.test.ts +79 -0
- package/src/ui/components/FilePickerView.ts +161 -0
- package/src/ui/components/MultiLineInput.test.ts +27 -0
- package/src/ui/components/MultiLineInput.ts +233 -0
- package/src/ui/components/SessionChip.test.ts +69 -0
- package/src/ui/components/SessionChip.ts +481 -0
- package/src/ui/components/ToyboxSidebar.test.ts +36 -0
- package/src/ui/components/ToyboxSidebar.ts +329 -0
- package/src/ui/components/refactor_plan.md +35 -0
- package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
- package/src/ui/controllers/DashboardController.ts +650 -0
- package/src/ui/dashboard.test.ts +43 -0
- package/src/ui/dashboard.ts +309 -0
- package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
- package/src/ui/dialogs/DashboardDialog.ts +399 -0
- package/src/ui/dialogs/Dialog.test.ts +50 -0
- package/src/ui/dialogs/Dialog.ts +241 -0
- package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
- package/src/ui/dialogs/DialogSidebar.ts +71 -0
- package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
- package/src/ui/dialogs/DiffViewDialog.ts +510 -0
- package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
- package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
- package/src/ui/dialogs/test-utils.ts +232 -0
- package/src/ui/file-picker-utils.test.ts +71 -0
- package/src/ui/file-picker-utils.ts +200 -0
- package/src/ui/input-chrome.test.ts +62 -0
- package/src/ui/input-chrome.ts +172 -0
- package/src/ui/logger.test.ts +68 -0
- package/src/ui/logger.ts +45 -0
- package/src/ui/mock-factory.ts +6 -0
- package/src/ui/spinner.test.ts +65 -0
- package/src/ui/spinner.ts +41 -0
- package/src/ui/test-setup.ts +300 -0
- package/src/ui/theme.test.ts +23 -0
- package/src/ui/theme.ts +16 -0
- package/src/ui/views/LandingView.integration.test.ts +21 -0
- package/src/ui/views/LandingView.test.ts +24 -0
- package/src/ui/views/LandingView.ts +221 -0
- package/src/ui/views/LogView.test.ts +24 -0
- package/src/ui/views/LogView.ts +277 -0
- package/src/ui/views/ToyboxView.test.ts +46 -0
- package/src/ui/views/ToyboxView.ts +323 -0
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +100 -0
- package/src/utils/index.test.ts +68 -0
- package/src/utils/index.ts +95 -0
- package/src/utils/persona.test.ts +12 -0
- package/src/utils/persona.ts +8 -0
- package/src/utils/project-root.test.ts +38 -0
- package/src/utils/project-root.ts +22 -0
- package/src/utils/resources.test.ts +64 -0
- package/src/utils/resources.ts +92 -0
- package/src/utils/search.test.ts +48 -0
- package/src/utils/search.ts +103 -0
- package/src/utils/session-tracker.test.ts +46 -0
- package/src/utils/session-tracker.ts +67 -0
- package/src/utils/spinner.test.ts +54 -0
- 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
|
+
});
|
package/src/ui/logger.ts
ADDED
|
@@ -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,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
|
+
}
|