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,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
+ });