letmecook 0.0.13 → 0.0.15

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,249 @@
1
+ import { type CliRenderer, TextRenderable } from "@opentui/core";
2
+ import { createBaseLayout, clearLayout } from "../renderer";
3
+ import { readProcessOutputWithBuffer } from "../../utils/stream";
4
+
5
+ export interface CommandTask {
6
+ label: string; // "Cloning microsoft/playwright"
7
+ command: string[]; // ["git", "clone", "https://..."]
8
+ cwd?: string; // Working directory
9
+ }
10
+
11
+ export interface CommandRunnerOptions {
12
+ title: string; // "Setting up session"
13
+ tasks: CommandTask[]; // Array of commands to run
14
+ showOutput?: boolean; // Show last N lines (default: true)
15
+ outputLines?: number; // How many lines to show (default: 5)
16
+ }
17
+
18
+ export interface CommandResult {
19
+ task: CommandTask;
20
+ success: boolean;
21
+ exitCode: number;
22
+ output: string[];
23
+ error?: string;
24
+ }
25
+
26
+ let taskTexts: TextRenderable[] = [];
27
+ let currentCommandText: TextRenderable | null = null;
28
+ let outputText: TextRenderable | null = null;
29
+ let tasksLabel: TextRenderable | null = null;
30
+
31
+ function getStatusIcon(status: "pending" | "running" | "done" | "error"): {
32
+ icon: string;
33
+ color: string;
34
+ } {
35
+ switch (status) {
36
+ case "done":
37
+ return { icon: "[✓]", color: "#22c55e" };
38
+ case "running":
39
+ return { icon: "[~]", color: "#fbbf24" };
40
+ case "error":
41
+ return { icon: "[✗]", color: "#ef4444" };
42
+ case "pending":
43
+ default:
44
+ return { icon: "[ ]", color: "#94a3b8" };
45
+ }
46
+ }
47
+
48
+ export function showCommandRunner(
49
+ renderer: CliRenderer,
50
+ options: CommandRunnerOptions,
51
+ ): {
52
+ taskStatuses: Array<{ task: CommandTask; status: "pending" | "running" | "done" | "error" }>;
53
+ } {
54
+ clearLayout(renderer);
55
+ taskTexts = [];
56
+
57
+ const { title, tasks, showOutput = true } = options;
58
+
59
+ const { content } = createBaseLayout(renderer, title);
60
+
61
+ tasksLabel = new TextRenderable(renderer, {
62
+ id: "tasks-label",
63
+ content: "Tasks",
64
+ fg: "#e2e8f0",
65
+ marginTop: 1,
66
+ marginBottom: 1,
67
+ });
68
+ content.add(tasksLabel);
69
+
70
+ const taskStatuses = tasks.map((task) => ({ task, status: "pending" as const }));
71
+
72
+ tasks.forEach((task, i) => {
73
+ const statusIcon = getStatusIcon("pending");
74
+ const taskText = new TextRenderable(renderer, {
75
+ id: `task-${i}`,
76
+ content: `${statusIcon.icon} ${task.label}`,
77
+ fg: statusIcon.color,
78
+ });
79
+ content.add(taskText);
80
+ taskTexts.push(taskText);
81
+ });
82
+
83
+ if (showOutput) {
84
+ currentCommandText = new TextRenderable(renderer, {
85
+ id: "current-command",
86
+ content: "",
87
+ fg: "#64748b",
88
+ marginTop: 2,
89
+ });
90
+ content.add(currentCommandText);
91
+
92
+ const separator = new TextRenderable(renderer, {
93
+ id: "output-separator",
94
+ content: "──────────────────────────────────────",
95
+ fg: "#475569",
96
+ marginTop: 0,
97
+ });
98
+ content.add(separator);
99
+
100
+ outputText = new TextRenderable(renderer, {
101
+ id: "command-output",
102
+ content: "",
103
+ fg: "#64748b",
104
+ marginTop: 0,
105
+ });
106
+ content.add(outputText);
107
+ }
108
+
109
+ return { taskStatuses };
110
+ }
111
+
112
+ export function updateCommandRunner(
113
+ renderer: CliRenderer,
114
+ taskStatuses: Array<{ task: CommandTask; status: "pending" | "running" | "done" | "error" }>,
115
+ currentTaskIndex?: number,
116
+ outputLines?: string[],
117
+ ): void {
118
+ taskStatuses.forEach((item, i) => {
119
+ const text = taskTexts[i];
120
+ if (text) {
121
+ const statusIcon = getStatusIcon(item.status);
122
+ text.content = `${statusIcon.icon} ${item.task.label}`;
123
+ text.fg = statusIcon.color;
124
+ }
125
+ });
126
+
127
+ if (currentCommandText && currentTaskIndex !== undefined && currentTaskIndex >= 0) {
128
+ const currentTask = taskStatuses[currentTaskIndex];
129
+ if (currentTask) {
130
+ const commandStr = currentTask.task.command.join(" ");
131
+ currentCommandText.content = `Running: ${commandStr}`;
132
+ currentCommandText.fg = "#38bdf8";
133
+ }
134
+ }
135
+
136
+ if (outputText && outputLines && outputLines.length > 0) {
137
+ outputText.content = outputLines.map((line) => ` ${line}`).join("\n");
138
+ } else if (outputText) {
139
+ outputText.content = "";
140
+ }
141
+
142
+ renderer.requestRender();
143
+ }
144
+
145
+ export function hideCommandRunner(renderer: CliRenderer): void {
146
+ clearLayout(renderer);
147
+ taskTexts = [];
148
+ currentCommandText = null;
149
+ outputText = null;
150
+ tasksLabel = null;
151
+ }
152
+
153
+ export async function runCommands(
154
+ renderer: CliRenderer,
155
+ options: CommandRunnerOptions,
156
+ ): Promise<CommandResult[]> {
157
+ const { tasks, showOutput = true, outputLines = 5 } = options;
158
+
159
+ const { taskStatuses } = showCommandRunner(renderer, options);
160
+ const results: CommandResult[] = [];
161
+
162
+ for (let i = 0; i < tasks.length; i++) {
163
+ const task = tasks[i];
164
+ const taskState = taskStatuses[i];
165
+
166
+ if (!task || !taskState) continue;
167
+
168
+ // Update status to running
169
+ taskState.status = "running";
170
+ updateCommandRunner(renderer, taskStatuses, i, []);
171
+
172
+ try {
173
+ const proc = Bun.spawn(task.command, {
174
+ cwd: task.cwd,
175
+ stdout: "pipe",
176
+ stderr: "pipe",
177
+ });
178
+
179
+ let outputBuffer: string[] = [];
180
+
181
+ const { success, output, fullOutput } = await readProcessOutputWithBuffer(proc, {
182
+ maxBufferLines: outputLines,
183
+ onBufferUpdate: (buffer) => {
184
+ outputBuffer = buffer;
185
+ if (showOutput) {
186
+ updateCommandRunner(renderer, taskStatuses, i, buffer);
187
+ }
188
+ },
189
+ });
190
+
191
+ const exitCode = await proc.exited;
192
+
193
+ if (success && exitCode === 0) {
194
+ taskState.status = "done";
195
+ results.push({
196
+ task,
197
+ success: true,
198
+ exitCode: 0,
199
+ output: outputBuffer.length > 0 ? outputBuffer : output,
200
+ });
201
+ } else {
202
+ taskState.status = "error";
203
+ const errorMsg = fullOutput.trim() || `Command exited with code ${exitCode}`;
204
+ results.push({
205
+ task,
206
+ success: false,
207
+ exitCode,
208
+ output: outputBuffer.length > 0 ? outputBuffer : output,
209
+ error: errorMsg,
210
+ });
211
+ }
212
+
213
+ // Final update with last output
214
+ if (showOutput) {
215
+ updateCommandRunner(
216
+ renderer,
217
+ taskStatuses,
218
+ i,
219
+ outputBuffer.length > 0 ? outputBuffer : output,
220
+ );
221
+ } else {
222
+ updateCommandRunner(renderer, taskStatuses, i);
223
+ }
224
+ } catch (error) {
225
+ taskState.status = "error";
226
+ const errorMsg = error instanceof Error ? error.message : String(error);
227
+ results.push({
228
+ task,
229
+ success: false,
230
+ exitCode: 1,
231
+ output: [],
232
+ error: errorMsg,
233
+ });
234
+
235
+ if (showOutput) {
236
+ updateCommandRunner(renderer, taskStatuses, i, [errorMsg]);
237
+ } else {
238
+ updateCommandRunner(renderer, taskStatuses, i);
239
+ }
240
+ }
241
+ }
242
+
243
+ // Clear current command indicator when done
244
+ if (currentCommandText) {
245
+ currentCommandText.content = "";
246
+ }
247
+
248
+ return results;
249
+ }
@@ -0,0 +1,105 @@
1
+ import { type CliRenderer, TextRenderable, type Renderable } from "@opentui/core";
2
+
3
+ export interface FooterActions {
4
+ navigate?: boolean; // Show ↑↓ Navigate
5
+ select?: boolean; // Show Enter Select
6
+ back?: boolean; // Show Esc Back
7
+ new?: boolean; // Show n New
8
+ delete?: boolean; // Show d Delete
9
+ quit?: boolean; // Show q Quit
10
+ custom?: string[]; // Custom action hints like ["Tab Switch", "s Save"]
11
+ }
12
+
13
+ let footerSeparator: TextRenderable | null = null;
14
+ let footerText: TextRenderable | null = null;
15
+ let footerParent: Renderable | null = null;
16
+
17
+ export function showFooter(
18
+ renderer: CliRenderer,
19
+ parent: Renderable,
20
+ actions: FooterActions = {},
21
+ ): void {
22
+ const {
23
+ navigate = true,
24
+ select = true,
25
+ back = true,
26
+ new: showNew = false,
27
+ delete: showDelete = false,
28
+ quit = false,
29
+ custom = [],
30
+ } = actions;
31
+
32
+ const parts: string[] = [];
33
+
34
+ if (navigate) {
35
+ parts.push("↑↓ Navigate");
36
+ }
37
+ if (select) {
38
+ parts.push("Enter Select");
39
+ }
40
+ if (back) {
41
+ parts.push("Esc Back");
42
+ }
43
+ if (showNew) {
44
+ parts.push("n New");
45
+ }
46
+ if (showDelete) {
47
+ parts.push("d Delete");
48
+ }
49
+ if (quit) {
50
+ parts.push("q Quit");
51
+ }
52
+ if (custom.length > 0) {
53
+ parts.push(...custom);
54
+ }
55
+
56
+ const footerContent = parts.join(" ");
57
+
58
+ // Hide any existing footer first
59
+ hideFooter(renderer);
60
+
61
+ // Create separator line
62
+ const separator = new TextRenderable(renderer, {
63
+ id: "footer-separator",
64
+ content: "─".repeat(66), // Approximate width for separator line
65
+ fg: "#475569",
66
+ marginTop: 1,
67
+ });
68
+ parent.add(separator);
69
+
70
+ // Create footer text
71
+ footerText = new TextRenderable(renderer, {
72
+ id: "footer-text",
73
+ content: footerContent,
74
+ fg: "#64748b",
75
+ });
76
+ parent.add(footerText);
77
+
78
+ footerSeparator = separator;
79
+ footerParent = parent;
80
+ }
81
+
82
+ export function updateFooter(
83
+ renderer: CliRenderer,
84
+ parent: Renderable,
85
+ actions: FooterActions,
86
+ ): void {
87
+ hideFooter(renderer);
88
+ showFooter(renderer, parent, actions);
89
+ }
90
+
91
+ export function hideFooter(_renderer: CliRenderer): void {
92
+ if (footerSeparator) {
93
+ if (footerParent) {
94
+ footerParent.remove("footer-separator");
95
+ }
96
+ footerSeparator = null;
97
+ }
98
+ if (footerText) {
99
+ if (footerParent) {
100
+ footerParent.remove("footer-text");
101
+ }
102
+ footerText = null;
103
+ }
104
+ footerParent = null;
105
+ }
@@ -0,0 +1,95 @@
1
+ import type { KeyEvent } from "@opentui/core";
2
+
3
+ /**
4
+ * Unified keyboard mapping constants for consistent navigation across the TUI.
5
+ *
6
+ * Design principles:
7
+ * - Arrow keys everywhere for navigation
8
+ * - Enter always confirms/selects
9
+ * - Esc always goes back/cancels
10
+ * - Tab moves between fields/sections
11
+ */
12
+
13
+ export const KEYBOARD = {
14
+ // Navigation
15
+ UP: "up",
16
+ DOWN: "down",
17
+ LEFT: "left",
18
+ RIGHT: "right",
19
+ TAB: "tab",
20
+
21
+ // Actions
22
+ ENTER: "enter",
23
+ RETURN: "return",
24
+ ESCAPE: "escape",
25
+
26
+ // Global shortcuts (only on list screens)
27
+ NEW: "n",
28
+ DELETE: "d",
29
+ QUIT: "q",
30
+
31
+ // Text input shortcuts
32
+ BACKSPACE: "backspace",
33
+ CTRL_D: "d", // Ctrl+D (handled via ctrl modifier)
34
+ } as const;
35
+
36
+ /**
37
+ * Check if a key event matches a specific key name
38
+ */
39
+ export function isKey(key: KeyEvent, name: string): boolean {
40
+ return key.name === name;
41
+ }
42
+
43
+ /**
44
+ * Check if a key event matches Enter/Return
45
+ */
46
+ export function isEnter(key: KeyEvent): boolean {
47
+ return key.name === KEYBOARD.ENTER || key.name === KEYBOARD.RETURN;
48
+ }
49
+
50
+ /**
51
+ * Check if a key event matches Escape
52
+ */
53
+ export function isEscape(key: KeyEvent): boolean {
54
+ return key.name === KEYBOARD.ESCAPE;
55
+ }
56
+
57
+ /**
58
+ * Check if a key event matches Tab
59
+ */
60
+ export function isTab(key: KeyEvent): boolean {
61
+ return key.name === KEYBOARD.TAB;
62
+ }
63
+
64
+ /**
65
+ * Check if a key event matches arrow up
66
+ */
67
+ export function isArrowUp(key: KeyEvent): boolean {
68
+ return key.name === KEYBOARD.UP;
69
+ }
70
+
71
+ /**
72
+ * Check if a key event matches arrow down
73
+ */
74
+ export function isArrowDown(key: KeyEvent): boolean {
75
+ return key.name === KEYBOARD.DOWN;
76
+ }
77
+
78
+ /**
79
+ * Check if a key event is Ctrl+D
80
+ */
81
+ export function isCtrlD(key: KeyEvent): boolean {
82
+ return key.name === KEYBOARD.CTRL_D && key.ctrl === true;
83
+ }
84
+
85
+ /**
86
+ * Check if a key event is a navigation key (arrows)
87
+ */
88
+ export function isNavigation(key: KeyEvent): boolean {
89
+ return (
90
+ key.name === KEYBOARD.UP ||
91
+ key.name === KEYBOARD.DOWN ||
92
+ key.name === KEYBOARD.LEFT ||
93
+ key.name === KEYBOARD.RIGHT
94
+ );
95
+ }
@@ -7,6 +7,8 @@ import {
7
7
  } from "@opentui/core";
8
8
  import { createBaseLayout, clearLayout } from "./renderer";
9
9
  import type { Session } from "../types";
10
+ import { showFooter, hideFooter } from "./common/footer";
11
+ import { isEscape } from "./common/keyboard";
10
12
 
11
13
  export type DeleteConfirmChoice = "confirm" | "cancel";
12
14
 
@@ -60,14 +62,6 @@ export function showDeleteConfirm(
60
62
  });
61
63
  content.add(select);
62
64
 
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
65
  select.focus();
72
66
 
73
67
  const handleSelect = (_index: number, option: { value: string }) => {
@@ -76,7 +70,7 @@ export function showDeleteConfirm(
76
70
  };
77
71
 
78
72
  const handleKeypress = (key: KeyEvent) => {
79
- if (key.name === "escape") {
73
+ if (isEscape(key)) {
80
74
  cleanup();
81
75
  resolve("cancel");
82
76
  }
@@ -86,9 +80,16 @@ export function showDeleteConfirm(
86
80
  select.off(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
87
81
  renderer.keyInput.off("keypress", handleKeypress);
88
82
  select.blur();
83
+ hideFooter(renderer);
89
84
  clearLayout(renderer);
90
85
  };
91
86
 
87
+ showFooter(renderer, content, {
88
+ navigate: true,
89
+ select: true,
90
+ back: true,
91
+ });
92
+
92
93
  select.on(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
93
94
  renderer.keyInput.on("keypress", handleKeypress);
94
95
  });
@@ -7,6 +7,8 @@ import {
7
7
  } from "@opentui/core";
8
8
  import { createBaseLayout, clearLayout } from "./renderer";
9
9
  import type { Session, ConflictChoice } from "../types";
10
+ import { showFooter, hideFooter } from "./common/footer";
11
+ import { isEscape } from "./common/keyboard";
10
12
 
11
13
  function formatTimeAgo(date: string): string {
12
14
  const now = new Date();
@@ -102,7 +104,7 @@ export function showConflictPrompt(
102
104
  };
103
105
 
104
106
  const handleKeypress = (key: KeyEvent) => {
105
- if (key.name === "escape") {
107
+ if (isEscape(key)) {
106
108
  cleanup();
107
109
  resolve("cancel");
108
110
  }
@@ -112,9 +114,16 @@ export function showConflictPrompt(
112
114
  select.off(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
113
115
  renderer.keyInput.off("keypress", handleKeypress);
114
116
  select.blur();
117
+ hideFooter(renderer);
115
118
  clearLayout(renderer);
116
119
  };
117
120
 
121
+ showFooter(renderer, content, {
122
+ navigate: true,
123
+ select: true,
124
+ back: true,
125
+ });
126
+
118
127
  select.on(SelectRenderableEvents.ITEM_SELECTED, handleSelect);
119
128
  renderer.keyInput.on("keypress", handleKeypress);
120
129
  });