letmecook 0.0.1 → 0.0.4

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.
@@ -0,0 +1,155 @@
1
+ import {
2
+ type CliRenderer,
3
+ TextRenderable,
4
+ SelectRenderable,
5
+ SelectRenderableEvents,
6
+ type KeyEvent,
7
+ } from "@opentui/core";
8
+ import { createBaseLayout, clearLayout } from "./renderer";
9
+ import { buildSessionOptions } from "./session-options";
10
+ import type { Session } from "../types";
11
+
12
+ export type MainMenuAction =
13
+ | { type: "new-session" }
14
+ | { type: "resume"; session: Session }
15
+ | { type: "delete"; session: Session }
16
+ | { type: "quit" };
17
+
18
+ export function showMainMenu(renderer: CliRenderer, sessions: Session[]): Promise<MainMenuAction> {
19
+ return new Promise((resolve) => {
20
+ clearLayout(renderer);
21
+
22
+ const { content } = createBaseLayout(renderer, "letmecook");
23
+
24
+ // Welcome text
25
+ const welcome = new TextRenderable(renderer, {
26
+ id: "welcome",
27
+ content: "Multi-repo workspace manager for AI coding sessions",
28
+ fg: "#94a3b8",
29
+ marginBottom: 2,
30
+ });
31
+ content.add(welcome);
32
+
33
+ // Sessions section
34
+ const sessionsHeader = new TextRenderable(renderer, {
35
+ id: "sessions-header",
36
+ content: "Sessions",
37
+ fg: "#e2e8f0",
38
+ marginTop: 1,
39
+ marginBottom: 1,
40
+ });
41
+ content.add(sessionsHeader);
42
+
43
+ const options = buildSessionOptions(sessions);
44
+ const select =
45
+ options.length > 0
46
+ ? new SelectRenderable(renderer, {
47
+ id: "session-list",
48
+ width: 65,
49
+ height: Math.min(sessions.length * 2, 10),
50
+ options,
51
+ showDescription: true,
52
+ backgroundColor: "transparent",
53
+ focusedBackgroundColor: "transparent",
54
+ selectedBackgroundColor: "#334155",
55
+ textColor: "#e2e8f0",
56
+ selectedTextColor: "#38bdf8",
57
+ descriptionColor: "#64748b",
58
+ selectedDescriptionColor: "#94a3b8",
59
+ })
60
+ : null;
61
+
62
+ if (select) {
63
+ content.add(select);
64
+ } else {
65
+ const emptyText = new TextRenderable(renderer, {
66
+ id: "empty-sessions",
67
+ content: "No sessions yet. Start one with [n].",
68
+ fg: "#94a3b8",
69
+ marginBottom: 1,
70
+ });
71
+ content.add(emptyText);
72
+ }
73
+
74
+ // Actions section
75
+ const actionsHeader = new TextRenderable(renderer, {
76
+ id: "actions-header",
77
+ content: "Actions",
78
+ fg: "#e2e8f0",
79
+ marginTop: 1,
80
+ marginBottom: 1,
81
+ });
82
+ content.add(actionsHeader);
83
+
84
+ const actionsText = new TextRenderable(renderer, {
85
+ id: "actions",
86
+ content:
87
+ sessions.length > 0
88
+ ? "[n] New session\n[d] Delete session\n[Esc] Quit"
89
+ : "[n] New session\n[Esc] Quit",
90
+ fg: "#94a3b8",
91
+ });
92
+ content.add(actionsText);
93
+
94
+ if (sessions.length > 0) {
95
+ const instructions = new TextRenderable(renderer, {
96
+ id: "instructions",
97
+ content: "\n[Enter] Resume session",
98
+ fg: "#64748b",
99
+ marginTop: 1,
100
+ });
101
+ content.add(instructions);
102
+ }
103
+
104
+ if (select) {
105
+ select.focus();
106
+ }
107
+
108
+ let selectedIndex = 0;
109
+
110
+ const handleSelect = (_index: number, option: { value: Session }) => {
111
+ cleanup();
112
+ resolve({ type: "resume", session: option.value });
113
+ };
114
+
115
+ const handleKeypress = (key: KeyEvent) => {
116
+ if (sessions.length > 0 && (key.name === "up" || key.name === "k")) {
117
+ selectedIndex = Math.max(0, selectedIndex - 1);
118
+ } else if (sessions.length > 0 && (key.name === "down" || key.name === "j")) {
119
+ selectedIndex = Math.min(sessions.length - 1, selectedIndex + 1);
120
+ } else if (key.name === "d" && sessions.length > 0) {
121
+ const session = sessions[selectedIndex];
122
+ if (session) {
123
+ cleanup();
124
+ resolve({ type: "delete", session });
125
+ }
126
+ return;
127
+ }
128
+
129
+ if (key.name === "n") {
130
+ cleanup();
131
+ resolve({ type: "new-session" });
132
+ return;
133
+ }
134
+
135
+ if (key.name === "escape") {
136
+ cleanup();
137
+ resolve({ type: "quit" });
138
+ }
139
+ };
140
+
141
+ const cleanup = () => {
142
+ if (select) {
143
+ select.off(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
144
+ select.blur();
145
+ }
146
+ renderer.keyInput.off("keypress", handleKeypress);
147
+ clearLayout(renderer);
148
+ };
149
+
150
+ if (select) {
151
+ select.on(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
152
+ }
153
+ renderer.keyInput.on("keypress", handleKeypress);
154
+ });
155
+ }
@@ -0,0 +1,99 @@
1
+ import { type CliRenderer, TextRenderable, InputRenderable, type KeyEvent } from "@opentui/core";
2
+ import { createBaseLayout, clearLayout } from "./renderer";
3
+ import type { RepoSpec } from "../types";
4
+
5
+ export interface NewSessionResult {
6
+ goal?: string;
7
+ cancelled: boolean;
8
+ }
9
+
10
+ export function showNewSessionPrompt(
11
+ renderer: CliRenderer,
12
+ repos: RepoSpec[],
13
+ ): Promise<NewSessionResult> {
14
+ return new Promise((resolve) => {
15
+ clearLayout(renderer);
16
+
17
+ const { content } = createBaseLayout(renderer, "Creating new session");
18
+
19
+ // Show repos
20
+ const reposLabel = new TextRenderable(renderer, {
21
+ id: "repos-label",
22
+ content: "Repositories:",
23
+ fg: "#e2e8f0",
24
+ marginBottom: 0,
25
+ });
26
+ content.add(reposLabel);
27
+
28
+ repos.forEach((repo, i) => {
29
+ const branch = repo.branch ? ` (${repo.branch})` : " (default)";
30
+ const roMarker = repo.readOnly ? " [RO]" : "";
31
+ const latestMarker = repo.latest ? " [Latest]" : "";
32
+ const repoText = new TextRenderable(renderer, {
33
+ id: `repo-${i}`,
34
+ content: ` - ${repo.owner}/${repo.name}${branch}${roMarker}${latestMarker}`,
35
+ fg: "#94a3b8",
36
+ });
37
+ content.add(repoText);
38
+ });
39
+
40
+ // Goal prompt
41
+ const goalLabel = new TextRenderable(renderer, {
42
+ id: "goal-label",
43
+ content: "\nAnything you'd like to add? (goal/context for AI agents)",
44
+ fg: "#e2e8f0",
45
+ marginTop: 1,
46
+ });
47
+ content.add(goalLabel);
48
+
49
+ const goalInput = new InputRenderable(renderer, {
50
+ id: "goal-input",
51
+ width: 60,
52
+ height: 1,
53
+ placeholder: "e.g., Integrate testing framework...",
54
+ placeholderColor: "#64748b",
55
+ backgroundColor: "#334155",
56
+ textColor: "#f8fafc",
57
+ cursorColor: "#38bdf8",
58
+ marginTop: 1,
59
+ });
60
+ content.add(goalInput);
61
+
62
+ goalInput.onPaste = (event) => {
63
+ const text = event.text.replace(/[\r\n]+/g, "");
64
+ if (!text) return;
65
+ goalInput.insertText(text);
66
+ event.preventDefault();
67
+ };
68
+
69
+ // Instructions
70
+ const instructions = new TextRenderable(renderer, {
71
+ id: "instructions",
72
+ content: "\n[Enter] Continue [Esc] Cancel",
73
+ fg: "#64748b",
74
+ marginTop: 1,
75
+ });
76
+ content.add(instructions);
77
+
78
+ goalInput.focus();
79
+
80
+ const handleKeypress = (key: KeyEvent) => {
81
+ if (key.name === "escape") {
82
+ cleanup();
83
+ resolve({ cancelled: true });
84
+ } else if (key.name === "return" || key.name === "enter") {
85
+ cleanup();
86
+ const goal = goalInput.value.trim() || undefined;
87
+ resolve({ goal, cancelled: false });
88
+ }
89
+ };
90
+
91
+ const cleanup = () => {
92
+ renderer.keyInput.off("keypress", handleKeypress);
93
+ goalInput.blur();
94
+ clearLayout(renderer);
95
+ };
96
+
97
+ renderer.keyInput.on("keypress", handleKeypress);
98
+ });
99
+ }
@@ -0,0 +1,191 @@
1
+ import { type CliRenderer, TextRenderable } from "@opentui/core";
2
+ import { createBaseLayout, clearLayout } from "./renderer";
3
+ import type { RepoSpec } from "../types";
4
+
5
+ export type ProgressPhase =
6
+ | "naming"
7
+ | "proposal"
8
+ | "cloning"
9
+ | "installing-skills"
10
+ | "refreshing"
11
+ | "done";
12
+
13
+ export type ProgressRepoStatus =
14
+ | "pending"
15
+ | "cloning"
16
+ | "refreshing"
17
+ | "updated"
18
+ | "up-to-date"
19
+ | "skipped"
20
+ | "done"
21
+ | "error";
22
+
23
+ export interface ProgressOptions {
24
+ title?: string;
25
+ label?: string;
26
+ initialPhase?: ProgressPhase;
27
+ }
28
+
29
+ export interface ProgressState {
30
+ sessionName?: string;
31
+ repos: Array<{
32
+ repo: RepoSpec;
33
+ status: ProgressRepoStatus;
34
+ }>;
35
+ phase: ProgressPhase;
36
+ currentOutput?: string[]; // Last 5 lines of git output
37
+ }
38
+
39
+ let statusTexts: TextRenderable[] = [];
40
+ let phaseText: TextRenderable | null = null;
41
+ let sessionText: TextRenderable | null = null;
42
+ let outputText: TextRenderable | null = null;
43
+
44
+ function getPhasePresentation(phase: ProgressPhase): { content: string; fg: string } {
45
+ switch (phase) {
46
+ case "naming":
47
+ return { content: "Generating session name...", fg: "#fbbf24" };
48
+ case "proposal":
49
+ return { content: "Here's what the agent proposed:", fg: "#38bdf8" };
50
+ case "cloning":
51
+ return { content: "Cloning repositories...", fg: "#38bdf8" };
52
+ case "installing-skills":
53
+ return { content: "Installing skills...", fg: "#38bdf8" };
54
+ case "refreshing":
55
+ return { content: "Refreshing latest repositories...", fg: "#38bdf8" };
56
+ case "done":
57
+ return { content: "Ready!", fg: "#22c55e" };
58
+ default:
59
+ return { content: "Processing...", fg: "#38bdf8" };
60
+ }
61
+ }
62
+
63
+ export function showProgress(
64
+ renderer: CliRenderer,
65
+ repos: RepoSpec[],
66
+ options: ProgressOptions = {},
67
+ ): ProgressState {
68
+ clearLayout(renderer);
69
+ statusTexts = [];
70
+
71
+ const {
72
+ title = "Setting up session",
73
+ label = "Cloning repositories:",
74
+ initialPhase = "naming",
75
+ } = options;
76
+
77
+ const { content } = createBaseLayout(renderer, title);
78
+
79
+ const phasePresentation = getPhasePresentation(initialPhase);
80
+
81
+ phaseText = new TextRenderable(renderer, {
82
+ id: "phase",
83
+ content: phasePresentation.content,
84
+ fg: phasePresentation.fg,
85
+ marginBottom: 1,
86
+ });
87
+ content.add(phaseText);
88
+
89
+ sessionText = new TextRenderable(renderer, {
90
+ id: "session-name",
91
+ content: "",
92
+ fg: "#38bdf8",
93
+ });
94
+ content.add(sessionText);
95
+
96
+ const cloningLabel = new TextRenderable(renderer, {
97
+ id: "cloning-label",
98
+ content: `\n${label}`,
99
+ fg: "#e2e8f0",
100
+ marginTop: 1,
101
+ });
102
+ content.add(cloningLabel);
103
+
104
+ const state: ProgressState = {
105
+ repos: repos.map((repo) => ({ repo, status: "pending" as const })),
106
+ phase: initialPhase,
107
+ };
108
+
109
+ repos.forEach((repo, i) => {
110
+ const statusText = new TextRenderable(renderer, {
111
+ id: `repo-status-${i}`,
112
+ content: ` [ ] ${repo.owner}/${repo.name}`,
113
+ fg: "#94a3b8",
114
+ });
115
+ content.add(statusText);
116
+ statusTexts.push(statusText);
117
+ });
118
+
119
+ // Output display section (shows last 5 lines of git output)
120
+ outputText = new TextRenderable(renderer, {
121
+ id: "git-output",
122
+ content: "",
123
+ fg: "#64748b", // Muted slate gray
124
+ marginTop: 1,
125
+ });
126
+ content.add(outputText);
127
+
128
+ return state;
129
+ }
130
+
131
+ export function updateProgress(renderer: CliRenderer, state: ProgressState): void {
132
+ if (phaseText) {
133
+ const phasePresentation = getPhasePresentation(state.phase);
134
+ phaseText.content = phasePresentation.content;
135
+ phaseText.fg = phasePresentation.fg;
136
+ }
137
+
138
+ if (sessionText && state.sessionName) {
139
+ sessionText.content = `Session: ${state.sessionName}`;
140
+ }
141
+
142
+ state.repos.forEach((item, i) => {
143
+ const text = statusTexts[i];
144
+ if (text) {
145
+ const icon =
146
+ item.status === "done" || item.status === "updated"
147
+ ? "[x]"
148
+ : item.status === "cloning" || item.status === "refreshing"
149
+ ? "[~]"
150
+ : item.status === "error"
151
+ ? "[!]"
152
+ : item.status === "up-to-date"
153
+ ? "[=]"
154
+ : item.status === "skipped"
155
+ ? "[>]"
156
+ : "[ ]";
157
+ const color =
158
+ item.status === "done" || item.status === "updated"
159
+ ? "#22c55e"
160
+ : item.status === "cloning"
161
+ ? "#fbbf24"
162
+ : item.status === "refreshing"
163
+ ? "#38bdf8"
164
+ : item.status === "error"
165
+ ? "#ef4444"
166
+ : item.status === "skipped"
167
+ ? "#f59e0b"
168
+ : "#94a3b8";
169
+
170
+ text.content = ` ${icon} ${item.repo.owner}/${item.repo.name}`;
171
+ text.fg = color;
172
+ }
173
+ });
174
+
175
+ // Update git output display
176
+ if (outputText && state.currentOutput && state.currentOutput.length > 0) {
177
+ outputText.content = state.currentOutput.map((line) => ` ${line}`).join("\n");
178
+ } else if (outputText) {
179
+ outputText.content = "";
180
+ }
181
+
182
+ renderer.requestRender();
183
+ }
184
+
185
+ export function hideProgress(renderer: CliRenderer): void {
186
+ clearLayout(renderer);
187
+ statusTexts = [];
188
+ phaseText = null;
189
+ sessionText = null;
190
+ outputText = null;
191
+ }
@@ -0,0 +1,93 @@
1
+ import {
2
+ type CliRenderer,
3
+ TextRenderable,
4
+ SelectRenderable,
5
+ SelectRenderableEvents,
6
+ type KeyEvent,
7
+ } from "@opentui/core";
8
+ import { createBaseLayout, clearLayout } from "./renderer";
9
+ import type { RepoSpec } from "../types";
10
+
11
+ export type RecloneChoice = "reclone" | "skip";
12
+
13
+ export function showReclonePrompt(renderer: CliRenderer, repo: RepoSpec): Promise<RecloneChoice> {
14
+ return new Promise((resolve) => {
15
+ clearLayout(renderer);
16
+
17
+ const { content } = createBaseLayout(renderer, "Update issue");
18
+
19
+ const repoInfo = new TextRenderable(renderer, {
20
+ id: "repo-info",
21
+ content: `${repo.owner}/${repo.name}`,
22
+ fg: "#38bdf8",
23
+ marginBottom: 1,
24
+ });
25
+ content.add(repoInfo);
26
+
27
+ const warning = new TextRenderable(renderer, {
28
+ id: "warning",
29
+ content: "We had issues updating this repo.",
30
+ fg: "#f59e0b",
31
+ marginBottom: 0,
32
+ });
33
+ content.add(warning);
34
+
35
+ const question = new TextRenderable(renderer, {
36
+ id: "question",
37
+ content: "Wipe and reclone it?",
38
+ fg: "#e2e8f0",
39
+ marginBottom: 1,
40
+ });
41
+ content.add(question);
42
+
43
+ const select = new SelectRenderable(renderer, {
44
+ id: "reclone-select",
45
+ width: 24,
46
+ height: 2,
47
+ options: [
48
+ { name: "Reclone", description: "", value: "reclone" },
49
+ { name: "Skip", description: "", value: "skip" },
50
+ ],
51
+ showDescription: false,
52
+ backgroundColor: "transparent",
53
+ focusedBackgroundColor: "transparent",
54
+ selectedBackgroundColor: "#334155",
55
+ textColor: "#e2e8f0",
56
+ selectedTextColor: "#38bdf8",
57
+ marginTop: 1,
58
+ });
59
+ content.add(select);
60
+
61
+ const instructions = new TextRenderable(renderer, {
62
+ id: "instructions",
63
+ content: "\n[Enter] Select [Esc] Skip",
64
+ fg: "#64748b",
65
+ marginTop: 1,
66
+ });
67
+ content.add(instructions);
68
+
69
+ select.focus();
70
+
71
+ const handleSelect = (_index: number, option: { value: string }) => {
72
+ cleanup();
73
+ resolve(option.value as RecloneChoice);
74
+ };
75
+
76
+ const handleKeypress = (key: KeyEvent) => {
77
+ if (key.name === "escape") {
78
+ cleanup();
79
+ resolve("skip");
80
+ }
81
+ };
82
+
83
+ const cleanup = () => {
84
+ select.off(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
85
+ renderer.keyInput.off("keypress", handleKeypress);
86
+ select.blur();
87
+ clearLayout(renderer);
88
+ };
89
+
90
+ select.on(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
91
+ renderer.keyInput.on("keypress", handleKeypress);
92
+ });
93
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ createCliRenderer,
3
+ type CliRenderer,
4
+ BoxRenderable,
5
+ TextRenderable,
6
+ ASCIIFontRenderable,
7
+ RGBA,
8
+ } from "@opentui/core";
9
+ import { measureText } from "@opentui/core";
10
+
11
+ let renderer: CliRenderer | null = null;
12
+
13
+ export async function createRenderer(): Promise<CliRenderer> {
14
+ if (renderer) {
15
+ return renderer;
16
+ }
17
+
18
+ renderer = await createCliRenderer({
19
+ exitOnCtrlC: false,
20
+ targetFps: 30,
21
+ });
22
+
23
+ renderer.setBackgroundColor("#0f172a");
24
+
25
+ return renderer;
26
+ }
27
+
28
+ export function getRenderer(): CliRenderer | null {
29
+ return renderer;
30
+ }
31
+
32
+ export function destroyRenderer(): void {
33
+ if (renderer) {
34
+ renderer.destroy();
35
+ renderer = null;
36
+ }
37
+ }
38
+
39
+ export interface LayoutElements {
40
+ container: BoxRenderable;
41
+ title: ASCIIFontRenderable;
42
+ content: BoxRenderable;
43
+ }
44
+
45
+ export function createBaseLayout(r: CliRenderer, subtitle?: string): LayoutElements {
46
+ const width = r.terminalWidth;
47
+
48
+ // Main container
49
+ const container = new BoxRenderable(r, {
50
+ id: "main-container",
51
+ width: "100%",
52
+ height: "100%",
53
+ flexDirection: "column",
54
+ alignItems: "center",
55
+ padding: 1,
56
+ });
57
+ r.root.add(container);
58
+
59
+ // Title
60
+ const titleText = "letmecook";
61
+ const titleFont = "tiny";
62
+ const { width: titleWidth } = measureText({ text: titleText, font: titleFont });
63
+ const centerX = Math.floor(width / 2) - Math.floor(titleWidth / 2);
64
+
65
+ const title = new ASCIIFontRenderable(r, {
66
+ id: "title",
67
+ text: titleText,
68
+ font: titleFont,
69
+ color: RGBA.fromHex("#f8fafc"),
70
+ position: "absolute",
71
+ left: centerX,
72
+ top: 11,
73
+ });
74
+ r.root.add(title);
75
+
76
+ // Content box below title
77
+ const content = new BoxRenderable(r, {
78
+ id: "content",
79
+ width: Math.min(70, width - 4),
80
+ marginTop: 15,
81
+ padding: 1,
82
+ flexDirection: "column",
83
+ borderStyle: "single",
84
+ borderColor: "#475569",
85
+ backgroundColor: "#1e293b",
86
+ });
87
+ container.add(content);
88
+
89
+ // Add subtitle if provided
90
+ if (subtitle) {
91
+ const subtitleText = new TextRenderable(r, {
92
+ id: "subtitle",
93
+ content: subtitle,
94
+ fg: "#94a3b8",
95
+ marginBottom: 1,
96
+ });
97
+ content.add(subtitleText);
98
+ }
99
+
100
+ return { container, title, content };
101
+ }
102
+
103
+ export function clearLayout(r: CliRenderer): void {
104
+ // Remove known elements
105
+ r.root.remove("main-container");
106
+ r.root.remove("title");
107
+ r.root.remove("content");
108
+ }