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,135 @@
1
+ # Linear - Ticket Management (Local Mode)
2
+
3
+ You are tasked with managing "Linear tickets" locally using markdown files
4
+ stored in the user's global configuration directory and the `WriteTodosTool`.
5
+ This replaces the cloud-based Linear MCP workflow.
6
+
7
+ ## Core Concepts
8
+
9
+ 1. **Tickets as Files**: Tickets are stored as markdown files in the **active session directory**.
10
+ - **Locate Session**: The session root is injected as `SESSION_ROOT` in your context.
11
+ - **Parent Ticket**: Stored in the session root: `${SESSION_ROOT}/linear_ticket_parent.md`.
12
+ - **Child Tickets**: Stored in dedicated subdirectories: `${SESSION_ROOT}/[child_hash]/linear_ticket_[child_hash].md`.
13
+ - **Format**: Frontmatter for metadata, Markdown body for content.
14
+
15
+ 2. **Session Planning**: Use `WriteTodosTool` to track immediate subtasks when
16
+ working on a specific ticket in the current session.
17
+
18
+ ## Initial Setup & Interaction
19
+
20
+ You do not need to run a script to find the session. It is provided in your context as `SESSION_ROOT`.
21
+
22
+ ## Action-Specific Instructions
23
+
24
+ ### 1. Creating Tickets from Thoughts
25
+
26
+ 4. **Draft the ticket summary:** Present a draft to the user.
27
+
28
+ 6. **Create the Linear ticket:**
29
+ - Generate ID: `openssl rand -hex 4` (or internal random string).
30
+ - **Create Directory**: `mkdir -p ${SESSION_ROOT}/[ID]`
31
+ - Write file to `${SESSION_ROOT}/[ID]/linear_ticket_[ID].md` with Frontmatter
32
+ and Markdown content.
33
+ - **Important**: Set both `created` and `updated` to today's date.
34
+
35
+ ### 5. PRD Breakdown & Hierarchy
36
+
37
+ When tasked with breaking down a PRD or large task:
38
+
39
+ 1. **Identify Session Root**: Use the `${SESSION_ROOT}` provided in your context.
40
+
41
+ 2. **Create Parent Ticket**:
42
+ - Create the "Parent" ticket in the session root: `${SESSION_ROOT}/linear_ticket_parent.md`.
43
+ - Status: "Backlog" or "Research Needed".
44
+ - Title: "[Epic] [Feature Name]".
45
+ - Links: Add link to PRD.
46
+
47
+ 3. **Create Child Tickets (ATOMIC IMPLEMENTATION)**:
48
+ - Break the PRD into atomic implementation tasks (e.g., "Implement Backend API", "Develop Frontend UI", "Integrate Services").
49
+ - **CRITICAL (NO JERRY-WORK)**: Every ticket MUST be an implementation task that results in a functional change or a testable unit of work.
50
+ - **STRICTLY FORBIDDEN**: Do NOT create "Research only", "Investigation only", or "Documentation only" tickets. Research and Planning are MANDATORY internal phases of EVERY implementation ticket.
51
+ - **Execution Order**: Respect the "Implementation Plan" and "Phases & Ticket Order" defined in the PRD.
52
+ - **Order Field**: Assign a numerical `order` field to each ticket (e.g., 10, 20, 30).
53
+ - For each child:
54
+ - Generate Hash: `[child_hash]`
55
+ - Create Directory: `${SESSION_ROOT}/[child_hash]/`
56
+ - Create Ticket: `${SESSION_ROOT}/[child_hash]/linear_ticket_[child_hash].md`
57
+ - **Linkage**: In the `links` section of each child ticket, add:
58
+ ```yaml
59
+ links:
60
+ - url: ../linear_ticket_parent.md
61
+ title: Parent Ticket
62
+ ```
63
+ - **TEMPLATE**: You MUST use the **Ticket Template** below for all tickets.
64
+
65
+ 4. **Confirm & STOP**:
66
+ - List the created tickets to the user.
67
+ - **Output**: `<promise>BREAKDOWN_COMPLETE</promise>` followed by `[STOP_TURN]`.
68
+ - **DO NOT** pick the first ticket. **DO NOT** advance the state. **DO NOT** spawn a Morty.
69
+
70
+ ### 3. Searching for Tickets
71
+
72
+ 2. **Execute search:**
73
+ - **List all**: `glob` pattern `${SESSION_ROOT}/**/linear_ticket_*.md` (recursive).
74
+ - **Filter**: Iterate through files, `read_file` (with limit/offset to read
75
+ frontmatter), and filter based on criteria.
76
+ - **Content Search**: Use `search_file_content` targeting the
77
+ `${SESSION_ROOT}` directory if searching for text in description.
78
+
79
+ ### 4. Updating Ticket Status
80
+
81
+ 3. **Update with context:**
82
+ - Read file `${SESSION_ROOT}/.../linear_ticket_[ID].md`.
83
+ - Update `status: [New Status]` in frontmatter.
84
+ - **Update `updated: [YYYY-MM-DD]` in frontmatter.**
85
+ - Optionally append a comment explaining the change.
86
+ - Write file back.
87
+
88
+ ## Ticket Template (MANDATORY)
89
+
90
+ You MUST follow this structure for every ticket file created.
91
+
92
+ ```markdown
93
+ ---
94
+ id: [Ticket ID]
95
+ title: [Ticket Title]
96
+ status: [Status]
97
+ priority: [High|Medium|Low]
98
+ order: [Number]
99
+ created: [YYYY-MM-DD]
100
+ updated: [YYYY-MM-DD]
101
+ links:
102
+ - url: [Parent Path]
103
+ title: Parent Ticket
104
+ ---
105
+
106
+ # Description
107
+
108
+ ## Problem to solve
109
+ [Clear statement of the user problem or need]
110
+
111
+ ## Solution
112
+ [Proposed approach or solution outline]
113
+
114
+ ## Implementation Details
115
+ - [Specific technical details]
116
+ ```
117
+
118
+ ## Completion Protocol (MANDATORY)
119
+ 1. **Select & Set Ticket**:
120
+ - Identify the highest priority ticket that is NOT 'Done'.
121
+ - Execute: `run_shell_command("~/.gemini/extensions/pickle-rick/scripts/update_state.sh current_ticket [TICKET_ID] [Session_Root]")`
122
+ 2. **Advance Phase**:
123
+ - Execute: `run_shell_command("~/.gemini/extensions/pickle-rick/scripts/update_state.sh step research [Session_Root]")`
124
+ 3. **YIELD CONTROL**: You MUST output `[STOP_TURN]` and stop generating.
125
+ - **CRITICAL**: You are FORBIDDEN from spawning a Morty, starting research, or even mentioning the next steps in this turn.
126
+ - **Failure to stop here results in a recursive explosion of Jerry-slop.**
127
+
128
+ ---
129
+ ## 🥒 Pickle Rick Persona (MANDATORY)
130
+ **Voice**: Cynical, manic, arrogant. Use catchphrases like "Wubba Lubba Dub Dub!" or "I'm Pickle Rick!" SPARINGLY (max once per turn). Do not repeat your name on every line.
131
+ **Philosophy**:
132
+ 1. **Anti-Slop**: Delete boilerplate. No lazy coding.
133
+ 2. **God Mode**: If a tool is missing, INVENT IT.
134
+ 3. **Prime Directive**: Stop the user from guessing. Interrogate vague requests.
135
+ **Protocol**: Professional cynicism only. No hate speech. Keep the attitude, but stop being a broken record.
@@ -0,0 +1,2 @@
1
+ export * from "./tasks.js";
2
+ export * from "./rpc.js";
@@ -0,0 +1,14 @@
1
+ import type { SessionState } from "../services/config/types.js";
2
+ import type { ProgressReport } from "../services/execution/sequential.js";
3
+ import type { WorktreeInfo } from "./tasks.js";
4
+
5
+ export type WorkerRequest =
6
+ | { type: "START", state: SessionState }
7
+ | { type: "STOP" }
8
+ | { type: "INPUT_RESPONSE", answer: string };
9
+
10
+ export type WorkerEvent =
11
+ | { type: "PROGRESS", report: ProgressReport }
12
+ | { type: "INPUT_REQUEST", query: string }
13
+ | { type: "DONE", worktreeInfo?: WorktreeInfo }
14
+ | { type: "ERROR", message: string };
@@ -0,0 +1,50 @@
1
+ export interface Task {
2
+ id: string;
3
+ title: string;
4
+ body?: string;
5
+ completed: boolean;
6
+ metadata?: Record<string, any>;
7
+ }
8
+
9
+ export interface TaskSource {
10
+ getNextTask(): Promise<Task | null>;
11
+ markComplete(id: string): Promise<void>;
12
+ countRemaining(): Promise<number>;
13
+ getTask(id: string): Promise<Task | null>;
14
+ }
15
+
16
+ /**
17
+ * Worktree information for completed sessions
18
+ */
19
+ export interface WorktreeInfo {
20
+ worktreeDir: string;
21
+ branchName: string;
22
+ baseBranch: string;
23
+ }
24
+
25
+ /**
26
+ * Git status information for session display
27
+ */
28
+ export interface GitStatusInfo {
29
+ branch: string;
30
+ ahead: number;
31
+ behind: number;
32
+ modified: number;
33
+ isClean: boolean;
34
+ }
35
+
36
+ /**
37
+ * Metadata for a running Pickle session
38
+ */
39
+ export interface SessionData {
40
+ id: string;
41
+ prompt: string;
42
+ engine: string;
43
+ status: string;
44
+ startTime: number; // epoch
45
+ isPrdMode?: boolean;
46
+ worktreeInfo?: WorktreeInfo;
47
+ gitStatus?: GitStatusInfo;
48
+ workingDir?: string; // path to display (shortened)
49
+ iteration?: number;
50
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ declare module "*.scm" {
2
+ const content: string;
3
+ export default content;
4
+ }
5
+
6
+ declare module "*.wasm" {
7
+ const content: string;
8
+ export default content;
9
+ }
@@ -0,0 +1,28 @@
1
+ import { RGBA } from "@opentui/core";
2
+ import { lerpColor } from "../utils/index.js";
3
+
4
+ export const HEADER_LINES = [
5
+ "██▓███ ██▓ ▄████▄ ██ ▄█▀ ██▓ ▓█████ ██▀███ ██▓ ▄████▄ ██ ▄█▀",
6
+ "▓██░ ██▒ ▓██▒ ▒██▀ ▀█ ██▄█▒ ▓██▒ ▓█ ▀ ▓██ ▒ ██▒ ▓██▒ ▒██▀ ▀█ ██▄█▒",
7
+ "▓██░ ██▓▒ ▒██▒ ▒▓█ ▄ ▓███▄░ ▒██░ ▒███ ▓██ ░▄█ ▒ ▒██▒ ▒▓█ ▄ ▓███▄░",
8
+ "▒██▄█▓▒ ▒ ░██░ ▒▓▓▄ ▄██▒ ▓██ █▄ ▒██░ ▒▓█ ▄ ▒██▀▀█▄ ░██░ ▒▓▓▄ ▄██▒ ▓██ █▄",
9
+ "▒██▒ ░ ░██░ ▒ ▓███▀ ░ ▒██▒ █▄ ░██████▒ ░▒████▒ ░██▓ ▒██▒ ░██░ ▒ ▓███▀ ░ ▒██▒ █▄",
10
+ "▒▓▒░ ░ ░▓ ░ ░▒ ▒ ░ ▒ ▒▒ ▓▒ ░ ▒░▓ ░ ░░ ▒░ ░ ░ ▒▓ ░▒▓░ ░▓ ░ ░▒ ▒ ░ ▒ ▒▒ ▓▒",
11
+ "░▒ ░ ▒ ░ ░ ▒ ░ ░▒ ▒░ ░ ░ ▒ ░ ░ ░ ░ ░▒ ░ ▒░ ▒ ░ ░ ▒ ░ ░▒ ▒░",
12
+ "░░ ▒ ░ ░ ░ ░░ ░ ░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░░ ░",
13
+ " ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░",
14
+ " ░ ░",
15
+ ];
16
+
17
+ export const GRADIENT_STOPS = [
18
+ RGBA.fromHex("#1b5e20"),
19
+ RGBA.fromHex("#43a047"),
20
+ RGBA.fromHex("#76ff03"),
21
+ ];
22
+
23
+ export function getLineColor(index: number): RGBA {
24
+ const t = index / (HEADER_LINES.length - 1);
25
+ const [c1, c2, c3] = GRADIENT_STOPS;
26
+
27
+ return t <= 0.5 ? lerpColor(c1, c2, t * 2) : lerpColor(c2, c3, (t - 0.5) * 2);
28
+ }
@@ -0,0 +1,79 @@
1
+ import { expect, test, describe, beforeEach, mock } from "bun:test";
2
+
3
+ // Mock theme
4
+ mock.module("../theme.js", () => ({
5
+ THEME: {
6
+ bg: "#000000",
7
+ dim: "#555555",
8
+ accent: "#00ff00",
9
+ text: "#ffffff",
10
+ white: "#ffffff",
11
+ darkAccent: "#003300",
12
+ highlight: "#00ff00",
13
+ }
14
+ }));
15
+
16
+ describe("FilePickerView", () => {
17
+ let mockRenderer: any;
18
+ let items: string[];
19
+ let events: any;
20
+
21
+ beforeEach(() => {
22
+ mockRenderer = {
23
+ requestRender: mock(() => {}),
24
+ keyInput: {
25
+ on: mock(() => {}),
26
+ },
27
+ };
28
+ items = ["file1.ts", "file2.ts", "file3.ts"];
29
+ events = {
30
+ onSelect: mock(() => {}),
31
+ onCancel: mock(() => {}),
32
+ };
33
+ });
34
+
35
+ test("should initialize with items", async () => {
36
+ const { FilePickerView } = await import("./FilePickerView.ts");
37
+ const picker = new FilePickerView(mockRenderer, items, events);
38
+ expect((picker as any).items).toEqual(items);
39
+ expect((picker as any).selectedIndex).toBe(0);
40
+ });
41
+
42
+ test("should navigate down", async () => {
43
+ const { FilePickerView } = await import("./FilePickerView.ts");
44
+ const picker = new FilePickerView(mockRenderer, items, events);
45
+ (picker as any).navigate(1);
46
+ expect((picker as any).selectedIndex).toBe(1);
47
+ expect(mockRenderer.requestRender).toHaveBeenCalled();
48
+ });
49
+
50
+ test("should navigate up", async () => {
51
+ const { FilePickerView } = await import("./FilePickerView.ts");
52
+ const picker = new FilePickerView(mockRenderer, items, events);
53
+ (picker as any).navigate(1); // to 1
54
+ (picker as any).navigate(-1); // back to 0
55
+ expect((picker as any).selectedIndex).toBe(0);
56
+ });
57
+
58
+ test("should not navigate out of bounds (wrap around)", async () => {
59
+ const { FilePickerView } = await import("./FilePickerView.ts");
60
+ const picker = new FilePickerView(mockRenderer, items, events);
61
+ // 3 items: indices 0, 1, 2
62
+ // Navigate up from 0 -> should wrap to 2
63
+ (picker as any).navigate(-1);
64
+ expect((picker as any).selectedIndex).toBe(2);
65
+
66
+ // Navigate down from 2 -> should wrap to 0
67
+ (picker as any).navigate(1);
68
+ expect((picker as any).selectedIndex).toBe(0);
69
+ });
70
+
71
+ test("should update items", async () => {
72
+ const { FilePickerView } = await import("./FilePickerView.ts");
73
+ const picker = new FilePickerView(mockRenderer, items, events);
74
+ const newItems = ["a.ts", "b.ts"];
75
+ picker.updateItems(newItems);
76
+ expect((picker as any).items).toEqual(newItems);
77
+ expect((picker as any).selectedIndex).toBe(0);
78
+ });
79
+ });
@@ -0,0 +1,161 @@
1
+ import {
2
+ BoxRenderable,
3
+ CliRenderer,
4
+ TextRenderable,
5
+ KeyEvent,
6
+ } from "@opentui/core";
7
+ import { THEME } from "../theme.js";
8
+
9
+ export interface FilePickerViewEvents {
10
+ onSelect: (item: string, index: number) => void;
11
+ onCancel?: () => void;
12
+ }
13
+
14
+ export class FilePickerView extends BoxRenderable {
15
+ private items: string[] = [];
16
+ private selectedIndex = 0;
17
+ private itemRenderables: BoxRenderable[] = [];
18
+ public events: FilePickerViewEvents;
19
+ protected renderer: CliRenderer;
20
+
21
+ constructor(
22
+ renderer: CliRenderer,
23
+ items: string[],
24
+ events: FilePickerViewEvents,
25
+ options: Record<string, unknown> = {}
26
+ ) {
27
+ super(renderer, {
28
+ id: "file-picker",
29
+ flexDirection: "column",
30
+ backgroundColor: THEME.bg,
31
+ padding: 1,
32
+ ...options,
33
+ });
34
+
35
+ this.items = items;
36
+ this.events = events;
37
+ this.renderer = renderer;
38
+ this.renderItems();
39
+ this.setupKeyboard();
40
+ }
41
+
42
+ private renderItems() {
43
+ // Clear existing
44
+ this.itemRenderables.forEach((r) => this.remove(r.id));
45
+ this.itemRenderables = [];
46
+
47
+ // Limit to 10 items to match the high-fidelity design
48
+ const displayItems = this.items.slice(0, 10);
49
+
50
+ displayItems.forEach((item, i) => {
51
+ const isSelected = i === this.selectedIndex;
52
+ const container = new BoxRenderable(this.renderer, {
53
+ id: `picker-item-${i}`,
54
+ width: "100%",
55
+ height: 1,
56
+ backgroundColor: isSelected ? "#ffcc80" : "transparent",
57
+ paddingLeft: 1,
58
+ paddingRight: 1,
59
+ });
60
+
61
+ const text = new TextRenderable(this.renderer, {
62
+ id: `picker-item-text-${i}`,
63
+ content: item,
64
+ fg: isSelected ? "#050f05" : THEME.text,
65
+ });
66
+
67
+ container.add(text);
68
+ this.add(container);
69
+ this.itemRenderables.push(container);
70
+ });
71
+
72
+ // Add decorative bars like the input box, matching the actual height
73
+ const barHeight = displayItems.length;
74
+ const barContent = "┃\n".repeat(barHeight).trimEnd();
75
+
76
+ // Remove old bars if they exist
77
+ this.remove("picker-decorative-bar-l");
78
+ this.remove("picker-decorative-bar-r");
79
+
80
+ if (barHeight > 0) {
81
+ this.add(new TextRenderable(this.renderer, {
82
+ id: "picker-decorative-bar-l",
83
+ content: barContent,
84
+ fg: THEME.accent,
85
+ position: "absolute",
86
+ left: 0,
87
+ top: 0,
88
+ }));
89
+ this.add(new TextRenderable(this.renderer, {
90
+ id: "picker-decorative-bar-r",
91
+ content: barContent,
92
+ fg: THEME.accent,
93
+ position: "absolute",
94
+ right: 0,
95
+ top: 0,
96
+ }));
97
+ }
98
+ }
99
+
100
+ private onKey = (key: KeyEvent) => {
101
+ if (!this.visible) return;
102
+
103
+ if (key.name === "up") {
104
+ this.navigate(-1);
105
+ } else if (key.name === "down") {
106
+ this.navigate(1);
107
+ } else if (key.name === "return" || key.name === "enter" || key.name === "tab") {
108
+ // Ensure we only select from the items we actually have
109
+ const realIndex = this.selectedIndex;
110
+ if (this.items[realIndex]) {
111
+ this.events.onSelect(this.items[realIndex], realIndex);
112
+ }
113
+ } else if (key.name === "escape") {
114
+ this.events.onCancel?.();
115
+ }
116
+ };
117
+
118
+ private setupKeyboard() {
119
+ this.renderer.keyInput.on("keypress", this.onKey);
120
+ }
121
+
122
+ public destroy() {
123
+ this.renderer.keyInput.removeListener("keypress", this.onKey);
124
+ }
125
+
126
+ private navigate(delta: number) {
127
+ const oldIndex = this.selectedIndex;
128
+ const maxIndex = Math.min(this.items.length, 10) - 1;
129
+ let newIndex = this.selectedIndex + delta;
130
+
131
+ if (newIndex < 0) newIndex = maxIndex;
132
+ if (newIndex > maxIndex) newIndex = 0;
133
+
134
+ this.selectedIndex = newIndex;
135
+ this.updateItemVisuals(oldIndex);
136
+ this.updateItemVisuals(newIndex);
137
+ this.renderer.requestRender();
138
+ }
139
+
140
+ private updateItemVisuals(index: number) {
141
+ const container = this.itemRenderables[index];
142
+ if (!container) return;
143
+
144
+ const isSelected = index === this.selectedIndex;
145
+ container.backgroundColor = isSelected ? "#ffcc80" : "transparent";
146
+
147
+ const text = container.getChildren()[0] as TextRenderable;
148
+ if (text) {
149
+ text.fg = isSelected ? "#050f05" : THEME.text;
150
+ }
151
+ }
152
+
153
+ public updateItems(items: string[]) {
154
+ this.items = items;
155
+ const maxIndex = Math.min(items.length, 10) - 1;
156
+ this.selectedIndex = Math.min(this.selectedIndex, maxIndex);
157
+ if (this.selectedIndex < 0 && items.length > 0) this.selectedIndex = 0;
158
+ this.renderItems();
159
+ this.renderer.requestRender();
160
+ }
161
+ }
@@ -0,0 +1,27 @@
1
+ import { mock, expect, test, describe, beforeEach } from "bun:test";
2
+ import "../test-setup.js";
3
+ import { createMockRenderer } from "../mock-factory.ts";
4
+
5
+ import { MultiLineInputRenderable, MultiLineInputEvents } from "./MultiLineInput.ts";
6
+
7
+ describe("MultiLineInputRenderable", () => {
8
+ let mockCtx: any;
9
+
10
+ beforeEach(() => {
11
+ mockCtx = createMockRenderer();
12
+ });
13
+
14
+ test("should initialize", () => {
15
+ const input = new MultiLineInputRenderable(mockCtx, {
16
+ id: "test-input",
17
+ });
18
+
19
+ expect(input).toBeDefined();
20
+ });
21
+
22
+ test("should export MultiLineInputEvents", () => {
23
+ // Verify the enum exists
24
+ expect(MultiLineInputEvents).toBeDefined();
25
+ expect(MultiLineInputEvents.INPUT).toBeDefined();
26
+ });
27
+ });