pacatui 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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/generated/prisma/browser.ts +59 -0
  4. package/generated/prisma/client.ts +81 -0
  5. package/generated/prisma/commonInputTypes.ts +402 -0
  6. package/generated/prisma/enums.ts +15 -0
  7. package/generated/prisma/internal/class.ts +260 -0
  8. package/generated/prisma/internal/prismaNamespace.ts +1362 -0
  9. package/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  10. package/generated/prisma/models/Customer.ts +1489 -0
  11. package/generated/prisma/models/Invoice.ts +1837 -0
  12. package/generated/prisma/models/Project.ts +1981 -0
  13. package/generated/prisma/models/Setting.ts +1086 -0
  14. package/generated/prisma/models/Tag.ts +1288 -0
  15. package/generated/prisma/models/Task.ts +1669 -0
  16. package/generated/prisma/models/TaskTag.ts +1340 -0
  17. package/generated/prisma/models/TimeEntry.ts +1602 -0
  18. package/generated/prisma/models.ts +19 -0
  19. package/package.json +71 -0
  20. package/prisma/migrations/20260115051911_init/migration.sql +71 -0
  21. package/prisma/migrations/20260115062427_add_time_tracking/migration.sql +20 -0
  22. package/prisma/migrations/20260117233250_add_customers_invoices/migration.sql +81 -0
  23. package/prisma/migrations/migration_lock.toml +3 -0
  24. package/prisma/schema.prisma +162 -0
  25. package/src/App.tsx +1492 -0
  26. package/src/components/CreateInvoiceModal.tsx +222 -0
  27. package/src/components/CustomerModal.tsx +158 -0
  28. package/src/components/CustomerSelectModal.tsx +142 -0
  29. package/src/components/Dashboard.tsx +242 -0
  30. package/src/components/DateTimePicker.tsx +335 -0
  31. package/src/components/EditTimeEntryModal.tsx +293 -0
  32. package/src/components/Header.tsx +65 -0
  33. package/src/components/HelpView.tsx +109 -0
  34. package/src/components/InputModal.tsx +79 -0
  35. package/src/components/InvoicesView.tsx +297 -0
  36. package/src/components/Modal.tsx +38 -0
  37. package/src/components/ProjectList.tsx +114 -0
  38. package/src/components/ProjectModal.tsx +116 -0
  39. package/src/components/SettingsView.tsx +145 -0
  40. package/src/components/SplashScreen.tsx +25 -0
  41. package/src/components/StatusBar.tsx +93 -0
  42. package/src/components/TaskList.tsx +143 -0
  43. package/src/components/Timer.tsx +95 -0
  44. package/src/components/TimerModals.tsx +120 -0
  45. package/src/components/TimesheetView.tsx +218 -0
  46. package/src/components/index.ts +17 -0
  47. package/src/db.ts +629 -0
  48. package/src/hooks/usePaste.ts +69 -0
  49. package/src/index.tsx +75 -0
  50. package/src/stripe.ts +163 -0
  51. package/src/types.ts +361 -0
@@ -0,0 +1,297 @@
1
+ import { COLORS } from "../types.ts";
2
+ import type { StripeInvoiceItem } from "../stripe.ts";
3
+
4
+ interface InvoicesViewProps {
5
+ invoices: StripeInvoiceItem[];
6
+ selectedIndex: number;
7
+ focused: boolean;
8
+ loading: boolean;
9
+ error: string | null;
10
+ hasStripeKey: boolean;
11
+ currentPage: number;
12
+ hasMore: boolean;
13
+ hasPrevious: boolean;
14
+ }
15
+
16
+ function formatCurrency(amount: number, currency: string): string {
17
+ return new Intl.NumberFormat("en-US", {
18
+ style: "currency",
19
+ currency: currency.toUpperCase(),
20
+ }).format(amount);
21
+ }
22
+
23
+ function formatDate(date: Date): string {
24
+ return date.toLocaleDateString("en-US", {
25
+ month: "short",
26
+ day: "numeric",
27
+ year: "numeric",
28
+ });
29
+ }
30
+
31
+ function getStatusColor(status: string): string {
32
+ switch (status) {
33
+ case "paid":
34
+ return "#10b981"; // green
35
+ case "open":
36
+ return "#f59e0b"; // amber
37
+ case "draft":
38
+ return "#64748b"; // gray
39
+ case "void":
40
+ return "#ef4444"; // red
41
+ case "uncollectible":
42
+ return "#ef4444"; // red
43
+ default:
44
+ return "#94a3b8";
45
+ }
46
+ }
47
+
48
+ // Column widths for table layout
49
+ const COL = {
50
+ number: 12,
51
+ date: 14,
52
+ customer: 24,
53
+ status: 10,
54
+ amount: 12,
55
+ };
56
+
57
+ export function InvoicesView({
58
+ invoices,
59
+ selectedIndex,
60
+ focused,
61
+ loading,
62
+ error,
63
+ hasStripeKey,
64
+ currentPage,
65
+ hasMore,
66
+ hasPrevious,
67
+ }: InvoicesViewProps) {
68
+ if (!hasStripeKey) {
69
+ return (
70
+ <box
71
+ style={{
72
+ flexGrow: 1,
73
+ flexDirection: "column",
74
+ padding: 1,
75
+ }}
76
+ >
77
+ <box
78
+ style={{
79
+ flexDirection: "row",
80
+ justifyContent: "space-between",
81
+ }}
82
+ >
83
+ <text fg="#ffffff">Invoices</text>
84
+ </box>
85
+ <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
86
+ <box
87
+ style={{
88
+ flexGrow: 1,
89
+ justifyContent: "center",
90
+ alignItems: "center",
91
+ }}
92
+ >
93
+ <text fg="#ef4444">No Stripe API key configured.</text>
94
+ <text fg="#64748b">Add one in Settings to view invoices.</text>
95
+ </box>
96
+ </box>
97
+ );
98
+ }
99
+
100
+ if (loading) {
101
+ return (
102
+ <box
103
+ style={{
104
+ flexGrow: 1,
105
+ flexDirection: "column",
106
+ padding: 1,
107
+ }}
108
+ >
109
+ <box
110
+ style={{
111
+ flexDirection: "row",
112
+ justifyContent: "space-between",
113
+ }}
114
+ >
115
+ <text fg="#ffffff">Invoices</text>
116
+ </box>
117
+ <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
118
+ <box
119
+ style={{
120
+ flexGrow: 1,
121
+ justifyContent: "center",
122
+ alignItems: "center",
123
+ }}
124
+ >
125
+ <text fg="#64748b">Loading invoices from Stripe...</text>
126
+ </box>
127
+ </box>
128
+ );
129
+ }
130
+
131
+ if (error) {
132
+ return (
133
+ <box
134
+ style={{
135
+ flexGrow: 1,
136
+ flexDirection: "column",
137
+ padding: 1,
138
+ }}
139
+ >
140
+ <box
141
+ style={{
142
+ flexDirection: "row",
143
+ justifyContent: "space-between",
144
+ }}
145
+ >
146
+ <text fg="#ffffff">Invoices</text>
147
+ </box>
148
+ <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
149
+ <box
150
+ style={{
151
+ flexGrow: 1,
152
+ justifyContent: "center",
153
+ alignItems: "center",
154
+ }}
155
+ >
156
+ <text fg="#ef4444">Error: {error}</text>
157
+ </box>
158
+ </box>
159
+ );
160
+ }
161
+
162
+ if (invoices.length === 0) {
163
+ return (
164
+ <box
165
+ style={{
166
+ flexGrow: 1,
167
+ flexDirection: "column",
168
+ padding: 1,
169
+ }}
170
+ >
171
+ <box
172
+ style={{
173
+ flexDirection: "row",
174
+ justifyContent: "space-between",
175
+ }}
176
+ >
177
+ <text fg="#ffffff">Invoices</text>
178
+ </box>
179
+ <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
180
+ <box
181
+ style={{
182
+ flexGrow: 1,
183
+ justifyContent: "center",
184
+ alignItems: "center",
185
+ }}
186
+ >
187
+ <text fg="#64748b">No invoices found in Stripe</text>
188
+ </box>
189
+ </box>
190
+ );
191
+ }
192
+
193
+ return (
194
+ <box
195
+ style={{
196
+ flexGrow: 1,
197
+ flexDirection: "column",
198
+ padding: 1,
199
+ }}
200
+ >
201
+ {/* Header */}
202
+ <box
203
+ style={{
204
+ flexDirection: "row",
205
+ justifyContent: "space-between",
206
+ }}
207
+ >
208
+ <text fg="#ffffff">Invoices</text>
209
+ <text fg="#94a3b8">
210
+ Page {currentPage} {hasMore && "| ] next"} {hasPrevious && "| [ prev"}
211
+ </text>
212
+ </box>
213
+ <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
214
+
215
+ {/* Column Headers */}
216
+ <box
217
+ style={{
218
+ flexDirection: "row",
219
+ paddingLeft: 1,
220
+ paddingRight: 1,
221
+ }}
222
+ >
223
+ <box style={{ width: COL.number }}>
224
+ <text fg="#64748b">Invoice #</text>
225
+ </box>
226
+ <box style={{ width: COL.date }}>
227
+ <text fg="#64748b">Date</text>
228
+ </box>
229
+ <box style={{ width: COL.customer }}>
230
+ <text fg="#64748b">Customer</text>
231
+ </box>
232
+ <box style={{ width: COL.status }}>
233
+ <text fg="#64748b">Status</text>
234
+ </box>
235
+ <box style={{ width: COL.amount, alignItems: "flex-end" }}>
236
+ <text fg="#64748b">Amount</text>
237
+ </box>
238
+ </box>
239
+ <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
240
+
241
+ {/* Invoice List */}
242
+ <scrollbox focused={focused} style={{ flexGrow: 1 }}>
243
+ {invoices.map((invoice, index) => {
244
+ const isSelected = index === selectedIndex;
245
+
246
+ return (
247
+ <box
248
+ key={invoice.id}
249
+ style={{
250
+ flexDirection: "row",
251
+ backgroundColor: isSelected
252
+ ? COLORS.selectedRowBg
253
+ : "transparent",
254
+ paddingLeft: 1,
255
+ paddingRight: 1,
256
+ }}
257
+ >
258
+ {/* Invoice Number */}
259
+ <box style={{ width: COL.number }}>
260
+ <text fg={isSelected ? "#ffffff" : "#e2e8f0"}>
261
+ {invoice.number || invoice.id.slice(-8)}
262
+ </text>
263
+ </box>
264
+ {/* Date */}
265
+ <box style={{ width: COL.date }}>
266
+ <text fg="#94a3b8">{formatDate(invoice.created)}</text>
267
+ </box>
268
+ {/* Customer */}
269
+ <box style={{ width: COL.customer }}>
270
+ <text fg={isSelected ? "#ffffff" : "#e2e8f0"}>
271
+ {(invoice.customerName || invoice.customerEmail || "Unknown")
272
+ .slice(0, COL.customer - 2)}
273
+ </text>
274
+ </box>
275
+ {/* Status */}
276
+ <box style={{ width: COL.status }}>
277
+ <text fg={getStatusColor(invoice.status)}>
278
+ {invoice.status}
279
+ </text>
280
+ </box>
281
+ {/* Amount */}
282
+ <box style={{ width: COL.amount, alignItems: "flex-end" }}>
283
+ <text fg="#10b981">
284
+ {formatCurrency(invoice.amount, invoice.currency)}
285
+ </text>
286
+ </box>
287
+ </box>
288
+ );
289
+ })}
290
+ </scrollbox>
291
+
292
+ {/* Footer */}
293
+ <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
294
+ <text fg="#64748b">Enter to open in browser | r to refresh</text>
295
+ </box>
296
+ );
297
+ }
@@ -0,0 +1,38 @@
1
+ import { COLORS } from "../types";
2
+
3
+ interface ProjectModalProps {
4
+ title: string;
5
+ height: number;
6
+ children: any;
7
+ }
8
+
9
+ export default function ProjectModal({
10
+ title,
11
+ height = 10,
12
+ children,
13
+ }: ProjectModalProps) {
14
+ return (
15
+ <box
16
+ style={{
17
+ position: "absolute",
18
+ top: "50%",
19
+ left: "50%",
20
+ width: 60,
21
+ height,
22
+ marginTop: -Math.floor(height / 2),
23
+ marginLeft: -30,
24
+ border: true,
25
+ borderColor: COLORS.border,
26
+ flexDirection: "column",
27
+ backgroundColor: COLORS.bg,
28
+ padding: 1,
29
+ zIndex: 99999,
30
+ }}
31
+ >
32
+ <text fg="#ffffff" attributes="bold">
33
+ {title}
34
+ </text>
35
+ {children}
36
+ </box>
37
+ );
38
+ }
@@ -0,0 +1,114 @@
1
+ import type { ProjectWithTaskCounts } from "../types.ts";
2
+ import { COLORS } from "../types";
3
+
4
+ interface ProjectListProps {
5
+ projects: ProjectWithTaskCounts[];
6
+ selectedIndex: number;
7
+ focused: boolean;
8
+ showArchived: boolean;
9
+ }
10
+
11
+ export function ProjectList({
12
+ projects,
13
+ selectedIndex,
14
+ focused,
15
+ showArchived,
16
+ }: ProjectListProps) {
17
+ const getTaskStats = (project: ProjectWithTaskCounts) => {
18
+ const total = project.tasks.length;
19
+ const done = project.tasks.filter((t) => t.status === "done").length;
20
+ const inProgress = project.tasks.filter(
21
+ (t) => t.status === "in_progress",
22
+ ).length;
23
+ return { total, done, inProgress };
24
+ };
25
+
26
+ const projectList = projects.sort((a, b) => {
27
+ return a.name.toLowerCase() + b.name.toLowerCase();
28
+ });
29
+
30
+ return (
31
+ <box
32
+ title={`Projects${showArchived ? " (incl. archived)" : ""}`}
33
+ style={{
34
+ border: true,
35
+ borderColor: focused ? COLORS.border : COLORS.borderOff,
36
+ flexGrow: 1,
37
+ flexDirection: "column",
38
+ }}
39
+ >
40
+ {projectList.length === 0 ? (
41
+ <box
42
+ style={{
43
+ flexGrow: 1,
44
+ alignItems: "center",
45
+ justifyContent: "center",
46
+ }}
47
+ >
48
+ <text fg="#64748b">No projects yet</text>
49
+ <text fg="#475569">Press 'n' to create one</text>
50
+ </box>
51
+ ) : (
52
+ <scrollbox focused={focused} style={{ flexGrow: 1 }}>
53
+ {projectList.map((project, index) => {
54
+ const isSelected = index === selectedIndex;
55
+ const stats = getTaskStats(project);
56
+
57
+ // Dim highlight when not focused, bright when focused
58
+ const bgColor = isSelected
59
+ ? focused
60
+ ? COLORS.selectedRowBg
61
+ : "#252560" // Muted version of selectedRowBg
62
+ : "transparent";
63
+
64
+ return (
65
+ <box
66
+ key={project.id}
67
+ style={{
68
+ width: "100%",
69
+ paddingLeft: 1,
70
+ paddingRight: 1,
71
+ backgroundColor: bgColor,
72
+ }}
73
+ >
74
+ <box
75
+ style={{
76
+ flexDirection: "row",
77
+ justifyContent: "space-between",
78
+ }}
79
+ >
80
+ <text>
81
+ <span fg={project.color}>
82
+ {project.archived ? "[ ] " : "[*] "}
83
+ </span>
84
+ <span
85
+ fg={isSelected ? "#ffffff" : "#e2e8f0"}
86
+ attributes={isSelected ? "bold" : undefined}
87
+ >
88
+ {project.name}
89
+ </span>
90
+ {project.archived && <span fg="#64748b"> (archived)</span>}
91
+ {project.hourlyRate != null && (
92
+ <span fg="#10b981"> ${project.hourlyRate}/hr</span>
93
+ )}
94
+ </text>
95
+ <text>
96
+ <span fg="#10b981">{stats.done}</span>
97
+ <span fg="#64748b">/</span>
98
+ <span fg="#94a3b8">{stats.total}</span>
99
+ </text>
100
+ </box>
101
+ {project.description && (
102
+ <text fg="#64748b" style={{ paddingLeft: 4 }}>
103
+ {project.description.slice(0, 40)}
104
+ {project.description.length > 40 ? "..." : ""}
105
+ </text>
106
+ )}
107
+ </box>
108
+ );
109
+ })}
110
+ </scrollbox>
111
+ )}
112
+ </box>
113
+ );
114
+ }
@@ -0,0 +1,116 @@
1
+ import { useState } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { COLORS } from "../types";
4
+ import Modal from "./Modal";
5
+ import { useMultiPaste } from "../hooks/usePaste";
6
+
7
+ interface ProjectModalProps {
8
+ mode: "create" | "edit";
9
+ initialName?: string;
10
+ initialRate?: number | null;
11
+ onSubmit: (name: string, rate: number | null) => void;
12
+ onCancel: () => void;
13
+ }
14
+
15
+ export function ProjectModal({
16
+ mode,
17
+ initialName = "",
18
+ initialRate = null,
19
+ onSubmit,
20
+ onCancel,
21
+ }: ProjectModalProps) {
22
+ const [name, setName] = useState(initialName);
23
+ const [rate, setRate] = useState(initialRate?.toString() ?? "");
24
+ const [activeField, setActiveField] = useState<"name" | "rate">("name");
25
+ const { registerInput } = useMultiPaste();
26
+
27
+ useKeyboard((key) => {
28
+ if (key.name === "escape") {
29
+ onCancel();
30
+ return;
31
+ }
32
+ if (key.name === "tab" || key.name === "down" || key.name === "up") {
33
+ setActiveField((f) => (f === "name" ? "rate" : "name"));
34
+ return;
35
+ }
36
+ });
37
+
38
+ const handleSubmit = () => {
39
+ if (!name.trim()) return;
40
+ const parsedRate = rate.trim() === "" ? null : parseFloat(rate);
41
+ const validRate =
42
+ parsedRate !== null && !isNaN(parsedRate) && parsedRate >= 0
43
+ ? parsedRate
44
+ : null;
45
+ onSubmit(name.trim(), validRate);
46
+ };
47
+
48
+ return (
49
+ <Modal
50
+ title={mode === "create" ? "Create New Project" : "Edit Project"}
51
+ height={14}
52
+ >
53
+ <box
54
+ onClick={() => setActiveField("name")}
55
+ style={{
56
+ flexDirection: "column",
57
+ marginTop: 1,
58
+ }}
59
+ >
60
+ <box>
61
+ <text fg="#94a3b8">Name</text>
62
+ </box>
63
+ <box
64
+ style={{
65
+ border: true,
66
+ borderColor:
67
+ activeField === "name" ? COLORS.border : COLORS.borderOff,
68
+ height: 3,
69
+ width: "100%",
70
+ }}
71
+ >
72
+ <input
73
+ ref={registerInput("name")}
74
+ placeholder="Project name..."
75
+ focused={activeField === "name"}
76
+ onInput={setName}
77
+ onSubmit={handleSubmit}
78
+ value={name}
79
+ />
80
+ </box>
81
+ </box>
82
+
83
+ <box
84
+ onClick={() => setActiveField("rate")}
85
+ style={{
86
+ flexDirection: "column",
87
+ marginTop: 1,
88
+ }}
89
+ >
90
+ <box>
91
+ <text fg="#94a3b8" style={{ minWidth: 12 }}>
92
+ Hourly Rate
93
+ </text>
94
+ </box>
95
+ <box
96
+ style={{
97
+ border: true,
98
+ borderColor:
99
+ activeField === "rate" ? COLORS.border : COLORS.borderOff,
100
+ height: 3,
101
+ width: "100%",
102
+ }}
103
+ >
104
+ <input
105
+ ref={registerInput("rate")}
106
+ placeholder="e.g. 75.00 (optional)"
107
+ focused={activeField === "rate"}
108
+ onInput={setRate}
109
+ onSubmit={handleSubmit}
110
+ value={rate}
111
+ />
112
+ </box>
113
+ </box>
114
+ </Modal>
115
+ );
116
+ }
@@ -0,0 +1,145 @@
1
+ import { COLORS, getSystemTimezone } from "../types.ts";
2
+ import type { AppSettings } from "../types.ts";
3
+
4
+ interface SettingsViewProps {
5
+ settings: AppSettings;
6
+ selectedIndex: number;
7
+ onEditBusinessName: () => void;
8
+ onEditStripeKey: () => void;
9
+ onEditTimezone: () => void;
10
+ onExportDatabase: () => void;
11
+ onImportDatabase: () => void;
12
+ }
13
+
14
+ const SETTINGS_ITEMS = [
15
+ { key: "businessName", label: "Business Name", type: "text" },
16
+ { key: "stripeApiKey", label: "Stripe API Key", type: "secret" },
17
+ { key: "timezone", label: "Timezone", type: "text" },
18
+ { key: "exportDatabase", label: "Export Database", type: "action" },
19
+ { key: "importDatabase", label: "Import Database", type: "action" },
20
+ ] as const;
21
+
22
+ export function SettingsView({
23
+ settings,
24
+ selectedIndex,
25
+ onEditBusinessName,
26
+ onEditStripeKey,
27
+ onEditTimezone,
28
+ onExportDatabase,
29
+ onImportDatabase,
30
+ }: SettingsViewProps) {
31
+ const maskSecret = (value: string) => {
32
+ if (!value) return "(not set)";
33
+ if (value.length <= 8) return "*".repeat(value.length);
34
+ return value.slice(0, 4) + "*".repeat(value.length - 8) + value.slice(-4);
35
+ };
36
+
37
+ const formatTimezone = (tz: string) => {
38
+ if (!tz || tz === "auto") {
39
+ return `Auto (${getSystemTimezone()})`;
40
+ }
41
+ return tz;
42
+ };
43
+
44
+ const getValue = (key: string) => {
45
+ switch (key) {
46
+ case "businessName":
47
+ return settings.businessName || "(not set)";
48
+ case "stripeApiKey":
49
+ return maskSecret(settings.stripeApiKey);
50
+ case "timezone":
51
+ return formatTimezone(settings.timezone);
52
+ case "exportDatabase":
53
+ return "Export to file...";
54
+ case "importDatabase":
55
+ return "Import from file...";
56
+ default:
57
+ return "";
58
+ }
59
+ };
60
+
61
+ const getAction = (key: string) => {
62
+ switch (key) {
63
+ case "businessName":
64
+ return onEditBusinessName;
65
+ case "stripeApiKey":
66
+ return onEditStripeKey;
67
+ case "timezone":
68
+ return onEditTimezone;
69
+ case "exportDatabase":
70
+ return onExportDatabase;
71
+ case "importDatabase":
72
+ return onImportDatabase;
73
+ default:
74
+ return () => {};
75
+ }
76
+ };
77
+
78
+ return (
79
+ <box
80
+ style={{
81
+ flexDirection: "column",
82
+ flexGrow: 1,
83
+ padding: 1,
84
+ }}
85
+ >
86
+ <box style={{ marginBottom: 1 }}>
87
+ <text fg="#64748b">Application Settings</text>
88
+ </box>
89
+
90
+ <box
91
+ title="Settings"
92
+ style={{
93
+ border: true,
94
+ borderColor: "#334155",
95
+ padding: 1,
96
+ flexDirection: "column",
97
+ }}
98
+ >
99
+ {SETTINGS_ITEMS.map((item, index) => {
100
+ const isSelected = index === selectedIndex;
101
+ return (
102
+ <box
103
+ key={item.key}
104
+ style={{
105
+ flexDirection: "row",
106
+ paddingLeft: 1,
107
+ paddingRight: 1,
108
+ backgroundColor: isSelected ? COLORS.selectedRowBg : "transparent",
109
+ }}
110
+ >
111
+ <box style={{ width: 20 }}>
112
+ <text
113
+ fg={isSelected ? "#ffffff" : "#e2e8f0"}
114
+ attributes={isSelected ? "bold" : undefined}
115
+ >
116
+ {item.label}
117
+ </text>
118
+ </box>
119
+ <box style={{ flexGrow: 1 }}>
120
+ <text fg={isSelected ? "#ffffff" : "#94a3b8"}>
121
+ {getValue(item.key)}
122
+ </text>
123
+ </box>
124
+ {isSelected && (
125
+ <text fg="#64748b">[Enter to {item.type === "action" ? "run" : "edit"}]</text>
126
+ )}
127
+ </box>
128
+ );
129
+ })}
130
+ </box>
131
+
132
+ <box style={{ marginTop: 2 }}>
133
+ <text fg="#475569">
134
+ Use j/k or arrows to navigate, Enter to select, Esc to go back
135
+ </text>
136
+ </box>
137
+
138
+ <box style={{ marginTop: 1 }}>
139
+ <text fg="#475569">Database location: ~/.paca/paca.db</text>
140
+ </box>
141
+ </box>
142
+ );
143
+ }
144
+
145
+ export const SETTINGS_COUNT = SETTINGS_ITEMS.length;