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,650 @@
|
|
|
1
|
+
import { BoxRenderable, CliRenderer, Renderable, TextRenderable, TextAttributes, TabSelectRenderable, InputRenderable, InputRenderableEvents, RenderableEvents, KeyEvent, RGBA, createTimeline, MouseEvent, TabSelectRenderableEvents } from "@opentui/core";
|
|
2
|
+
import { TabSelectOption } from "@opentui/core";
|
|
3
|
+
import { SessionChip } from "../components/SessionChip.js";
|
|
4
|
+
import { SessionData } from "../../types/tasks.js";
|
|
5
|
+
import { createSession, listSessions } from "../../services/config/state.js";
|
|
6
|
+
import { WorkerExecutorClient } from "../../services/execution/worker-client.js";
|
|
7
|
+
import { THEME } from "../theme.js";
|
|
8
|
+
import { ToyboxView } from "../views/ToyboxView.js";
|
|
9
|
+
import { isSessionActive } from "../../utils/index.js";
|
|
10
|
+
import { FilePickerView } from "../components/FilePickerView.js";
|
|
11
|
+
import { recursiveSearch } from "../../utils/search.js";
|
|
12
|
+
import { setupFilePicker, FilePickerState } from "../file-picker-utils.js";
|
|
13
|
+
import { sessionTracker, type TrackedSession } from "../../utils/session-tracker.js";
|
|
14
|
+
import { DashboardDialog } from "../dialogs/DashboardDialog.js";
|
|
15
|
+
import { DiffViewDialog } from "../dialogs/DiffViewDialog.js";
|
|
16
|
+
import { PRPreviewDialog } from "../dialogs/PRPreviewDialog.js";
|
|
17
|
+
import { cleanupPickleWorktree, syncWorktreeToOriginal, createPullRequest, isGhAvailable, getGitStatusInfo } from "../../services/git/index.js";
|
|
18
|
+
import { isGameboyActive } from "../../games/gameboy/GameboyView.js";
|
|
19
|
+
import { execCommand } from "../../services/providers/base.js";
|
|
20
|
+
|
|
21
|
+
export interface DashboardUI {
|
|
22
|
+
tabs: TabSelectRenderable | undefined;
|
|
23
|
+
separator: Renderable;
|
|
24
|
+
dashboardView: BoxRenderable;
|
|
25
|
+
toyboxView: Renderable;
|
|
26
|
+
inputGroup: BoxRenderable;
|
|
27
|
+
landingView: Renderable;
|
|
28
|
+
mainContent: BoxRenderable;
|
|
29
|
+
globalFooter: Renderable;
|
|
30
|
+
input: InputRenderable;
|
|
31
|
+
inputContainer: BoxRenderable;
|
|
32
|
+
metadataLabel: TextRenderable;
|
|
33
|
+
modelLabel: TextRenderable;
|
|
34
|
+
footerLeft: TextRenderable;
|
|
35
|
+
footerRight: TextRenderable;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class DashboardController {
|
|
39
|
+
private isHomeHidden = false;
|
|
40
|
+
private chips: SessionChip[] = [];
|
|
41
|
+
private activeExecutors = new Map<string, WorkerExecutorClient>();
|
|
42
|
+
private focusedChipIndex = -1;
|
|
43
|
+
private selectedChipIndex = -1;
|
|
44
|
+
private selectedSession: SessionData | null = null;
|
|
45
|
+
private isListFocused = false;
|
|
46
|
+
private _ui?: DashboardUI;
|
|
47
|
+
private toybox?: ToyboxView;
|
|
48
|
+
private isInToybox = false;
|
|
49
|
+
private dashboardDialog: DashboardDialog;
|
|
50
|
+
private diffViewDialog: DiffViewDialog;
|
|
51
|
+
private prPreviewDialog: PRPreviewDialog;
|
|
52
|
+
|
|
53
|
+
get ui(): DashboardUI | undefined {
|
|
54
|
+
return this._ui;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
set ui(value: DashboardUI | undefined) {
|
|
58
|
+
this._ui = value;
|
|
59
|
+
if (value) {
|
|
60
|
+
this.setupInputFilePicker();
|
|
61
|
+
value.input?.focus();
|
|
62
|
+
this.renderer.requestRender();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private setupInputFilePicker() {
|
|
67
|
+
if (!this.ui) return;
|
|
68
|
+
const resolveBottom = (): number => {
|
|
69
|
+
const height = this.ui?.inputContainer?.height;
|
|
70
|
+
return typeof height === "number" ? height : 5;
|
|
71
|
+
};
|
|
72
|
+
setupFilePicker(this.renderer, this.ui.input, this.ui.inputContainer as BoxRenderable, this.pickerState, {
|
|
73
|
+
bottom: resolveBottom,
|
|
74
|
+
width: "100%",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private cleanupPicker() {
|
|
79
|
+
if (this.pickerState.activePicker) {
|
|
80
|
+
this.pickerState.activePicker.destroy();
|
|
81
|
+
this.renderer.root.remove(this.pickerState.activePicker.id);
|
|
82
|
+
this.pickerState.activePicker = null;
|
|
83
|
+
this.ui?.input.focus();
|
|
84
|
+
this.renderer.requestRender();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private ticker: ReturnType<typeof setInterval> | null = null;
|
|
89
|
+
private pickerState: FilePickerState = { activePicker: null };
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
private renderer: CliRenderer,
|
|
93
|
+
private sessionContainer: BoxRenderable
|
|
94
|
+
) {
|
|
95
|
+
this.dashboardDialog = new DashboardDialog(renderer);
|
|
96
|
+
this.renderer.root.add(this.dashboardDialog.root);
|
|
97
|
+
|
|
98
|
+
// Initialize diff view dialog
|
|
99
|
+
this.diffViewDialog = new DiffViewDialog(renderer, {
|
|
100
|
+
onMerge: async (session) => {
|
|
101
|
+
await this.mergeWorktree(session);
|
|
102
|
+
},
|
|
103
|
+
onCreatePR: async (session) => {
|
|
104
|
+
this.openPRPreview(session);
|
|
105
|
+
},
|
|
106
|
+
onReject: async (session) => {
|
|
107
|
+
await this.rejectWorktree(session);
|
|
108
|
+
},
|
|
109
|
+
onClose: () => {
|
|
110
|
+
// Restore focus to input when dialog closes
|
|
111
|
+
this.ui?.input.focus();
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
this.renderer.root.add(this.diffViewDialog.root);
|
|
115
|
+
|
|
116
|
+
// Initialize PR preview dialog
|
|
117
|
+
this.prPreviewDialog = new PRPreviewDialog(renderer, {
|
|
118
|
+
onConfirm: async (session, title, body) => {
|
|
119
|
+
await this.createPullRequest(session, title, body);
|
|
120
|
+
},
|
|
121
|
+
onCancel: () => {
|
|
122
|
+
// Go back to diff view
|
|
123
|
+
if (this.selectedSession?.worktreeInfo) {
|
|
124
|
+
this.ui?.input.blur();
|
|
125
|
+
this.diffViewDialog.show(this.selectedSession);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
this.renderer.root.add(this.prPreviewDialog.root);
|
|
130
|
+
|
|
131
|
+
this.init();
|
|
132
|
+
this.setupKeyboardNav();
|
|
133
|
+
this.startTicker();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public hasActivePicker(): boolean {
|
|
137
|
+
return !!this.pickerState.activePicker || !!this.pickerState.justClosed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private updateFooter() {
|
|
141
|
+
if (!this.ui) return;
|
|
142
|
+
|
|
143
|
+
if (this.isInToybox) {
|
|
144
|
+
this.ui.footerLeft.content = "";
|
|
145
|
+
} else {
|
|
146
|
+
const dialogHint = this.ui?.input.focused ? "CTRL+S: Dialog | " : "";
|
|
147
|
+
this.ui.footerLeft.content = `${dialogHint}CTRL+T: Toybox`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Right: Empty or Time
|
|
151
|
+
this.ui.footerRight.content = new Date().toLocaleTimeString();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private startTicker() {
|
|
155
|
+
this.ticker = setInterval(() => {
|
|
156
|
+
if (!this.ui) return;
|
|
157
|
+
|
|
158
|
+
this.chips.forEach(chip => {
|
|
159
|
+
if (isSessionActive(chip.session.status)) {
|
|
160
|
+
chip.update(chip.session);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
this.updateFooter();
|
|
165
|
+
this.renderer.requestRender();
|
|
166
|
+
}, 1000);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async init() {
|
|
170
|
+
// No history initialization needed
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private addChip(session: SessionData, container: BoxRenderable, prepend: boolean = false): SessionChip {
|
|
174
|
+
const chip = new SessionChip(
|
|
175
|
+
this.renderer,
|
|
176
|
+
session,
|
|
177
|
+
(s) => this.selectSession(s),
|
|
178
|
+
(s) => this.cancelSession(s),
|
|
179
|
+
(s) => this.openDiffView(s)
|
|
180
|
+
);
|
|
181
|
+
container.add(chip);
|
|
182
|
+
if (prepend) {
|
|
183
|
+
this.chips.unshift(chip);
|
|
184
|
+
} else {
|
|
185
|
+
this.chips.push(chip);
|
|
186
|
+
}
|
|
187
|
+
return chip;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private cancelSession(sessionData: SessionData) {
|
|
191
|
+
const executor = this.activeExecutors.get(sessionData.id);
|
|
192
|
+
if (executor) {
|
|
193
|
+
executor.stop();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
sessionData.status = "CANCELLED";
|
|
197
|
+
|
|
198
|
+
const chip = this.chips.find(c => c.session.id === sessionData.id);
|
|
199
|
+
if (chip) {
|
|
200
|
+
chip.update(sessionData);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.activeExecutors.delete(sessionData.id);
|
|
204
|
+
|
|
205
|
+
this.renderer.requestRender();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private setupKeyboardNav() {
|
|
209
|
+
this.renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
210
|
+
if (this.hasActivePicker()) return;
|
|
211
|
+
|
|
212
|
+
// Check if any "game" container is in renderer.root
|
|
213
|
+
// This MUST happen before any other key handling to avoid leakage
|
|
214
|
+
const rootChildren = this.renderer.root.getChildren();
|
|
215
|
+
const hasGame = rootChildren.some(c => c.id === "snake-container" || c.id === "doom-container");
|
|
216
|
+
const hasGameboy = isGameboyActive();
|
|
217
|
+
|
|
218
|
+
// Show dashboard dialog with Ctrl+S - only when input is focused
|
|
219
|
+
if (key.ctrl && key.name === "s") {
|
|
220
|
+
if (hasGameboy || hasGame || this.isInToybox) return;
|
|
221
|
+
|
|
222
|
+
if (this.dashboardDialog.isOpen()) {
|
|
223
|
+
this.dashboardDialog.hide();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (this.diffViewDialog.isOpen()) {
|
|
228
|
+
this.diffViewDialog.hide();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Open the last selected session if available, otherwise the most recent
|
|
232
|
+
const targetSession =
|
|
233
|
+
(this.selectedSession ? this.chips.find(c => c.session.id === this.selectedSession!.id)?.session : undefined) ||
|
|
234
|
+
this.chips[0]?.session;
|
|
235
|
+
|
|
236
|
+
if (targetSession) {
|
|
237
|
+
this.dashboardDialog.update(targetSession);
|
|
238
|
+
this.dashboardDialog.show();
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// If a game is active, let it handle input
|
|
244
|
+
if (hasGame) return;
|
|
245
|
+
|
|
246
|
+
if (!this.ui?.mainContent.visible) return;
|
|
247
|
+
|
|
248
|
+
if (key.name === "escape") {
|
|
249
|
+
if (this.dashboardDialog.isOpen()) {
|
|
250
|
+
this.dashboardDialog.hide();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (key.name === "tab" && this.hasActivePicker()) return;
|
|
256
|
+
|
|
257
|
+
if (!this.isListFocused || this.focusedChipIndex === -1) return;
|
|
258
|
+
|
|
259
|
+
const chip = this.chips[this.focusedChipIndex];
|
|
260
|
+
if (!chip) return;
|
|
261
|
+
|
|
262
|
+
if (key.name === "return" || key.name === "linefeed" || key.name === "enter" || key.name === "space") {
|
|
263
|
+
this.selectSession(chip.session);
|
|
264
|
+
} else if (key.name === "up") {
|
|
265
|
+
this.navigateChips(-1);
|
|
266
|
+
} else if (key.name === "down") {
|
|
267
|
+
this.navigateChips(1);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private selectSession(session: SessionData, silent: boolean = false) {
|
|
273
|
+
const index = this.chips.findIndex(c => c.session.id === session.id);
|
|
274
|
+
|
|
275
|
+
this.chips.forEach(c => c.resetHover());
|
|
276
|
+
|
|
277
|
+
if (index !== -1) {
|
|
278
|
+
if (this.selectedChipIndex !== -1 && this.selectedChipIndex !== index) {
|
|
279
|
+
this.chips[this.selectedChipIndex].setSelected(false);
|
|
280
|
+
}
|
|
281
|
+
this.selectedChipIndex = index;
|
|
282
|
+
this.chips[index].setSelected(true);
|
|
283
|
+
|
|
284
|
+
if (this.focusedChipIndex !== -1 && this.focusedChipIndex !== index) {
|
|
285
|
+
this.chips[this.focusedChipIndex].blur();
|
|
286
|
+
}
|
|
287
|
+
this.focusedChipIndex = index;
|
|
288
|
+
this.chips[index].focus();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Always update the selected session
|
|
292
|
+
this.selectedSession = session;
|
|
293
|
+
if (this.ui) {
|
|
294
|
+
this.ui.metadataLabel.content = session.isPrdMode ? "Pickle PRD" : "Pickle";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Update dashboard dialog
|
|
298
|
+
this.dashboardDialog.update(session);
|
|
299
|
+
|
|
300
|
+
// Only show dialog when not in silent mode and it's a chip click (Ctrl+S or mouse click)
|
|
301
|
+
// Don't show dialog when creating a new session (silent = true)
|
|
302
|
+
if (!silent) {
|
|
303
|
+
this.dashboardDialog.show();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.renderer.requestRender();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private navigateChips(delta: number) {
|
|
310
|
+
if (this.chips.length === 0) return;
|
|
311
|
+
|
|
312
|
+
if (this.focusedChipIndex !== -1) {
|
|
313
|
+
this.chips[this.focusedChipIndex].blur();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.focusedChipIndex += delta;
|
|
317
|
+
if (this.focusedChipIndex < 0) this.focusedChipIndex = this.chips.length - 1;
|
|
318
|
+
if (this.focusedChipIndex >= this.chips.length) this.focusedChipIndex = 0;
|
|
319
|
+
|
|
320
|
+
this.chips[this.focusedChipIndex].focus();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
public setListFocus(focused: boolean) {
|
|
324
|
+
this.isListFocused = focused;
|
|
325
|
+
if (focused) {
|
|
326
|
+
if (this.focusedChipIndex === -1 && this.chips.length > 0) {
|
|
327
|
+
this.focusedChipIndex = 0;
|
|
328
|
+
}
|
|
329
|
+
if (this.focusedChipIndex !== -1) {
|
|
330
|
+
this.chips[this.focusedChipIndex].focus();
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
if (this.focusedChipIndex !== -1) {
|
|
334
|
+
this.chips[this.focusedChipIndex].blur();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
public destroy() {
|
|
340
|
+
this.dashboardDialog.destroy();
|
|
341
|
+
this.diffViewDialog.destroy();
|
|
342
|
+
this.prPreviewDialog.destroy();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
public async ask(query: string): Promise<string> {
|
|
346
|
+
if (!this.ui) return "n";
|
|
347
|
+
|
|
348
|
+
return new Promise((resolve) => {
|
|
349
|
+
const originalPlaceholder = this.ui!.input.placeholder;
|
|
350
|
+
const originalValue = this.ui!.input.value;
|
|
351
|
+
|
|
352
|
+
this.ui!.input.placeholder = query;
|
|
353
|
+
this.ui!.input.value = "";
|
|
354
|
+
this.ui!.input.focus();
|
|
355
|
+
|
|
356
|
+
const onEnter = (value: string) => {
|
|
357
|
+
this.ui!.input.removeListener(InputRenderableEvents.ENTER, onEnter);
|
|
358
|
+
this.ui!.input.placeholder = originalPlaceholder;
|
|
359
|
+
this.ui!.input.value = originalValue;
|
|
360
|
+
resolve(value);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
this.ui!.input.on(InputRenderableEvents.ENTER, onEnter);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
public toggleToybox() {
|
|
370
|
+
if (!this.ui) return;
|
|
371
|
+
|
|
372
|
+
if (this.isInToybox) {
|
|
373
|
+
this.showDashboard();
|
|
374
|
+
} else {
|
|
375
|
+
this.showToybox();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private showDashboard() {
|
|
380
|
+
if (!this.ui) return;
|
|
381
|
+
|
|
382
|
+
this.isInToybox = false;
|
|
383
|
+
this.ui.dashboardView.visible = true;
|
|
384
|
+
this.ui.toyboxView.visible = false;
|
|
385
|
+
this.ui.inputGroup.visible = true;
|
|
386
|
+
this.ui.separator.visible = false;
|
|
387
|
+
|
|
388
|
+
if (this.toybox) {
|
|
389
|
+
this.toybox.disable();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!this.isHomeHidden) {
|
|
393
|
+
this.setHomeViewVisible(true);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this.ui.input.focus();
|
|
397
|
+
this.updateFooter();
|
|
398
|
+
this.renderer.requestRender();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private showToybox() {
|
|
402
|
+
if (!this.ui) return;
|
|
403
|
+
|
|
404
|
+
this.isInToybox = true;
|
|
405
|
+
this.ui.dashboardView.visible = false;
|
|
406
|
+
this.ui.toyboxView.visible = true;
|
|
407
|
+
this.ui.inputGroup.visible = false;
|
|
408
|
+
this.ui.separator.visible = false;
|
|
409
|
+
|
|
410
|
+
if (!this.toybox && this.ui.toyboxView instanceof BoxRenderable) {
|
|
411
|
+
this.ui.toyboxView.getChildren().forEach((child) => this.ui!.toyboxView.remove(child.id));
|
|
412
|
+
this.toybox = new ToyboxView(
|
|
413
|
+
this.renderer,
|
|
414
|
+
this.ui.toyboxView,
|
|
415
|
+
undefined,
|
|
416
|
+
() => this.showDashboard()
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (this.toybox) {
|
|
421
|
+
this.toybox.enable();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this.ui.input.blur();
|
|
425
|
+
this.setHomeViewVisible(false);
|
|
426
|
+
this.updateFooter();
|
|
427
|
+
this.renderer.requestRender();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private setHomeViewVisible(visible: boolean) {
|
|
431
|
+
if (!this.ui) return;
|
|
432
|
+
this.ui.separator.visible = visible;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private hideHomeView() {
|
|
436
|
+
if (this.isHomeHidden || !this.ui) return;
|
|
437
|
+
|
|
438
|
+
this.setHomeViewVisible(false);
|
|
439
|
+
this.isHomeHidden = true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
public startDashboardSession(prompt: string, mode: "pickle" | "pickle-prd" = "pickle") {
|
|
443
|
+
if (!this.ui) return;
|
|
444
|
+
|
|
445
|
+
this.ui.landingView.parent?.remove(this.ui.landingView.id);
|
|
446
|
+
this.ui.mainContent.visible = true;
|
|
447
|
+
this.ui.globalFooter.visible = true;
|
|
448
|
+
|
|
449
|
+
this.spawnSession(prompt, mode);
|
|
450
|
+
|
|
451
|
+
this.ui.input.focus();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async spawnSession(prompt: string, mode: "pickle" | "pickle-prd" = "pickle") {
|
|
455
|
+
if (!prompt.trim()) return;
|
|
456
|
+
|
|
457
|
+
this.hideHomeView();
|
|
458
|
+
|
|
459
|
+
const isPrdMode = mode === "pickle-prd";
|
|
460
|
+
const cwd = process.cwd();
|
|
461
|
+
const state = await createSession(cwd, prompt, isPrdMode);
|
|
462
|
+
|
|
463
|
+
// Fetch git status for display
|
|
464
|
+
const gitStatus = await getGitStatusInfo(cwd);
|
|
465
|
+
|
|
466
|
+
if (this.ui) {
|
|
467
|
+
this.ui.metadataLabel.content = isPrdMode ? "Pickle PRD" : "Pickle";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const session: SessionData = {
|
|
471
|
+
id: state.session_dir,
|
|
472
|
+
prompt,
|
|
473
|
+
engine: "Gemini CLI",
|
|
474
|
+
status: "Initializing...",
|
|
475
|
+
startTime: Date.now(),
|
|
476
|
+
isPrdMode: isPrdMode,
|
|
477
|
+
gitStatus,
|
|
478
|
+
workingDir: cwd,
|
|
479
|
+
iteration: 1,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Track this session for ToyboxSidebar
|
|
483
|
+
const trackedSession: TrackedSession = {
|
|
484
|
+
id: state.session_dir,
|
|
485
|
+
prompt,
|
|
486
|
+
createdAt: Date.now(),
|
|
487
|
+
startedAt: state.started_at,
|
|
488
|
+
sessionDir: state.session_dir,
|
|
489
|
+
};
|
|
490
|
+
sessionTracker.addSession(trackedSession);
|
|
491
|
+
|
|
492
|
+
const chip = this.addChip(session, this.sessionContainer, true);
|
|
493
|
+
|
|
494
|
+
this.selectSession(session, true);
|
|
495
|
+
|
|
496
|
+
const executor = new WorkerExecutorClient();
|
|
497
|
+
this.activeExecutors.set(session.id, executor);
|
|
498
|
+
|
|
499
|
+
executor.onInput((q) => this.ask(q));
|
|
500
|
+
|
|
501
|
+
executor.onProgress((report) => {
|
|
502
|
+
let status = `Iteration ${report.iteration}`;
|
|
503
|
+
if (report.taskTitle) status += `: ${report.taskTitle}`;
|
|
504
|
+
if (report.step) status += ` (${report.step})`;
|
|
505
|
+
|
|
506
|
+
session.status = status;
|
|
507
|
+
session.iteration = report.iteration;
|
|
508
|
+
chip.update(session);
|
|
509
|
+
sessionTracker.updateSession(session.id, {
|
|
510
|
+
status,
|
|
511
|
+
iteration: report.iteration,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
if (this.selectedSession?.id === session.id) {
|
|
515
|
+
this.dashboardDialog.update(session);
|
|
516
|
+
}
|
|
517
|
+
this.renderer.requestRender();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
executor.run(state).then((result) => {
|
|
521
|
+
this.activeExecutors.delete(session.id);
|
|
522
|
+
if (session.status.toLowerCase().includes("cancelled")) return;
|
|
523
|
+
session.status = "Done";
|
|
524
|
+
// Store worktree info if available
|
|
525
|
+
if (result?.worktreeInfo) {
|
|
526
|
+
session.worktreeInfo = result.worktreeInfo;
|
|
527
|
+
}
|
|
528
|
+
chip.update(session);
|
|
529
|
+
if (this.selectedSession?.id === session.id) {
|
|
530
|
+
this.dashboardDialog.update(session);
|
|
531
|
+
}
|
|
532
|
+
}).catch((err) => {
|
|
533
|
+
this.activeExecutors.delete(session.id);
|
|
534
|
+
if (session.status.toLowerCase().includes("cancelled")) return;
|
|
535
|
+
session.status = `ERROR: ${err.message}`;
|
|
536
|
+
chip.update(session);
|
|
537
|
+
if (this.selectedSession?.id === session.id) {
|
|
538
|
+
this.dashboardDialog.update(session);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private openDiffView(session: SessionData) {
|
|
544
|
+
if (!session.worktreeInfo) return;
|
|
545
|
+
// Blur the input so typing in diff view doesn't go to the prompt
|
|
546
|
+
this.ui?.input.blur();
|
|
547
|
+
this.diffViewDialog.show(session);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private openPRPreview(session: SessionData) {
|
|
551
|
+
if (!session.worktreeInfo) return;
|
|
552
|
+
this.diffViewDialog.hide();
|
|
553
|
+
this.prPreviewDialog.show(session);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Close all review dialogs and return focus to input
|
|
558
|
+
*/
|
|
559
|
+
public closeReviewDialogs() {
|
|
560
|
+
this.diffViewDialog.hide();
|
|
561
|
+
this.prPreviewDialog.hide();
|
|
562
|
+
this.ui?.input.focus();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private async mergeWorktree(session: SessionData) {
|
|
566
|
+
if (!session.worktreeInfo || !session.workingDir) return;
|
|
567
|
+
|
|
568
|
+
const { worktreeDir, branchName } = session.worktreeInfo;
|
|
569
|
+
const originalDir = session.workingDir;
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
// 1. Sync worktree changes to original directory (commit + merge)
|
|
573
|
+
await syncWorktreeToOriginal(worktreeDir, originalDir, branchName);
|
|
574
|
+
|
|
575
|
+
// 2. Clean up the worktree
|
|
576
|
+
await cleanupPickleWorktree(worktreeDir, originalDir);
|
|
577
|
+
|
|
578
|
+
// Clear worktree info after merge
|
|
579
|
+
session.worktreeInfo = undefined;
|
|
580
|
+
|
|
581
|
+
// Update chip to remove review button
|
|
582
|
+
const chip = this.chips.find(c => c.session.id === session.id);
|
|
583
|
+
if (chip) {
|
|
584
|
+
chip.update(session);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Hide dialog
|
|
588
|
+
this.diffViewDialog.hide();
|
|
589
|
+
|
|
590
|
+
this.renderer.requestRender();
|
|
591
|
+
} catch (error) {
|
|
592
|
+
console.error("Failed to merge worktree:", error);
|
|
593
|
+
// Could show error dialog here
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private async createPullRequest(session: SessionData, title: string, body: string) {
|
|
598
|
+
if (!session.worktreeInfo || !session.workingDir) return;
|
|
599
|
+
|
|
600
|
+
const { branchName, baseBranch, worktreeDir } = session.worktreeInfo;
|
|
601
|
+
const originalDir = session.workingDir;
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
// Create the PR
|
|
605
|
+
await createPullRequest(branchName, baseBranch, title, body);
|
|
606
|
+
|
|
607
|
+
// Clean up worktree (don't sync since we're using PR)
|
|
608
|
+
await cleanupPickleWorktree(worktreeDir, originalDir);
|
|
609
|
+
|
|
610
|
+
// Clear worktree info after PR creation
|
|
611
|
+
session.worktreeInfo = undefined;
|
|
612
|
+
|
|
613
|
+
// Update chip to remove review button
|
|
614
|
+
const chip = this.chips.find(c => c.session.id === session.id);
|
|
615
|
+
if (chip) {
|
|
616
|
+
chip.update(session);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Hide PR dialog
|
|
620
|
+
this.prPreviewDialog.hide();
|
|
621
|
+
|
|
622
|
+
this.renderer.requestRender();
|
|
623
|
+
} catch (error) {
|
|
624
|
+
console.error("Failed to create PR:", error);
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async rejectWorktree(session: SessionData) {
|
|
630
|
+
if (!session.worktreeInfo || !session.workingDir) return;
|
|
631
|
+
|
|
632
|
+
const { worktreeDir } = session.worktreeInfo;
|
|
633
|
+
const originalDir = session.workingDir;
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
await cleanupPickleWorktree(worktreeDir, originalDir);
|
|
637
|
+
session.worktreeInfo = undefined;
|
|
638
|
+
|
|
639
|
+
const chip = this.chips.find(c => c.session.id === session.id);
|
|
640
|
+
if (chip) {
|
|
641
|
+
chip.update(session);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
this.diffViewDialog.hide();
|
|
645
|
+
this.renderer.requestRender();
|
|
646
|
+
} catch (error) {
|
|
647
|
+
console.error("Failed to reject worktree:", error);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { expect, test, describe, mock } from "bun:test";
|
|
2
|
+
import { createMockRenderer } from "./mock-factory.ts";
|
|
3
|
+
|
|
4
|
+
// Mock views, controllers, and components
|
|
5
|
+
mock.module("./views/LandingView.js", () => ({
|
|
6
|
+
createLandingView: async () => ({
|
|
7
|
+
root: { visible: false, focus: mock(() => {}) },
|
|
8
|
+
input: { focus: mock(() => {}) },
|
|
9
|
+
}),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
mock.module("./controllers/DashboardController.js", () => ({
|
|
13
|
+
DashboardController: class {
|
|
14
|
+
spawnSession = mock(() => {});
|
|
15
|
+
hasActivePicker = () => false;
|
|
16
|
+
constructor() {}
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
mock.module("./components/MultiLineInput.js", () => ({
|
|
21
|
+
MultiLineInputRenderable: class {
|
|
22
|
+
id = "";
|
|
23
|
+
focus = mock(() => {});
|
|
24
|
+
on = mock(() => {});
|
|
25
|
+
constructor(_1: never, opts: { id: string }) { this.id = opts.id; }
|
|
26
|
+
},
|
|
27
|
+
MultiLineInputEvents: {
|
|
28
|
+
SUBMIT: "submit",
|
|
29
|
+
INPUT: "input",
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { createDashboard } from "./dashboard.js";
|
|
34
|
+
|
|
35
|
+
describe("Dashboard", () => {
|
|
36
|
+
test("createDashboard should initialize without crashing", async () => {
|
|
37
|
+
const mockRenderer = createMockRenderer();
|
|
38
|
+
const dashboard = await createDashboard(mockRenderer as any);
|
|
39
|
+
expect(dashboard.root).toBeDefined();
|
|
40
|
+
expect(dashboard.sessionContainer).toBeDefined();
|
|
41
|
+
expect(dashboard.input).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
});
|