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.
- package/README.md +120 -0
- package/bin.js +3 -0
- package/index.ts +234 -0
- package/package.json +42 -5
- package/src/agents-md.ts +115 -0
- package/src/flows/add-repos.ts +57 -0
- package/src/flows/add-skills.ts +57 -0
- package/src/flows/edit-session.ts +107 -0
- package/src/flows/index.ts +5 -0
- package/src/flows/new-session.ts +182 -0
- package/src/flows/resume-session.ts +231 -0
- package/src/git.ts +256 -0
- package/src/naming.ts +57 -0
- package/src/opencode-integration.ts +20 -0
- package/src/repo-history.ts +82 -0
- package/src/sessions.ts +217 -0
- package/src/skills.ts +49 -0
- package/src/tui-mode.ts +184 -0
- package/src/types.ts +80 -0
- package/src/ui/add-repos.ts +396 -0
- package/src/ui/agent-proposal.ts +80 -0
- package/src/ui/common/repo-formatter.ts +45 -0
- package/src/ui/confirm-delete.ts +95 -0
- package/src/ui/conflict.ts +121 -0
- package/src/ui/exit.ts +175 -0
- package/src/ui/list.ts +112 -0
- package/src/ui/main-menu.ts +155 -0
- package/src/ui/new-session.ts +99 -0
- package/src/ui/progress.ts +191 -0
- package/src/ui/reclone-prompt.ts +93 -0
- package/src/ui/renderer.ts +108 -0
- package/src/ui/session-actions.ts +109 -0
- package/src/ui/session-details.ts +77 -0
- package/src/ui/session-options.ts +41 -0
- package/src/ui/session-settings.ts +363 -0
- package/src/ui/skills.ts +185 -0
- package/src/utils/stream.ts +108 -0
|
@@ -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
|
+
}
|