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,60 @@
1
+ import { mock, expect, test, describe, beforeEach } from "bun:test";
2
+ import { createMockRenderer, createMockSession, type MockRenderer } from "./test-utils.ts";
3
+ import type { CliRenderer } from "@opentui/core";
4
+
5
+ const mockDashboardDialog = {
6
+ update: mock(() => {}),
7
+ show: mock(() => {}),
8
+ hide: mock(() => {}),
9
+ isOpen: mock(() => false),
10
+ root: { visible: false, add: mock(() => {}) },
11
+ destroy: mock(() => {}),
12
+ };
13
+
14
+ mock.module("./DashboardDialog.js", () => ({
15
+ DashboardDialog: class {
16
+ constructor() { return mockDashboardDialog; }
17
+ }
18
+ }));
19
+
20
+ describe("DialogSidebar", () => {
21
+ let mockRenderer: MockRenderer;
22
+
23
+ beforeEach(() => {
24
+ mockRenderer = createMockRenderer();
25
+ mockDashboardDialog.isOpen.mockReturnValue(false);
26
+ // Clear mocks
27
+ mockDashboardDialog.update.mockClear();
28
+ mockDashboardDialog.show.mockClear();
29
+ mockDashboardDialog.hide.mockClear();
30
+ });
31
+
32
+ test("should delegate to DashboardDialog", async () => {
33
+ const { DialogSidebar } = await import("./DialogSidebar.ts");
34
+ const ds = new DialogSidebar(mockRenderer as unknown as CliRenderer);
35
+
36
+ const mockSession = createMockSession({ id: "test" });
37
+ ds.update(mockSession);
38
+
39
+ expect(mockDashboardDialog.update).toHaveBeenCalledWith(mockSession);
40
+ });
41
+
42
+ test("should handle show/hide", async () => {
43
+ const { DialogSidebar } = await import("./DialogSidebar.ts");
44
+ const ds = new DialogSidebar(mockRenderer as unknown as CliRenderer);
45
+
46
+ ds.show();
47
+ expect(mockDashboardDialog.show).toHaveBeenCalled();
48
+
49
+ ds.hide();
50
+ expect(mockDashboardDialog.hide).toHaveBeenCalled();
51
+ });
52
+
53
+ test("should check isOpen", async () => {
54
+ const { DialogSidebar } = await import("./DialogSidebar.ts");
55
+ const ds = new DialogSidebar(mockRenderer as unknown as CliRenderer);
56
+
57
+ mockDashboardDialog.isOpen.mockReturnValue(true);
58
+ expect(ds.isOpen()).toBe(true);
59
+ });
60
+ });
@@ -0,0 +1,71 @@
1
+ import { DashboardDialog } from "./DashboardDialog.js";
2
+ import { SessionData } from "../../types/tasks.js";
3
+ import { CliRenderer } from "@opentui/core";
4
+
5
+ export class DialogSidebar {
6
+ private dashboardDialog: DashboardDialog;
7
+ private useDialog = true;
8
+
9
+ constructor(renderer: CliRenderer) {
10
+ this.dashboardDialog = new DashboardDialog(renderer);
11
+
12
+ // Add the dialog to the renderer root
13
+ renderer.root.add(this.dashboardDialog.root);
14
+ }
15
+
16
+ public setUseDialog(use: boolean) {
17
+ this.useDialog = use;
18
+ }
19
+
20
+ public update(session: SessionData, silent: boolean = false) {
21
+ this.dashboardDialog.update(session);
22
+ if (!silent && !this.dashboardDialog.isOpen()) {
23
+ this.dashboardDialog.show();
24
+ }
25
+ }
26
+
27
+ public show() {
28
+ this.dashboardDialog.show();
29
+ }
30
+
31
+ public hide() {
32
+ this.dashboardDialog.hide();
33
+ }
34
+
35
+ public isOpen(): boolean {
36
+ return this.dashboardDialog.isOpen();
37
+ }
38
+
39
+ public showInput(placeholder?: string) {
40
+ // Dialog doesn't support input
41
+ }
42
+
43
+ public hideInput() {
44
+ }
45
+
46
+ public focusInput() {
47
+ }
48
+
49
+ public get onHide() {
50
+ return undefined;
51
+ }
52
+
53
+ public set onHide(callback: (() => void) | undefined) {
54
+ }
55
+
56
+ public get input() {
57
+ return undefined;
58
+ }
59
+
60
+ public get root() {
61
+ return this.dashboardDialog.root;
62
+ }
63
+
64
+ public get dialogComponent() {
65
+ return this.dashboardDialog;
66
+ }
67
+
68
+ public destroy() {
69
+ this.dashboardDialog.destroy();
70
+ }
71
+ }
@@ -0,0 +1,57 @@
1
+ import { mock, expect, test, describe, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { createMockRenderer } from "../mock-factory.ts";
3
+ import * as git from "../../services/git/index.js";
4
+ import { DiffViewDialog } from "./DiffViewDialog.ts";
5
+
6
+ describe("DiffViewDialog", () => {
7
+ let mockRenderer: any;
8
+ let events: any;
9
+ let spies: any[] = [];
10
+
11
+ beforeEach(() => {
12
+ mockRenderer = createMockRenderer();
13
+ events = {
14
+ onMerge: mock(async () => {}),
15
+ onCreatePR: mock(async () => {}),
16
+ onReject: mock(async () => {}),
17
+ onClose: mock(() => {}),
18
+ };
19
+
20
+ // Use spyOn instead of global mock.module to avoid polluting other tests
21
+ spies = [
22
+ spyOn(git, "getChangedFiles").mockImplementation(async () => [
23
+ { path: "file1.ts", status: "modified", additions: 10, deletions: 5 },
24
+ ]),
25
+ spyOn(git, "getFileDiff").mockResolvedValue("diff"),
26
+ spyOn(git, "getStatusIndicator").mockReturnValue("M"),
27
+ spyOn(git, "getStatusColor").mockReturnValue("#ffffff"),
28
+ spyOn(git, "getFileType").mockReturnValue("typescript"),
29
+ ];
30
+ });
31
+
32
+ afterEach(() => {
33
+ spies.forEach(spy => spy.mockRestore());
34
+ });
35
+
36
+ test("should initialize and setup UI", () => {
37
+ const dialog = new DiffViewDialog(mockRenderer, events);
38
+ expect(dialog).toBeDefined();
39
+ expect(dialog.isOpen()).toBe(false);
40
+ });
41
+
42
+ test("should show and load changed files", async () => {
43
+ const dialog = new DiffViewDialog(mockRenderer, events);
44
+
45
+ const mockSession = {
46
+ worktreeInfo: {
47
+ worktreeDir: "/tmp/wt",
48
+ branchName: "feature",
49
+ baseBranch: "main",
50
+ }
51
+ };
52
+
53
+ await dialog.show(mockSession as any);
54
+ expect(dialog.isOpen()).toBe(true);
55
+ expect(git.getChangedFiles).toHaveBeenCalled();
56
+ });
57
+ });
@@ -0,0 +1,510 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ TextAttributes,
5
+ ScrollBoxRenderable,
6
+ CliRenderer,
7
+ KeyEvent,
8
+ DiffRenderable,
9
+ SelectRenderable,
10
+ SelectRenderableEvents,
11
+ type SelectOption,
12
+ parseColor,
13
+ SyntaxStyle,
14
+ } from "@opentui/core";
15
+ import { THEME } from "../theme.js";
16
+ import type { SessionData, WorktreeInfo } from "../../types/tasks.js";
17
+ import {
18
+ getChangedFiles,
19
+ getFileDiff,
20
+ getFileType,
21
+ getStatusIndicator,
22
+ getStatusColor,
23
+ type ChangedFile,
24
+ } from "../../services/git/index.js";
25
+
26
+ export interface DiffViewDialogEvents {
27
+ onMerge: (session: SessionData) => Promise<void>;
28
+ onCreatePR: (session: SessionData) => Promise<void>;
29
+ onReject: (session: SessionData) => Promise<void>;
30
+ onClose: () => void;
31
+ }
32
+
33
+ export class DiffViewDialog {
34
+ public root: BoxRenderable;
35
+ private renderer: CliRenderer;
36
+ private isVisible = false;
37
+ private session: SessionData | null = null;
38
+ private events: DiffViewDialogEvents;
39
+ private keyHandler: ((key: KeyEvent) => void) | null = null;
40
+
41
+ // UI Elements
42
+ private overlay: BoxRenderable;
43
+ private mainPanel: BoxRenderable;
44
+ private headerBar: BoxRenderable;
45
+ private titleText: TextRenderable;
46
+ private leftPanel: BoxRenderable;
47
+ private rightPanel: BoxRenderable;
48
+ private footerBar: BoxRenderable;
49
+ private fileSelect: SelectRenderable;
50
+ private diffContainer: ScrollBoxRenderable;
51
+ private diffView: DiffRenderable | null = null;
52
+ private noDiffText: TextRenderable;
53
+ private syntaxStyle: SyntaxStyle;
54
+
55
+ // State
56
+ private changedFiles: ChangedFile[] = [];
57
+ private currentFileIndex = 0;
58
+ private viewMode: "unified" | "split" = "unified";
59
+ private wrapMode: "none" | "word" = "none";
60
+
61
+ constructor(renderer: CliRenderer, events: DiffViewDialogEvents) {
62
+ this.renderer = renderer;
63
+ this.events = events;
64
+
65
+ // Create syntax style for diff view
66
+ this.syntaxStyle = SyntaxStyle.fromStyles({
67
+ keyword: { fg: parseColor("#FF7B72"), bold: true },
68
+ "keyword.import": { fg: parseColor("#FF7B72"), bold: true },
69
+ string: { fg: parseColor("#A5D6FF") },
70
+ comment: { fg: parseColor("#8B949E"), italic: true },
71
+ number: { fg: parseColor("#79C0FF") },
72
+ boolean: { fg: parseColor("#79C0FF") },
73
+ constant: { fg: parseColor("#79C0FF") },
74
+ function: { fg: parseColor("#D2A8FF") },
75
+ "function.call": { fg: parseColor("#D2A8FF") },
76
+ constructor: { fg: parseColor("#FFA657") },
77
+ type: { fg: parseColor("#FFA657") },
78
+ operator: { fg: parseColor("#FF7B72") },
79
+ variable: { fg: parseColor("#E6EDF3") },
80
+ property: { fg: parseColor("#79C0FF") },
81
+ bracket: { fg: parseColor("#F0F6FC") },
82
+ punctuation: { fg: parseColor("#F0F6FC") },
83
+ default: { fg: parseColor("#E6EDF3") },
84
+ });
85
+
86
+ // Background overlay
87
+ this.overlay = new BoxRenderable(renderer, {
88
+ id: "diff-view-overlay",
89
+ width: "100%",
90
+ height: "100%",
91
+ position: "absolute",
92
+ left: 0,
93
+ top: 0,
94
+ backgroundColor: THEME.bg,
95
+ visible: false,
96
+ zIndex: 30000,
97
+ flexDirection: "column",
98
+ });
99
+
100
+ // Main panel
101
+ this.mainPanel = new BoxRenderable(renderer, {
102
+ id: "diff-view-main",
103
+ width: "100%",
104
+ height: "100%",
105
+ flexDirection: "column",
106
+ });
107
+ this.overlay.add(this.mainPanel);
108
+
109
+ // Header bar
110
+ this.headerBar = new BoxRenderable(renderer, {
111
+ id: "diff-view-header",
112
+ width: "100%",
113
+ height: 3,
114
+ flexDirection: "row",
115
+ alignItems: "center",
116
+ paddingLeft: 2,
117
+ paddingRight: 2,
118
+ backgroundColor: THEME.surface,
119
+ borderColor: THEME.accent,
120
+ border: ["bottom"],
121
+ flexShrink: 0,
122
+ });
123
+ this.mainPanel.add(this.headerBar);
124
+
125
+ this.titleText = new TextRenderable(renderer, {
126
+ id: "diff-view-title",
127
+ content: "Review Changes",
128
+ fg: THEME.accent,
129
+ attributes: TextAttributes.BOLD,
130
+ });
131
+ this.headerBar.add(this.titleText);
132
+
133
+ // Content area (file picker + diff view)
134
+ const contentArea = new BoxRenderable(renderer, {
135
+ id: "diff-view-content",
136
+ width: "100%",
137
+ flexGrow: 1,
138
+ flexDirection: "row",
139
+ });
140
+ this.mainPanel.add(contentArea);
141
+
142
+ // Left panel - File picker (30% width)
143
+ this.leftPanel = new BoxRenderable(renderer, {
144
+ id: "diff-view-left",
145
+ width: "30%",
146
+ height: "100%",
147
+ flexDirection: "column",
148
+ backgroundColor: THEME.surface,
149
+ borderColor: THEME.darkAccent,
150
+ border: ["right"],
151
+ padding: 1,
152
+ });
153
+ contentArea.add(this.leftPanel);
154
+
155
+ const fileListHeader = new TextRenderable(renderer, {
156
+ id: "diff-view-file-header",
157
+ content: "Changed Files",
158
+ fg: THEME.white,
159
+ attributes: TextAttributes.BOLD,
160
+ marginBottom: 1,
161
+ });
162
+ this.leftPanel.add(fileListHeader);
163
+
164
+ this.fileSelect = new SelectRenderable(renderer, {
165
+ id: "diff-view-file-select",
166
+ width: "100%",
167
+ flexGrow: 1,
168
+ options: [],
169
+ backgroundColor: THEME.surface,
170
+ textColor: THEME.text,
171
+ selectedBackgroundColor: THEME.darkAccent,
172
+ selectedTextColor: THEME.accent,
173
+ focusedBackgroundColor: THEME.surface,
174
+ focusedTextColor: THEME.text,
175
+ descriptionColor: THEME.dim,
176
+ selectedDescriptionColor: THEME.dim,
177
+ showScrollIndicator: true,
178
+ wrapSelection: true,
179
+ showDescription: true,
180
+ });
181
+ this.leftPanel.add(this.fileSelect);
182
+
183
+ // Right panel - Diff view (70% width)
184
+ this.rightPanel = new BoxRenderable(renderer, {
185
+ id: "diff-view-right",
186
+ width: "70%",
187
+ height: "100%",
188
+ flexDirection: "column",
189
+ backgroundColor: THEME.bg,
190
+ padding: 1,
191
+ });
192
+ contentArea.add(this.rightPanel);
193
+
194
+ this.diffContainer = new ScrollBoxRenderable(renderer, {
195
+ id: "diff-view-scroll",
196
+ width: "100%",
197
+ flexGrow: 1,
198
+ scrollY: true,
199
+ scrollX: true,
200
+ backgroundColor: THEME.bg,
201
+ });
202
+ this.rightPanel.add(this.diffContainer);
203
+
204
+ this.noDiffText = new TextRenderable(renderer, {
205
+ id: "diff-view-no-diff",
206
+ content: "Select a file to view diff",
207
+ fg: THEME.dim,
208
+ });
209
+ this.diffContainer.add(this.noDiffText);
210
+
211
+ // Footer bar
212
+ this.footerBar = new BoxRenderable(renderer, {
213
+ id: "diff-view-footer",
214
+ width: "100%",
215
+ height: 3,
216
+ flexDirection: "row",
217
+ alignItems: "center",
218
+ justifyContent: "space-between",
219
+ paddingLeft: 2,
220
+ paddingRight: 2,
221
+ backgroundColor: THEME.surface,
222
+ borderColor: THEME.darkAccent,
223
+ border: ["top"],
224
+ flexShrink: 0,
225
+ });
226
+ this.mainPanel.add(this.footerBar);
227
+
228
+ const footerLeft = new TextRenderable(renderer, {
229
+ id: "diff-view-footer-left",
230
+ content: "[M] Merge | [P] Create PR | [R] Reject | [ESC] Cancel",
231
+ fg: THEME.dim,
232
+ });
233
+ this.footerBar.add(footerLeft);
234
+
235
+ const footerRight = new TextRenderable(renderer, {
236
+ id: "diff-view-footer-right",
237
+ content: "[V] View Mode | [W] Wrap | [j/k] Navigate",
238
+ fg: THEME.dim,
239
+ });
240
+ this.footerBar.add(footerRight);
241
+
242
+ this.root = this.overlay;
243
+
244
+ // Setup file selection event
245
+ this.fileSelect.on(
246
+ SelectRenderableEvents.SELECTION_CHANGED,
247
+ (index: number) => {
248
+ this.currentFileIndex = index;
249
+ this.loadFileDiff();
250
+ }
251
+ );
252
+
253
+ this.fileSelect.on(
254
+ SelectRenderableEvents.ITEM_SELECTED,
255
+ (index: number) => {
256
+ this.currentFileIndex = index;
257
+ this.loadFileDiff();
258
+ }
259
+ );
260
+ }
261
+
262
+ private setupKeyboard() {
263
+ if (this.keyHandler) return;
264
+
265
+ this.keyHandler = (key: KeyEvent) => {
266
+ if (!this.isVisible) return;
267
+
268
+ // File navigation
269
+ if (key.name === "up" || key.name === "k") {
270
+ this.fileSelect.moveUp();
271
+ } else if (key.name === "down" || key.name === "j") {
272
+ this.fileSelect.moveDown();
273
+ } else if (key.name === "return" || key.name === "enter") {
274
+ this.fileSelect.selectCurrent();
275
+ }
276
+ // View mode toggle
277
+ else if (key.name === "v" && !key.ctrl && !key.meta) {
278
+ this.viewMode = this.viewMode === "unified" ? "split" : "unified";
279
+ if (this.diffView) {
280
+ this.diffView.view = this.viewMode;
281
+ }
282
+ this.renderer.requestRender();
283
+ }
284
+ // Wrap mode toggle
285
+ else if (key.name === "w" && !key.ctrl && !key.meta) {
286
+ this.wrapMode = this.wrapMode === "none" ? "word" : "none";
287
+ if (this.diffView) {
288
+ this.diffView.wrapMode = this.wrapMode;
289
+ }
290
+ this.renderer.requestRender();
291
+ }
292
+ // Merge action
293
+ else if (key.name === "m" && !key.ctrl && !key.meta) {
294
+ if (this.session) {
295
+ this.events.onMerge(this.session);
296
+ }
297
+ }
298
+ // Reject / cleanup worktree
299
+ else if (key.name === "r" && !key.ctrl && !key.meta) {
300
+ if (this.session) {
301
+ this.events.onReject(this.session);
302
+ }
303
+ }
304
+ // Create PR action
305
+ else if (key.name === "p" && !key.ctrl && !key.meta) {
306
+ if (this.session) {
307
+ this.events.onCreatePR(this.session);
308
+ }
309
+ }
310
+ // Close
311
+ else if (key.name === "escape") {
312
+ this.hide();
313
+ this.events.onClose();
314
+ }
315
+ };
316
+
317
+ this.renderer.keyInput.on("keypress", this.keyHandler);
318
+ }
319
+
320
+ private cleanupKeyboard() {
321
+ if (this.keyHandler) {
322
+ this.renderer.keyInput.off("keypress", this.keyHandler);
323
+ this.keyHandler = null;
324
+ }
325
+ }
326
+
327
+ private async loadChangedFiles() {
328
+ if (!this.session?.worktreeInfo) return;
329
+
330
+ const { worktreeDir, baseBranch } = this.session.worktreeInfo;
331
+
332
+ const disallowedExtensions = new Set([
333
+ "png",
334
+ "jpg",
335
+ "jpeg",
336
+ "gif",
337
+ "webp",
338
+ "bmp",
339
+ "tiff",
340
+ "ico",
341
+ "svg",
342
+ "pdf",
343
+ "zip",
344
+ "tar",
345
+ "gz",
346
+ "tgz",
347
+ "bz2",
348
+ "xz",
349
+ "7z",
350
+ "mov",
351
+ "mp4",
352
+ "mkv",
353
+ "avi",
354
+ "mp3",
355
+ "wav",
356
+ "ogg",
357
+ "flac",
358
+ "woff",
359
+ "woff2",
360
+ "ttf",
361
+ "otf",
362
+ "psd",
363
+ "ai",
364
+ ]);
365
+
366
+ const isRenderableFile = (path: string): boolean => {
367
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
368
+ return !disallowedExtensions.has(ext);
369
+ };
370
+
371
+ try {
372
+ const allFiles = await getChangedFiles(worktreeDir, baseBranch);
373
+ this.changedFiles = allFiles.filter((file) => isRenderableFile(file.path));
374
+
375
+ const options: SelectOption[] = this.changedFiles.map((file) => {
376
+ const indicator = getStatusIndicator(file.status);
377
+ const stats =
378
+ file.additions > 0 || file.deletions > 0
379
+ ? `+${file.additions} -${file.deletions}`
380
+ : "";
381
+ return {
382
+ name: `${indicator} ${file.path}`,
383
+ description: stats,
384
+ value: file.path,
385
+ };
386
+ });
387
+
388
+ this.fileSelect.options = options;
389
+
390
+ if (this.changedFiles.length > 0) {
391
+ this.currentFileIndex = 0;
392
+ this.fileSelect.setSelectedIndex(0);
393
+ await this.loadFileDiff();
394
+ } else {
395
+ this.noDiffText.content = "No renderable text changes (images/binaries hidden)";
396
+ this.noDiffText.visible = true;
397
+ this.diffView = null;
398
+ }
399
+ } catch (error) {
400
+ console.error("Failed to load changed files:", error);
401
+ }
402
+ }
403
+
404
+ private async loadFileDiff() {
405
+ if (!this.session?.worktreeInfo || this.changedFiles.length === 0) return;
406
+
407
+ const { worktreeDir, baseBranch } = this.session.worktreeInfo;
408
+ const file = this.changedFiles[this.currentFileIndex];
409
+
410
+ if (!file) return;
411
+
412
+ try {
413
+ const diff = await getFileDiff(worktreeDir, baseBranch, file.path, file.status);
414
+ const filetype = getFileType(file.path);
415
+ const syntaxStyle = this.syntaxStyle ?? SyntaxStyle.fromStyles({});
416
+
417
+ // Remove the "no diff" text
418
+ this.noDiffText.visible = false;
419
+
420
+ // Create or update diff view
421
+ if (!this.diffView) {
422
+ this.diffView = new DiffRenderable(this.renderer, {
423
+ id: "diff-view-renderable",
424
+ diff,
425
+ view: this.viewMode,
426
+ filetype,
427
+ syntaxStyle,
428
+ showLineNumbers: true,
429
+ wrapMode: this.wrapMode,
430
+ addedBg: "#1a4d1a",
431
+ removedBg: "#4d1a1a",
432
+ contextBg: "transparent",
433
+ addedSignColor: "#22c55e",
434
+ removedSignColor: "#ef4444",
435
+ lineNumberFg: "#6b7280",
436
+ lineNumberBg: "#161b22",
437
+ addedLineNumberBg: "#0d3a0d",
438
+ removedLineNumberBg: "#3a0d0d",
439
+ width: "100%",
440
+ flexGrow: 1,
441
+ });
442
+ this.diffContainer.add(this.diffView);
443
+ } else {
444
+ this.diffView.diff = diff;
445
+ this.diffView.filetype = filetype;
446
+ }
447
+
448
+ this.renderer.requestRender();
449
+ } catch (error) {
450
+ console.error("Failed to load file diff:", error);
451
+ }
452
+ }
453
+
454
+ public async show(session: SessionData) {
455
+ if (this.isVisible) return;
456
+ if (!session.worktreeInfo) return;
457
+
458
+ this.session = session;
459
+ this.isVisible = true;
460
+ this.overlay.visible = true;
461
+
462
+ // Update title
463
+ const { branchName, baseBranch } = session.worktreeInfo;
464
+ this.titleText.content = `Review Changes: ${branchName} -> ${baseBranch}`;
465
+
466
+ // Load changed files
467
+ await this.loadChangedFiles();
468
+
469
+ // Setup keyboard
470
+ this.setupKeyboard();
471
+
472
+ this.renderer.requestRender();
473
+ }
474
+
475
+ public hide() {
476
+ if (!this.isVisible) return;
477
+
478
+ this.isVisible = false;
479
+ this.overlay.visible = false;
480
+ this.cleanupKeyboard();
481
+
482
+ // Reset state
483
+ this.changedFiles = [];
484
+ this.currentFileIndex = 0;
485
+ if (this.diffView) {
486
+ this.diffContainer.remove(this.diffView.id);
487
+ this.diffView = null;
488
+ }
489
+ this.noDiffText.visible = true;
490
+ this.fileSelect.options = [];
491
+
492
+ this.renderer.requestRender();
493
+ }
494
+
495
+ public isOpen(): boolean {
496
+ return this.isVisible;
497
+ }
498
+
499
+ public getSession(): SessionData | null {
500
+ return this.session;
501
+ }
502
+
503
+ public destroy() {
504
+ this.cleanupKeyboard();
505
+ if (this.diffView) {
506
+ this.diffView.destroy();
507
+ }
508
+ this.syntaxStyle.destroy();
509
+ }
510
+ }