letmecook 0.0.1 → 0.0.2

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,80 @@
1
+ import { type CliRenderer, TextRenderable } from "@opentui/core";
2
+ import { createBaseLayout, clearLayout } from "./renderer";
3
+ import type { RepoSpec } from "../types";
4
+
5
+ export interface AgentProposal {
6
+ sessionName: string;
7
+ repos: RepoSpec[];
8
+ goal?: string;
9
+ }
10
+
11
+ export function showAgentProposal(renderer: CliRenderer, proposal: AgentProposal): void {
12
+ clearLayout(renderer);
13
+
14
+ const { content } = createBaseLayout(renderer, "Agent Proposal");
15
+
16
+ // Session name
17
+ const sessionNameText = new TextRenderable(renderer, {
18
+ id: "session-name",
19
+ content: `Session: ${proposal.sessionName}`,
20
+ fg: "#38bdf8",
21
+ marginBottom: 2,
22
+ });
23
+ content.add(sessionNameText);
24
+
25
+ // Goal if provided
26
+ if (proposal.goal) {
27
+ const goalLabel = new TextRenderable(renderer, {
28
+ id: "goal-label",
29
+ content: "Goal:",
30
+ fg: "#e2e8f0",
31
+ marginBottom: 0,
32
+ });
33
+ content.add(goalLabel);
34
+
35
+ const goalText = new TextRenderable(renderer, {
36
+ id: "goal-text",
37
+ content: proposal.goal,
38
+ fg: "#94a3b8",
39
+ marginBottom: 2,
40
+ });
41
+ content.add(goalText);
42
+ }
43
+
44
+ // Repository plan
45
+ const planLabel = new TextRenderable(renderer, {
46
+ id: "plan-label",
47
+ content: "Repositories to clone:",
48
+ fg: "#e2e8f0",
49
+ marginBottom: 1,
50
+ });
51
+ content.add(planLabel);
52
+
53
+ proposal.repos.forEach((repo) => {
54
+ const repoText = new TextRenderable(renderer, {
55
+ id: `repo-${repo.owner}-${repo.name}`,
56
+ content: ` 📦 ${repo.owner}/${repo.name}`,
57
+ fg: "#10b981",
58
+ marginBottom: 0,
59
+ });
60
+ content.add(repoText);
61
+ });
62
+
63
+ // Summary
64
+ const summaryText = new TextRenderable(renderer, {
65
+ id: "summary",
66
+ content: `\nThis will clone ${proposal.repos.length} repositories.`,
67
+ fg: "#64748b",
68
+ marginTop: 2,
69
+ });
70
+ content.add(summaryText);
71
+
72
+ // Continue prompt
73
+ const continueText = new TextRenderable(renderer, {
74
+ id: "continue",
75
+ content: "Continuing...",
76
+ fg: "#64748b",
77
+ marginTop: 1,
78
+ });
79
+ content.add(continueText);
80
+ }
@@ -0,0 +1,45 @@
1
+ import type { RepoSpec } from "../../types";
2
+
3
+ export interface RepoFormatterOptions {
4
+ showMarkers?: boolean;
5
+ prefix?: string;
6
+ }
7
+
8
+ export function formatRepoList(repos: RepoSpec[], options: RepoFormatterOptions = {}): string {
9
+ const { showMarkers = true, prefix = "" } = options;
10
+
11
+ if (repos.length === 0) return "(none)";
12
+
13
+ return repos
14
+ .map((repo) => {
15
+ let text = `${prefix}${repo.owner}/${repo.name}`;
16
+
17
+ if (showMarkers) {
18
+ const branchMarker = repo.branch ? ` (${repo.branch})` : "";
19
+ const roMarker = repo.readOnly ? " [RO]" : "";
20
+ const latestMarker = repo.latest ? " [Latest]" : "";
21
+ text += `${branchMarker}${roMarker}${latestMarker}`;
22
+ }
23
+
24
+ return text;
25
+ })
26
+ .join("\n");
27
+ }
28
+
29
+ export function formatRepoString(repo: RepoSpec): string {
30
+ const parts = [`${repo.owner}/${repo.name}`];
31
+
32
+ if (repo.branch) {
33
+ parts.push(`(${repo.branch})`);
34
+ }
35
+
36
+ if (repo.readOnly) {
37
+ parts.push("[RO]");
38
+ }
39
+
40
+ if (repo.latest) {
41
+ parts.push("[Latest]");
42
+ }
43
+
44
+ return parts.join(" ");
45
+ }
@@ -0,0 +1,95 @@
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 { Session } from "../types";
10
+
11
+ export type DeleteConfirmChoice = "confirm" | "cancel";
12
+
13
+ export function showDeleteConfirm(
14
+ renderer: CliRenderer,
15
+ session: Session,
16
+ ): Promise<DeleteConfirmChoice> {
17
+ return new Promise((resolve) => {
18
+ clearLayout(renderer);
19
+
20
+ const { content } = createBaseLayout(renderer, "Delete session");
21
+
22
+ const sessionInfo = new TextRenderable(renderer, {
23
+ id: "session-info",
24
+ content: `Session: ${session.name}`,
25
+ fg: "#38bdf8",
26
+ marginBottom: 1,
27
+ });
28
+ content.add(sessionInfo);
29
+
30
+ const warning = new TextRenderable(renderer, {
31
+ id: "warning",
32
+ content: "This permanently deletes the session and its files.",
33
+ fg: "#f59e0b",
34
+ marginBottom: 1,
35
+ });
36
+ content.add(warning);
37
+
38
+ const question = new TextRenderable(renderer, {
39
+ id: "question",
40
+ content: "Are you sure you want to delete this session?",
41
+ fg: "#e2e8f0",
42
+ });
43
+ content.add(question);
44
+
45
+ const select = new SelectRenderable(renderer, {
46
+ id: "delete-confirm-select",
47
+ width: 38,
48
+ height: 2,
49
+ options: [
50
+ { name: "Cancel", description: "", value: "cancel" },
51
+ { name: "Delete session", description: "", value: "confirm" },
52
+ ],
53
+ showDescription: false,
54
+ backgroundColor: "transparent",
55
+ focusedBackgroundColor: "transparent",
56
+ selectedBackgroundColor: "#334155",
57
+ textColor: "#e2e8f0",
58
+ selectedTextColor: "#38bdf8",
59
+ marginTop: 1,
60
+ });
61
+ content.add(select);
62
+
63
+ const instructions = new TextRenderable(renderer, {
64
+ id: "instructions",
65
+ content: "\n[Enter] Select [Esc] Cancel",
66
+ fg: "#64748b",
67
+ marginTop: 1,
68
+ });
69
+ content.add(instructions);
70
+
71
+ select.focus();
72
+
73
+ const handleSelect = (_index: number, option: { value: string }) => {
74
+ cleanup();
75
+ resolve(option.value as DeleteConfirmChoice);
76
+ };
77
+
78
+ const handleKeypress = (key: KeyEvent) => {
79
+ if (key.name === "escape") {
80
+ cleanup();
81
+ resolve("cancel");
82
+ }
83
+ };
84
+
85
+ const cleanup = () => {
86
+ select.off(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
87
+ renderer.keyInput.off("keypress", handleKeypress);
88
+ select.blur();
89
+ clearLayout(renderer);
90
+ };
91
+
92
+ select.on(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
93
+ renderer.keyInput.on("keypress", handleKeypress);
94
+ });
95
+ }
@@ -0,0 +1,121 @@
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 { Session, ConflictChoice } from "../types";
10
+
11
+ function formatTimeAgo(date: string): string {
12
+ const now = new Date();
13
+ const then = new Date(date);
14
+ const diffMs = now.getTime() - then.getTime();
15
+ const diffMins = Math.floor(diffMs / 60000);
16
+ const diffHours = Math.floor(diffMins / 60);
17
+ const diffDays = Math.floor(diffHours / 24);
18
+
19
+ if (diffMins < 1) return "just now";
20
+ if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
21
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
22
+ if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
23
+ return then.toLocaleDateString();
24
+ }
25
+
26
+ export function showConflictPrompt(
27
+ renderer: CliRenderer,
28
+ existingSession: Session,
29
+ ): Promise<ConflictChoice> {
30
+ return new Promise((resolve) => {
31
+ clearLayout(renderer);
32
+
33
+ const { content } = createBaseLayout(renderer, "Existing session found");
34
+
35
+ // Session info
36
+ const sessionInfo = new TextRenderable(renderer, {
37
+ id: "session-info",
38
+ content: `Session: ${existingSession.name}`,
39
+ fg: "#38bdf8",
40
+ });
41
+ content.add(sessionInfo);
42
+
43
+ const timeInfo = new TextRenderable(renderer, {
44
+ id: "time-info",
45
+ content: `Created: ${formatTimeAgo(existingSession.created)}`,
46
+ fg: "#94a3b8",
47
+ });
48
+ content.add(timeInfo);
49
+
50
+ const reposInfo = new TextRenderable(renderer, {
51
+ id: "repos-info",
52
+ content: `Repos: ${existingSession.repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`,
53
+ fg: "#94a3b8",
54
+ });
55
+ content.add(reposInfo);
56
+
57
+ if (existingSession.goal) {
58
+ const goalInfo = new TextRenderable(renderer, {
59
+ id: "goal-info",
60
+ content: `Goal: ${existingSession.goal}`,
61
+ fg: "#94a3b8",
62
+ marginBottom: 1,
63
+ });
64
+ content.add(goalInfo);
65
+ }
66
+
67
+ // Question
68
+ const question = new TextRenderable(renderer, {
69
+ id: "question",
70
+ content: "\nWhat would you like to do?",
71
+ fg: "#e2e8f0",
72
+ marginTop: 1,
73
+ });
74
+ content.add(question);
75
+
76
+ // Options
77
+ const select = new SelectRenderable(renderer, {
78
+ id: "conflict-select",
79
+ width: 40,
80
+ height: 4,
81
+ options: [
82
+ { name: "Resume existing session", description: "", value: "resume" },
83
+ { name: "Nuke it and start fresh", description: "", value: "nuke" },
84
+ { name: "Create new session (keep old)", description: "", value: "new" },
85
+ { name: "Cancel", description: "", value: "cancel" },
86
+ ],
87
+ showDescription: false,
88
+ backgroundColor: "transparent",
89
+ focusedBackgroundColor: "transparent",
90
+ selectedBackgroundColor: "#334155",
91
+ textColor: "#e2e8f0",
92
+ selectedTextColor: "#38bdf8",
93
+ marginTop: 1,
94
+ });
95
+ content.add(select);
96
+
97
+ select.focus();
98
+
99
+ const handleSelect = (_index: number, option: { value: string }) => {
100
+ cleanup();
101
+ resolve(option.value as ConflictChoice);
102
+ };
103
+
104
+ const handleKeypress = (key: KeyEvent) => {
105
+ if (key.name === "escape") {
106
+ cleanup();
107
+ resolve("cancel");
108
+ }
109
+ };
110
+
111
+ const cleanup = () => {
112
+ select.off(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
113
+ renderer.keyInput.off("keypress", handleKeypress);
114
+ select.blur();
115
+ clearLayout(renderer);
116
+ };
117
+
118
+ select.on(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
119
+ renderer.keyInput.on("keypress", handleKeypress);
120
+ });
121
+ }
package/src/ui/exit.ts ADDED
@@ -0,0 +1,175 @@
1
+ import { type CliRenderer, TextRenderable, type KeyEvent } from "@opentui/core";
2
+ import { createBaseLayout, clearLayout } from "./renderer";
3
+ import type { Session, ExitChoice, RepoSpec } from "../types";
4
+ import { sessionHasUncommittedChanges } from "../git";
5
+
6
+ export function showExitPrompt(renderer: CliRenderer, session: Session): Promise<ExitChoice> {
7
+ return new Promise((resolve) => {
8
+ clearLayout(renderer);
9
+
10
+ const { content } = createBaseLayout(renderer, "Session complete");
11
+
12
+ // Session info
13
+ const sessionInfo = new TextRenderable(renderer, {
14
+ id: "session-info",
15
+ content: `Session: ${session.name}`,
16
+ fg: "#38bdf8",
17
+ marginBottom: 1,
18
+ });
19
+ content.add(sessionInfo);
20
+
21
+ // Question
22
+ const question = new TextRenderable(renderer, {
23
+ id: "question",
24
+ content: "What would you like to do?",
25
+ fg: "#e2e8f0",
26
+ });
27
+ content.add(question);
28
+
29
+ const instructions = new TextRenderable(renderer, {
30
+ id: "instructions",
31
+ content: "\n[Enter] Resume [e] Edit session [d] Delete session [Esc] Back to home",
32
+ fg: "#64748b",
33
+ marginTop: 1,
34
+ });
35
+ content.add(instructions);
36
+
37
+ const handleKeypress = (key: KeyEvent) => {
38
+ if (key.name === "return" || key.name === "enter") {
39
+ cleanup();
40
+ resolve("resume");
41
+ return;
42
+ }
43
+
44
+ if (key.name === "e") {
45
+ cleanup();
46
+ resolve("edit");
47
+ return;
48
+ }
49
+
50
+ if (key.name === "d") {
51
+ cleanup();
52
+ resolve("delete");
53
+ return;
54
+ }
55
+
56
+ if (key.name === "escape") {
57
+ cleanup();
58
+ resolve("home");
59
+ }
60
+ };
61
+
62
+ const cleanup = () => {
63
+ renderer.keyInput.off("keypress", handleKeypress);
64
+ clearLayout(renderer);
65
+ };
66
+
67
+ renderer.keyInput.on("keypress", handleKeypress);
68
+ });
69
+ }
70
+
71
+ export function showExitPromptWithChanges(
72
+ renderer: CliRenderer,
73
+ session: Session,
74
+ reposWithChanges: RepoSpec[],
75
+ ): Promise<ExitChoice> {
76
+ return new Promise((resolve) => {
77
+ clearLayout(renderer);
78
+
79
+ const { content } = createBaseLayout(renderer, "Session complete");
80
+
81
+ // Session info
82
+ const sessionInfo = new TextRenderable(renderer, {
83
+ id: "session-info",
84
+ content: `Session: ${session.name}`,
85
+ fg: "#38bdf8",
86
+ marginBottom: 1,
87
+ });
88
+ content.add(sessionInfo);
89
+
90
+ // Warning about uncommitted changes
91
+ const warning = new TextRenderable(renderer, {
92
+ id: "warning",
93
+ content: "⚠️ Uncommitted changes detected:",
94
+ fg: "#f59e0b",
95
+ marginBottom: 0,
96
+ });
97
+ content.add(warning);
98
+
99
+ const changedReposList = new TextRenderable(renderer, {
100
+ id: "changed-repos",
101
+ content: reposWithChanges.map((r) => ` - ${r.dir}/`).join("\n"),
102
+ fg: "#fbbf24",
103
+ marginBottom: 1,
104
+ });
105
+ content.add(changedReposList);
106
+
107
+ // Question
108
+ const question = new TextRenderable(renderer, {
109
+ id: "question",
110
+ content: "What would you like to do?",
111
+ fg: "#e2e8f0",
112
+ });
113
+ content.add(question);
114
+
115
+ const instructions = new TextRenderable(renderer, {
116
+ id: "instructions",
117
+ content: "\n[Enter] Resume [e] Edit session [d] Delete session [Esc] Back to home",
118
+ fg: "#64748b",
119
+ marginTop: 1,
120
+ });
121
+ content.add(instructions);
122
+
123
+ const handleKeypress = (key: KeyEvent) => {
124
+ if (key.name === "return" || key.name === "enter") {
125
+ cleanup();
126
+ resolve("resume");
127
+ return;
128
+ }
129
+
130
+ if (key.name === "e") {
131
+ cleanup();
132
+ resolve("edit");
133
+ return;
134
+ }
135
+
136
+ if (key.name === "d") {
137
+ cleanup();
138
+ resolve("delete");
139
+ return;
140
+ }
141
+
142
+ if (key.name === "escape") {
143
+ cleanup();
144
+ resolve("home");
145
+ }
146
+ };
147
+
148
+ const cleanup = () => {
149
+ renderer.keyInput.off("keypress", handleKeypress);
150
+ clearLayout(renderer);
151
+ };
152
+
153
+ renderer.keyInput.on("keypress", handleKeypress);
154
+ });
155
+ }
156
+
157
+ export async function handleSmartExit(
158
+ renderer: CliRenderer,
159
+ session: Session,
160
+ ): Promise<{ action: ExitChoice }> {
161
+ const { hasChanges, reposWithChanges } = await sessionHasUncommittedChanges(
162
+ session.repos,
163
+ session.path,
164
+ );
165
+
166
+ // Always prompt - if there are uncommitted changes, show warning
167
+ if (hasChanges) {
168
+ const choice = await showExitPromptWithChanges(renderer, session, reposWithChanges);
169
+ return { action: choice };
170
+ }
171
+
172
+ // No uncommitted changes - show simple exit prompt
173
+ const choice = await showExitPrompt(renderer, session);
174
+ return { action: choice };
175
+ }
package/src/ui/list.ts ADDED
@@ -0,0 +1,112 @@
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 ListAction =
13
+ | { type: "resume"; session: Session }
14
+ | { type: "delete"; session: Session }
15
+ | { type: "nuke-all" }
16
+ | { type: "quit" };
17
+
18
+ export function showSessionList(renderer: CliRenderer, sessions: Session[]): Promise<ListAction> {
19
+ return new Promise((resolve) => {
20
+ clearLayout(renderer);
21
+
22
+ const { content } = createBaseLayout(renderer, "Sessions");
23
+
24
+ if (sessions.length === 0) {
25
+ const emptyText = new TextRenderable(renderer, {
26
+ id: "empty",
27
+ content: "No sessions found.\n\nCreate one with: letmecook owner/repo",
28
+ fg: "#94a3b8",
29
+ });
30
+ content.add(emptyText);
31
+
32
+ const handleKeypress = (key: KeyEvent) => {
33
+ if (key.name === "escape") {
34
+ renderer.keyInput.off("keypress", handleKeypress);
35
+ clearLayout(renderer);
36
+ resolve({ type: "quit" });
37
+ }
38
+ };
39
+
40
+ renderer.keyInput.on("keypress", handleKeypress);
41
+ return;
42
+ }
43
+
44
+ // Build options from sessions
45
+ const options = buildSessionOptions(sessions);
46
+
47
+ const select = new SelectRenderable(renderer, {
48
+ id: "session-list",
49
+ width: 65,
50
+ height: Math.min(sessions.length * 2, 12),
51
+ options,
52
+ showDescription: true,
53
+ backgroundColor: "transparent",
54
+ focusedBackgroundColor: "transparent",
55
+ selectedBackgroundColor: "#334155",
56
+ textColor: "#e2e8f0",
57
+ selectedTextColor: "#38bdf8",
58
+ descriptionColor: "#64748b",
59
+ selectedDescriptionColor: "#94a3b8",
60
+ });
61
+ content.add(select);
62
+
63
+ // Instructions
64
+ const instructions = new TextRenderable(renderer, {
65
+ id: "instructions",
66
+ content: "\n[Enter] Resume [d] Delete [a] Nuke All [Esc] Quit",
67
+ fg: "#64748b",
68
+ marginTop: 1,
69
+ });
70
+ content.add(instructions);
71
+
72
+ select.focus();
73
+
74
+ let selectedIndex = 0;
75
+
76
+ const handleSelect = (_index: number, option: { value: Session }) => {
77
+ cleanup();
78
+ resolve({ type: "resume", session: option.value });
79
+ };
80
+
81
+ const handleKeypress = (key: KeyEvent) => {
82
+ // Track selection for delete
83
+ if (key.name === "up" || key.name === "k") {
84
+ selectedIndex = Math.max(0, selectedIndex - 1);
85
+ } else if (key.name === "down" || key.name === "j") {
86
+ selectedIndex = Math.min(sessions.length - 1, selectedIndex + 1);
87
+ } else if (key.name === "d") {
88
+ const session = sessions[selectedIndex];
89
+ if (session) {
90
+ cleanup();
91
+ resolve({ type: "delete", session });
92
+ }
93
+ } else if (key.name === "a") {
94
+ cleanup();
95
+ resolve({ type: "nuke-all" });
96
+ } else if (key.name === "escape") {
97
+ cleanup();
98
+ resolve({ type: "quit" });
99
+ }
100
+ };
101
+
102
+ const cleanup = () => {
103
+ select.off(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
104
+ renderer.keyInput.off("keypress", handleKeypress);
105
+ select.blur();
106
+ clearLayout(renderer);
107
+ };
108
+
109
+ select.on(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
110
+ renderer.keyInput.on("keypress", handleKeypress);
111
+ });
112
+ }