kcg-code 0.1.0 → 0.2.1

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 (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. package/src/components/projects/PanelSection.tsx +0 -102
@@ -5,25 +5,234 @@
5
5
  */
6
6
  import { networkInterfaces } from "node:os";
7
7
  import { Box, render, renderToString, Text, useInput } from "ink";
8
- import { type ReactNode, useEffect, useReducer } from "react";
8
+ import type { ReactNode } from "react";
9
+ import { renderUnicodeCompact } from "uqr";
9
10
  import type { KcgServer } from "../server/app";
11
+ import { InkReact } from "./ink-react";
12
+ import type { KeepAwake } from "./keep-awake";
10
13
  import { logoLines, readPackageVersion } from "./theme";
11
14
 
15
+ // Hook wajib dari instance React milik Ink — lihat ink-react.ts.
16
+ const { useEffect, useReducer, useRef, useState } = InkReact;
17
+
18
+ export type TunnelProviderId = "kcg" | "lhr";
19
+
20
+ interface KcgTunnelStatus {
21
+ phase: string;
22
+ account?: { url: string } | null;
23
+ pairing?: {
24
+ userCode: string;
25
+ verificationUriComplete: string;
26
+ } | null;
27
+ error?: string | null;
28
+ }
29
+
30
+ interface LhrTunnelStatus {
31
+ phase: string;
32
+ url?: string | null;
33
+ error?: string | null;
34
+ enabled?: boolean;
35
+ }
36
+
12
37
  export interface DashboardInfo {
13
- server: KcgServer;
38
+ server: KcgServer & {
39
+ lhr?: { status: () => LhrTunnelStatus };
40
+ tunnelProvider?: () => TunnelProviderId;
41
+ };
14
42
  port: number;
15
43
  hostname: string;
44
+ /** Status pencegah sleep; tidak ada = baris "Awake" disembunyikan. */
45
+ keepAwake?: Pick<KeepAwake, "status">;
46
+ /** Catatan port saat startup, mis. "3000 in use" (port otomatis diganti). */
47
+ portNote?: string | null;
48
+ /** true selama server di-restart (ganti port) — store lama sudah ditutup. */
49
+ restarting?: boolean;
50
+ /** Waktu server mulai (untuk uptime). */
51
+ startedAt?: number;
52
+ }
53
+
54
+ /**
55
+ * Mode dashboard:
56
+ * - `foreground`: server jalan di proses ini; q menghentikan server.
57
+ * - `background`: dashboard memantau daemon lewat `daemon.json`; q hanya
58
+ * menutup dashboard, server tetap jalan.
59
+ */
60
+ export type DashboardMode = "foreground" | "background";
61
+
62
+ /**
63
+ * Data siap-tampil dashboard. Dihitung dari server lokal (`takeSnapshot`)
64
+ * atau dibaca dari state daemon — UI tidak menyentuh server langsung.
65
+ */
66
+ export interface DashboardSnapshot {
67
+ mode: DashboardMode;
68
+ port: number;
69
+ pid: number;
70
+ memory: string;
71
+ startedAt: number;
72
+ localUrl: string;
73
+ networkUrl: string | null;
74
+ /** URL terbaik untuk HP (QR / Copy). */
75
+ target: { label: string; url: string };
76
+ publicUrl: string | null;
77
+ tunnel: { text: string; color: string };
78
+ pairingUri: string | null;
79
+ tunnelDetail: {
80
+ provider: TunnelProviderId | null;
81
+ kcg: { phase: string; url: string | null };
82
+ lhr: { phase: string; url: string | null } | null;
83
+ };
84
+ keepAwake: { text: string; active: boolean } | null;
85
+ sessions: number;
86
+ running: number;
87
+ projects: number;
88
+ portNote: string | null;
89
+ restarting: boolean;
90
+ /** Teks status selama `restarting`, mis. "Stopping…". Default "Restarting…". */
91
+ busyLabel?: string | null;
16
92
  }
17
93
 
18
94
  export interface DashboardCallbacks {
19
- /** Panggil saat user minta berhenti (q / Ctrl+C). */
95
+ /** q / Ctrl+C. Foreground: hentikan server. Background: tutup dashboard saja. */
20
96
  onQuit: (reason: string) => void;
21
97
  /** Buka URL dashboard di browser default. */
22
98
  onOpenUrl: () => void | Promise<void>;
99
+ /** Simpan port ke config & restart server. Resolve null = sukses, string = error. */
100
+ onChangePort: (port: number) => Promise<string | null>;
101
+ /** Foreground: pindahkan server ke background (daemon) lalu keluar. */
102
+ onBackground: () => void;
103
+ /** Background: hentikan daemon. */
104
+ onStop: () => void;
105
+ /** Background: hentikan daemon lalu jalankan server di terminal ini. */
106
+ onForeground: () => void;
23
107
  }
24
108
 
25
- const REFRESH_MS = 2000;
109
+ const REFRESH_MS = 1000;
26
110
  const PAD = 2;
111
+ const FLASH_MS = 2500;
112
+ const COMPACT_BELOW = 64;
113
+ const CONFIRM_MS = 3000;
114
+ /** Lebar kolom label di card status. */
115
+ const LABEL_W = 8;
116
+
117
+ /** State tampilan dinamis; semua opsional untuk render statis. */
118
+ export interface DashboardViewState {
119
+ showQr?: boolean;
120
+ showDetail?: boolean;
121
+ /** Index menu yang disorot (0..menuItems().length-1). */
122
+ menuIndex?: number;
123
+ flash?: string | null;
124
+ /** Menunggu tekan kedua untuk aksi yang memutus session running. */
125
+ confirm?: ConfirmAction | null;
126
+ /** Isi input port saat mode "Change port" aktif; null = tidak sedang mengisi. */
127
+ portInput?: string | null;
128
+ portError?: string | null;
129
+ }
130
+
131
+ export type ConfirmAction = "quit" | "background" | "stop" | "foreground";
132
+
133
+ export interface MenuItem {
134
+ id: "open" | "copy" | "qr" | "detail" | "port" | "background" | "foreground" | "stop" | "quit";
135
+ key: string;
136
+ label: string;
137
+ }
138
+
139
+ export function menuItems(
140
+ view: Pick<DashboardViewState, "showQr" | "showDetail"> = {},
141
+ mode: DashboardMode = "foreground",
142
+ ): MenuItem[] {
143
+ const common: MenuItem[] = [
144
+ { id: "open", key: "o", label: "Open in browser" },
145
+ { id: "copy", key: "c", label: "Copy URL" },
146
+ { id: "qr", key: "m", label: view.showQr ? "Hide phone QR" : "Show phone QR" },
147
+ {
148
+ id: "detail",
149
+ key: "t",
150
+ label: view.showDetail ? "Hide tunnel detail" : "Tunnel detail",
151
+ },
152
+ { id: "port", key: "p", label: "Change port" },
153
+ ];
154
+ if (mode === "background") {
155
+ return [
156
+ ...common,
157
+ { id: "foreground", key: "f", label: "Run in foreground" },
158
+ { id: "stop", key: "s", label: "Stop server" },
159
+ { id: "quit", key: "q", label: "Close dashboard" },
160
+ ];
161
+ }
162
+ return [
163
+ ...common,
164
+ { id: "background", key: "b", label: "Run in background" },
165
+ { id: "quit", key: "q", label: "Quit" },
166
+ ];
167
+ }
168
+
169
+ export function formatUptime(ms: number): string {
170
+ const s = Math.max(0, Math.floor(ms / 1000));
171
+ const h = Math.floor(s / 3600);
172
+ const m = Math.floor((s % 3600) / 60);
173
+ const sec = s % 60;
174
+ if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
175
+ if (m > 0) return `${m}m ${String(sec).padStart(2, "0")}s`;
176
+ return `${sec}s`;
177
+ }
178
+
179
+ /** Provider tunnel terpilih ("kcg" | "lhr"); null bila tak diketahui. */
180
+ export function activeProvider(info: DashboardInfo): TunnelProviderId | null {
181
+ try {
182
+ const fn = info.server.tunnelProvider;
183
+ if (typeof fn === "function") return fn.call(info.server);
184
+ const viaStore = (
185
+ info.server as unknown as {
186
+ store?: { getTunnelSettings?: (now: number) => { provider: TunnelProviderId } };
187
+ }
188
+ ).store?.getTunnelSettings?.(Date.now())?.provider;
189
+ if (viaStore === "kcg" || viaStore === "lhr") return viaStore;
190
+ } catch {
191
+ // abaikan — fallback di bawah
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function kcgStatus(info: DashboardInfo): KcgTunnelStatus {
197
+ try {
198
+ return info.server.tunnel.status() as unknown as KcgTunnelStatus;
199
+ } catch {
200
+ return { phase: "error", account: null, error: "status gagal dibaca" };
201
+ }
202
+ }
203
+
204
+ function lhrStatus(info: DashboardInfo): LhrTunnelStatus | null {
205
+ try {
206
+ const lhr = info.server.lhr;
207
+ if (!lhr) return null;
208
+ return lhr.status();
209
+ } catch {
210
+ return { phase: "error", url: null, error: "status gagal dibaca" };
211
+ }
212
+ }
213
+
214
+ /** URL terbaik untuk dibuka dari HP: publik aktif > publik lain > LAN > local. */
215
+ export function bestUrl(info: DashboardInfo): { label: string; url: string } {
216
+ const provider = activeProvider(info);
217
+ const kcg = kcgStatus(info);
218
+ const lhr = lhrStatus(info);
219
+ const kcgUrl = kcg.account && kcg.phase === "online" ? kcg.account.url : null;
220
+ const lhrUrl = lhr && lhr.phase === "online" && lhr.url ? lhr.url : null;
221
+
222
+ if (provider === "lhr" && lhrUrl) return { label: "public (lhr)", url: lhrUrl };
223
+ if (provider === "kcg" && kcgUrl) return { label: "public (kcg)", url: kcgUrl };
224
+ // Provider tak diketahui / belum online: pakai mana pun yang online.
225
+ if (kcgUrl) return { label: "public (kcg)", url: kcgUrl };
226
+ if (lhrUrl) return { label: "public (lhr)", url: lhrUrl };
227
+ const urls = localUrls(info.hostname, info.port);
228
+ return urls.find((u) => u.label === "network") ?? (urls[0] as { label: string; url: string });
229
+ }
230
+
231
+ /** Salin teks ke clipboard (OSC 52 — didukung sebagian besar terminal modern). */
232
+ export function copyToClipboard(text: string): void {
233
+ const b64 = Buffer.from(text, "utf8").toString("base64");
234
+ process.stdout.write(`\u001b]52;c;${b64}\u0007`);
235
+ }
27
236
 
28
237
  function localUrls(hostname: string, port: number): { label: string; url: string }[] {
29
238
  const urls: { label: string; url: string }[] = [];
@@ -45,44 +254,181 @@ function localUrls(hostname: string, port: number): { label: string; url: string
45
254
  }
46
255
 
47
256
  function activityStats(app: KcgServer) {
48
- const projects = app.store.listProjects().length;
49
- const sessions = app.store.listSessions();
50
- const running = sessions.filter((s) => s.status === "running");
51
- return { projects, sessions: sessions.length, running };
257
+ try {
258
+ const projects = app.store.listProjects().length;
259
+ const sessions = app.store.listSessions();
260
+ const running = sessions.filter((s) => s.status === "running");
261
+ return { projects, sessions: sessions.length, running };
262
+ } catch {
263
+ // Store sedang ditutup (restart ganti port).
264
+ return { projects: 0, sessions: 0, running: [] };
265
+ }
52
266
  }
53
267
 
54
- function Card({ title, children }: { title: string; children: ReactNode }) {
55
- return (
56
- <Box borderStyle="round" borderColor="cyan" flexDirection="column" paddingX={1} gap={0}>
57
- <Text color="cyan" bold>
58
- {title}
59
- </Text>
60
- <Box flexDirection="column" gap={0}>
61
- {children}
62
- </Box>
63
- </Box>
64
- );
268
+ function formatMemory(): string {
269
+ const rss = process.memoryUsage().rss / 1024 / 1024;
270
+ if (rss >= 1024) return `${(rss / 1024).toFixed(1)} GB`;
271
+ return `${rss.toFixed(1)} MB`;
272
+ }
273
+
274
+ /** Ringkasan satu baris tunnel aktif: "lhr · online" / "kcg · off" / pairing. URL publik punya baris sendiri. */
275
+ function tunnelSummary(info: DashboardInfo): { text: string; color: string } {
276
+ const provider = activeProvider(info);
277
+ const kcg = kcgStatus(info);
278
+ const lhr = lhrStatus(info);
279
+ if (kcg.pairing && !(kcg.phase === "online" && kcg.account)) {
280
+ return { text: `pairing code ${kcg.pairing.userCode}`, color: "yellow" };
281
+ }
282
+ const active = provider === "lhr" ? lhr : kcg.account ? kcg : lhr;
283
+ if (!active) return { text: `${provider ?? "kcg"} · off`, color: "gray" };
284
+ if (provider === "lhr" && lhr) {
285
+ if (lhr.phase === "online" && lhr.url) return { text: "lhr · online", color: "green" };
286
+ return { text: `lhr · ${lhr.phase === "stopped" ? "off" : lhr.phase}`, color: "gray" };
287
+ }
288
+ if (kcg.phase === "online" && kcg.account?.url) return { text: "kcg · online", color: "green" };
289
+ if (kcg.error && provider !== "lhr") return { text: `kcg · ${kcg.error}`, color: "red" };
290
+ return {
291
+ text: `kcg · ${kcg.phase === "stopped" || kcg.phase === "signed_out" ? "off" : kcg.phase}`,
292
+ color: "gray",
293
+ };
294
+ }
295
+
296
+ /** URL tanpa skema — lebih ringkas untuk hint menu. */
297
+ function shortUrl(url: string): string {
298
+ return url.replace(/^https?:\/\//, "");
65
299
  }
66
300
 
67
- function Key({ children }: { children: string }) {
68
- return <Text dimColor>{children}</Text>;
301
+ function keepAwakeText(keepAwake: Pick<KeepAwake, "status">): { text: string; active: boolean } {
302
+ const st = keepAwake.status();
303
+ return {
304
+ active: st.active,
305
+ text: st.active
306
+ ? `on · sleep blocked (${st.method})`
307
+ : `off${st.error ? ` · ${st.error}` : ""}`,
308
+ };
69
309
  }
70
310
 
71
- function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
311
+ /** Hitung data tampilan dari server yang berjalan di proses ini. */
312
+ export function takeSnapshot(
313
+ info: DashboardInfo,
314
+ mode: DashboardMode = "foreground",
315
+ ): DashboardSnapshot {
316
+ const urls = localUrls(info.hostname, info.port);
317
+ const { projects, sessions, running } = activityStats(info.server);
318
+ const target = bestUrl(info);
319
+ const kcg = kcgStatus(info);
320
+ const lhr = lhrStatus(info);
321
+ const tunnel = tunnelSummary(info);
322
+ return {
323
+ mode,
324
+ port: info.port,
325
+ pid: process.pid,
326
+ memory: formatMemory(),
327
+ startedAt: info.startedAt ?? Date.now(),
328
+ localUrl: urls[0]?.url ?? target.url,
329
+ networkUrl: urls.find((u) => u.label === "network")?.url ?? null,
330
+ target,
331
+ publicUrl: target.label.startsWith("public") ? target.url : null,
332
+ tunnel,
333
+ pairingUri:
334
+ kcg.pairing && tunnel.color === "yellow" ? kcg.pairing.verificationUriComplete : null,
335
+ tunnelDetail: {
336
+ provider: activeProvider(info),
337
+ kcg: { phase: kcg.phase, url: kcg.account?.url ?? null },
338
+ lhr: lhr ? { phase: lhr.phase, url: lhr.url ?? null } : null,
339
+ },
340
+ keepAwake: info.keepAwake ? keepAwakeText(info.keepAwake) : null,
341
+ sessions,
342
+ running: running.length,
343
+ projects,
344
+ portNote: info.portNote ?? null,
345
+ restarting: Boolean(info.restarting),
346
+ };
347
+ }
348
+
349
+ /** Deskripsi kanan tiap item menu. */
350
+ function menuHint(id: MenuItem["id"], snap: DashboardSnapshot): string {
351
+ const running = snap.running;
352
+ if (id === "open") return shortUrl(snap.localUrl);
353
+ if (id === "copy") return `${snap.target.label} URL`;
354
+ if (id === "qr") return "scan from your phone";
355
+ if (id === "detail") return snap.tunnel.text;
356
+ if (id === "port") return `current ${snap.port}`;
357
+ if (id === "background") return "keeps running after terminal closes";
358
+ if (id === "foreground") return "move the server into this terminal";
359
+ if (id === "stop")
360
+ return running > 0 ? `stop server · ${running} running` : "stop the background server";
361
+ if (snap.mode === "background") return "server keeps running";
362
+ return running > 0 ? `stop server · ${running} running` : "stop server & exit";
363
+ }
364
+
365
+ const plural = (n: number) => `${n} session${n === 1 ? "" : "s"}`;
366
+
367
+ const CONFIRM_TEXT: Record<ConfirmAction, (n: number) => string> = {
368
+ quit: (n) => `! ${plural(n)} running — press q again to stop`,
369
+ stop: (n) => `! ${plural(n)} running — press s again to stop`,
370
+ background: (n) => `! ${plural(n)} running will be interrupted — press b again`,
371
+ foreground: (n) => `! ${plural(n)} running will be interrupted — press f again`,
372
+ };
373
+
374
+ function Card({
375
+ title,
376
+ badge,
377
+ badgeColor = "green",
378
+ width,
379
+ children,
380
+ }: {
381
+ title: string;
382
+ badge?: string;
383
+ badgeColor?: string;
384
+ width?: number;
385
+ children: ReactNode;
386
+ }) {
72
387
  return (
73
- <Box gap={1}>
74
- <Text dimColor>{label.padEnd(10)}</Text>
75
- <Text bold color={accent ? "green" : undefined}>
76
- {value}
77
- </Text>
388
+ <Box
389
+ width={width}
390
+ borderStyle="round"
391
+ borderColor="gray"
392
+ flexDirection="column"
393
+ paddingX={2}
394
+ paddingY={0}
395
+ >
396
+ <Box justifyContent="space-between" marginBottom={0}>
397
+ <Text color="cyan" bold>
398
+ {title}
399
+ </Text>
400
+ {badge && (
401
+ <Text color={badgeColor} bold>
402
+ {badge}
403
+ </Text>
404
+ )}
405
+ </Box>
406
+ <Box flexDirection="column" gap={0}>
407
+ {children}
408
+ </Box>
78
409
  </Box>
79
410
  );
80
411
  }
81
412
 
82
- function Brand({ version }: { version: string }) {
413
+ function Brand({ version, compact }: { version: string; compact?: boolean }) {
83
414
  const logos = logoLines();
415
+ if (compact) {
416
+ return (
417
+ <Box gap={1}>
418
+ <Box flexShrink={0} gap={1}>
419
+ <Text bold color="cyan">
420
+ ▍kcgcode
421
+ </Text>
422
+ <Text color="cyan">v{version}</Text>
423
+ </Box>
424
+ <Text dimColor wrap="truncate-end">
425
+ · mobile control for CLI agents
426
+ </Text>
427
+ </Box>
428
+ );
429
+ }
84
430
  return (
85
- <Box gap={2}>
431
+ <Box gap={3} alignItems="center">
86
432
  <Box flexDirection="column">
87
433
  {logos.map((line, i) => (
88
434
  // biome-ignore lint/suspicious/noArrayIndexKey: static logo lines
@@ -91,12 +437,14 @@ function Brand({ version }: { version: string }) {
91
437
  </Text>
92
438
  ))}
93
439
  </Box>
94
- <Box flexDirection="column">
95
- <Box gap={1}>
96
- <Text bold color="cyan">
440
+ <Box flexDirection="column" gap={0}>
441
+ <Box gap={1} alignItems="baseline">
442
+ <Text bold color="white">
97
443
  kcgcode
98
444
  </Text>
99
- <Text dimColor>v{version}</Text>
445
+ <Text color="cyan" bold>
446
+ v{version}
447
+ </Text>
100
448
  </Box>
101
449
  <Text dimColor>mobile control for CLI agents</Text>
102
450
  <Text dimColor>opencode · claude code</Text>
@@ -113,163 +461,533 @@ const TUNNEL_COLOR: Record<string, string> = {
113
461
  error: "red",
114
462
  };
115
463
 
116
- /** Status tunnel publik (`https://<username>.<domain>`). */
117
- function TunnelCard({ server }: { server: KcgServer }) {
118
- const t = server.tunnel.status();
119
- if (!t.account) {
120
- return (
121
- <Card title="TUNNEL">
122
- {t.pairing ? (
123
- <>
124
- <Box gap={1}>
125
- <Text color="yellow">○</Text>
126
- <Text dimColor>{"code".padEnd(8)}</Text>
127
- <Text bold color="yellow">
128
- {t.pairing.userCode}
129
- </Text>
130
- </Box>
131
- <Box gap={1}>
132
- <Text color="gray"> </Text>
133
- <Text dimColor>{"open".padEnd(8)}</Text>
134
- <Text color="cyan" underline>
135
- {t.pairing.verificationUriComplete}
136
- </Text>
137
- </Box>
138
- </>
139
- ) : (
140
- <Box gap={1}>
141
- <Text color="gray">○</Text>
142
- <Text dimColor>off · connect in Settings → Remote access</Text>
143
- </Box>
144
- )}
145
- </Card>
146
- );
147
- }
148
- const color = TUNNEL_COLOR[t.phase] ?? "gray";
464
+ /** Rincian kedua tunnel — dibuka lewat menu (t). */
465
+ function TunnelDetailCard({
466
+ detail,
467
+ width,
468
+ }: {
469
+ detail: DashboardSnapshot["tunnelDetail"];
470
+ width?: number;
471
+ }) {
472
+ const { provider, kcg, lhr } = detail;
149
473
  return (
150
- <Card title="TUNNEL">
151
- <Box gap={1}>
152
- <Text color={color}>{t.phase === "online" ? "●" : "○"}</Text>
153
- <Text dimColor>{"public".padEnd(8)}</Text>
154
- <Text color="cyan" underline={t.phase === "online"}>
155
- {t.account.url}
156
- </Text>
157
- </Box>
158
- <Box gap={1}>
159
- <Text dimColor>{"status".padEnd(10)}</Text>
160
- <Text color={color} bold>
161
- {t.phase === "stopped" ? "off" : t.phase}
162
- </Text>
163
- {t.error && <Text color="red">({t.error})</Text>}
474
+ <Card title="TUNNEL DETAIL" badge={provider ?? "—"} badgeColor="cyan" width={width}>
475
+ <InfoRow label="kcg phase" width={10}>
476
+ <Text color={TUNNEL_COLOR[kcg.phase] ?? "white"}>{kcg.phase}</Text>
477
+ </InfoRow>
478
+ <InfoRow label="kcg url" width={10}>
479
+ <Text color={kcg.url ? "cyan" : "gray"}>{kcg.url ?? "—"}</Text>
480
+ </InfoRow>
481
+ {lhr ? (
482
+ <>
483
+ <InfoRow label="lhr phase" width={10}>
484
+ <Text color={TUNNEL_COLOR[lhr.phase] ?? "white"}>{lhr.phase}</Text>
485
+ </InfoRow>
486
+ <InfoRow label="lhr url" width={10}>
487
+ <Text color={lhr.url ? "cyan" : "gray"}>{lhr.url ?? "—"}</Text>
488
+ </InfoRow>
489
+ </>
490
+ ) : (
491
+ <InfoRow label="lhr" width={10}>
492
+ <Text dimColor>tidak tersedia</Text>
493
+ </InfoRow>
494
+ )}
495
+ <Box marginTop={0}>
496
+ <Text dimColor>Ganti provider di Settings → Remote access.</Text>
164
497
  </Box>
165
498
  </Card>
166
499
  );
167
500
  }
168
501
 
169
502
  interface DashboardUIProps {
170
- info: DashboardInfo;
503
+ snap: DashboardSnapshot;
171
504
  version: string;
505
+ view?: DashboardViewState;
172
506
  }
173
507
 
174
- function DashboardUI({ info, version }: DashboardUIProps) {
175
- const { server, port, hostname } = info;
176
- const urls = localUrls(hostname, port);
177
- const { projects, sessions, running } = activityStats(server);
508
+ function DashboardUI({ snap, version, view = {} }: DashboardUIProps) {
509
+ const background = snap.mode === "background";
510
+ const items = menuItems(view, snap.mode);
511
+ const menuIndex = Math.min(Math.max(view.menuIndex ?? 0, 0), items.length - 1);
512
+ const { running, target } = snap;
513
+
514
+ const terminalWidth = process.stdout.columns ?? 72;
515
+ const cardWidth = Math.min(68, Math.max(36, terminalWidth - 4));
516
+ const compact = terminalWidth < COMPACT_BELOW;
517
+ const uptime = formatUptime(Date.now() - snap.startedAt);
518
+ const labelWidth = Math.max(...items.map((i) => i.label.length));
519
+ const panelOpen = Boolean(view.showQr || view.showDetail);
520
+ const editingPort = view.portInput != null;
521
+ const quitHint = background ? "q close" : "q quit";
178
522
 
179
523
  return (
180
- <Box flexDirection="column" paddingX={PAD} paddingY={1} gap={1}>
181
- <Brand version={version} />
182
-
183
- <Card title="SERVER">
184
- {urls.map((u, idx) => (
185
- <Box key={u.url} gap={1}>
186
- <Text color={idx === 0 ? "green" : "gray"}>{idx === 0 ? "●" : "○"}</Text>
187
- <Text dimColor>{u.label.padEnd(8)}</Text>
188
- <Text color="cyan" underline>
189
- {u.url}
190
- </Text>
524
+ // Wrapper selebar terminal agar blok konten berada di tengah secara horizontal
525
+ <Box width={terminalWidth} flexDirection="column" alignItems="center">
526
+ <Box flexDirection="column" paddingX={PAD} paddingY={1} gap={1} width={cardWidth + PAD * 2}>
527
+ {/* Dekorasi KCG tetap di atas */}
528
+ <Brand version={version} compact={compact} />
529
+
530
+ {/* Status server: header Running + uptime, lalu URL & info proses */}
531
+ <Box
532
+ width={cardWidth}
533
+ borderStyle="round"
534
+ borderColor="cyan"
535
+ flexDirection="column"
536
+ paddingX={2}
537
+ paddingY={0}
538
+ >
539
+ <Box justifyContent="space-between" marginBottom={1}>
540
+ {snap.restarting ? (
541
+ <Text color="yellow" bold>
542
+ ◌ {snap.busyLabel ?? "Restarting…"}
543
+ </Text>
544
+ ) : (
545
+ <Text color="green" bold>
546
+ ● Running{background ? " in background" : ""}
547
+ </Text>
548
+ )}
549
+ <Text dimColor>up {uptime}</Text>
191
550
  </Box>
192
- ))}
193
- <Box marginTop={0} gap={1}>
194
- <Text dimColor>{"process".padEnd(8)}</Text>
195
- <Text color="green" bold>
196
- running
197
- </Text>
551
+ <InfoRow label="Local">
552
+ <Text color="cyan">{snap.localUrl}</Text>
553
+ {snap.portNote && <Text color="yellow">({snap.portNote})</Text>}
554
+ </InfoRow>
555
+ {snap.networkUrl && (
556
+ <InfoRow label="Network">
557
+ <Text color="cyan">{snap.networkUrl}</Text>
558
+ </InfoRow>
559
+ )}
560
+ {snap.publicUrl && (
561
+ <InfoRow label="Public">
562
+ <Text color="cyan" bold>
563
+ {snap.publicUrl}
564
+ </Text>
565
+ </InfoRow>
566
+ )}
567
+ <InfoRow label="Tunnel">
568
+ <Text color={snap.tunnel.color}>{snap.tunnel.text}</Text>
569
+ </InfoRow>
570
+ {snap.pairingUri && (
571
+ <InfoRow label="Pair at">
572
+ <Text color="yellow">{snap.pairingUri}</Text>
573
+ </InfoRow>
574
+ )}
575
+ {snap.keepAwake && (
576
+ <InfoRow label="Awake">
577
+ <Text color={snap.keepAwake.active ? "green" : "yellow"} wrap="truncate-end">
578
+ {snap.keepAwake.text}
579
+ </Text>
580
+ </InfoRow>
581
+ )}
582
+ <InfoRow label="Sessions">
583
+ <Text color="white">{snap.sessions}</Text>
584
+ <Text dimColor>·</Text>
585
+ <Text color={running > 0 ? "green" : "white"}>{running} running</Text>
586
+ <Text dimColor>·</Text>
587
+ <Text color="white">
588
+ {snap.projects} {snap.projects === 1 ? "project" : "projects"}
589
+ </Text>
590
+ </InfoRow>
591
+ <InfoRow label="Process">
592
+ <Text color="white">PID {snap.pid}</Text>
593
+ <Text dimColor>·</Text>
594
+ <Text color="white">{snap.memory}</Text>
595
+ </InfoRow>
198
596
  </Box>
199
- </Card>
200
-
201
- <TunnelCard server={server} />
202
597
 
203
- <Card title="ACTIVITY">
204
- <Metric label="projects" value={String(projects)} />
205
- <Metric label="sessions" value={String(sessions)} />
206
- <Metric label="running" value={String(running.length)} accent={running.length > 0} />
207
-
208
- {running.length > 0 && (
209
- <Box flexDirection="column" marginTop={1}>
210
- {running.slice(0, 5).map((s) => (
211
- <Box key={s.id} gap={1}>
212
- <Text color="green">●</Text>
213
- <Text>{s.title?.trim() || s.id.slice(0, 12)}</Text>
214
- </Box>
215
- ))}
216
- </Box>
598
+ {view.showQr && <QrCard target={target} width={cardWidth} />}
599
+ {view.showDetail && <TunnelDetailCard detail={snap.tunnelDetail} width={cardWidth} />}
600
+ {editingPort && (
601
+ <PortCard
602
+ value={view.portInput ?? ""}
603
+ error={view.portError ?? null}
604
+ current={snap.port}
605
+ running={running}
606
+ busy={snap.restarting}
607
+ width={cardWidth}
608
+ />
217
609
  )}
218
- </Card>
219
610
 
220
- <Box gap={2}>
221
- <Box gap={1}>
222
- <Key>q</Key>
223
- <Text dimColor>quit</Text>
611
+ {/* Menu: tombol shortcut + label rata kolom + hint terpotong rapi */}
612
+ <Box
613
+ width={cardWidth}
614
+ borderStyle="round"
615
+ borderColor="gray"
616
+ flexDirection="column"
617
+ paddingX={1}
618
+ paddingY={0}
619
+ >
620
+ {items.map((item, idx) => {
621
+ const hint = menuHint(item.id, snap);
622
+ const selected = idx === menuIndex;
623
+ return (
624
+ <Box key={item.id} paddingX={1} backgroundColor={selected ? "blue" : undefined}>
625
+ <Box flexShrink={0}>
626
+ <Text color={selected ? "white" : "cyan"} bold>
627
+ {selected ? "▶ " : " "}
628
+ {item.key}
629
+ {" "}
630
+ </Text>
631
+ <Text color="white" bold={selected}>
632
+ {item.label.padEnd(labelWidth + 2)}
633
+ </Text>
634
+ </Box>
635
+ <Box flexShrink={1}>
636
+ <Text color={selected ? "white" : "gray"} wrap="truncate-end">
637
+ {hint}
638
+ </Text>
639
+ </Box>
640
+ </Box>
641
+ );
642
+ })}
224
643
  </Box>
225
- <Box gap={1}>
226
- <Key>o</Key>
227
- <Text dimColor>open</Text>
644
+
645
+ <Box justifyContent="center" paddingX={1}>
646
+ <Text dimColor wrap="truncate-end">
647
+ {editingPort
648
+ ? "type a port · ⏎ save & restart · esc cancel"
649
+ : compact
650
+ ? `↑↓ move · ⏎ select · ${quitHint}`
651
+ : `↑↓ move · ⏎ select · letter = shortcut${panelOpen ? " · esc close" : ""} · ${quitHint}`}
652
+ </Text>
228
653
  </Box>
229
- <Box gap={1}>
230
- <Key>ctrl+c</Key>
231
- <Text dimColor>stop</Text>
654
+ <Box justifyContent="center" height={1}>
655
+ {view.confirm ? (
656
+ <Text color="yellow" bold>
657
+ {CONFIRM_TEXT[view.confirm](running)}
658
+ </Text>
659
+ ) : view.flash ? (
660
+ <Text color="green">✓ {view.flash}</Text>
661
+ ) : (
662
+ <Text dimColor> </Text>
663
+ )}
232
664
  </Box>
233
665
  </Box>
234
666
  </Box>
235
667
  );
236
668
  }
237
669
 
670
+ /** Form ganti port: input angka, validasi, dan peringatan session running. */
671
+ function PortCard({
672
+ value,
673
+ error,
674
+ current,
675
+ running,
676
+ busy,
677
+ width,
678
+ }: {
679
+ value: string;
680
+ error: string | null;
681
+ current: number;
682
+ running: number;
683
+ busy: boolean;
684
+ width: number;
685
+ }) {
686
+ return (
687
+ <Card title="CHANGE PORT" badge={`current ${current}`} badgeColor="cyan" width={width}>
688
+ <Box marginTop={1} gap={1}>
689
+ <Text dimColor>New port</Text>
690
+ <Text color="cyan" bold>
691
+ ›
692
+ </Text>
693
+ <Text color="white" bold>
694
+ {value}
695
+ </Text>
696
+ {!busy && <Text color="cyan">▌</Text>}
697
+ </Box>
698
+ <Box marginTop={1} flexDirection="column">
699
+ {busy ? (
700
+ <Text color="yellow">Restarting server…</Text>
701
+ ) : error ? (
702
+ <Text color="red">✗ {error}</Text>
703
+ ) : (
704
+ <Text dimColor>Saved to config · server restarts on the new port.</Text>
705
+ )}
706
+ {running > 0 && !busy && (
707
+ <Text color="yellow">
708
+ ! {running} running session{running === 1 ? "" : "s"} will be interrupted.
709
+ </Text>
710
+ )}
711
+ </Box>
712
+ </Card>
713
+ );
714
+ }
715
+
716
+ /** Baris label (rata kiri, lebar tetap) + nilai. */
717
+ function InfoRow({
718
+ label,
719
+ width = LABEL_W,
720
+ children,
721
+ }: {
722
+ label: string;
723
+ width?: number;
724
+ children: ReactNode;
725
+ }) {
726
+ return (
727
+ <Box gap={1}>
728
+ <Box flexShrink={0} width={width}>
729
+ <Text dimColor>{label}</Text>
730
+ </Box>
731
+ <Box flexShrink={1} gap={1}>
732
+ {children}
733
+ </Box>
734
+ </Box>
735
+ );
736
+ }
737
+
738
+ /** QR code untuk membuka dashboard dari HP. */
739
+ function QrCard({ target, width }: { target: { label: string; url: string }; width: number }) {
740
+ const qr = renderUnicodeCompact(target.url, { border: 1 });
741
+ return (
742
+ <Card title="OPEN ON PHONE" badge={target.label} badgeColor="cyan" width={width}>
743
+ <Box flexDirection="column" alignItems="center">
744
+ <Text>{qr}</Text>
745
+ <Text color="cyan">{target.url}</Text>
746
+ </Box>
747
+ </Card>
748
+ );
749
+ }
750
+
238
751
  /** Frame string (non-TTY / test). */
239
- export function renderDashboard(info: DashboardInfo, version: string): string {
240
- return renderToString(<DashboardUI info={info} version={version} />);
752
+ export function renderDashboard(
753
+ info: DashboardInfo,
754
+ version: string,
755
+ view: DashboardViewState = {},
756
+ ): string {
757
+ return renderSnapshot(takeSnapshot(info), version, view);
241
758
  }
242
759
 
760
+ export function renderSnapshot(
761
+ snap: DashboardSnapshot,
762
+ version: string,
763
+ view: DashboardViewState = {},
764
+ ): string {
765
+ return renderToString(<DashboardUI snap={snap} version={version} view={view} />);
766
+ }
767
+
768
+ /** Sumber data dashboard — dibaca ulang setiap render. */
769
+ export type SnapshotSource = () => DashboardSnapshot;
770
+
243
771
  interface DashboardAppProps {
244
- info: DashboardInfo;
772
+ source: SnapshotSource;
245
773
  version: string;
246
- onQuit: DashboardCallbacks["onQuit"];
247
- onOpenUrl: DashboardCallbacks["onOpenUrl"];
774
+ callbacks: DashboardCallbacks;
248
775
  }
249
776
 
250
- function DashboardApp({ info, version, onQuit, onOpenUrl }: DashboardAppProps) {
777
+ /** Validasi isi input port; null = valid. */
778
+ export function portInputError(raw: string): string | null {
779
+ if (raw === "") return "Enter a port number";
780
+ const n = Number(raw);
781
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be between 1 and 65535";
782
+ if (n < 1024) return "Ports below 1024 need root — pick 1024 or higher";
783
+ return null;
784
+ }
785
+
786
+ function DashboardApp({ source, version, callbacks }: DashboardAppProps) {
787
+ const { onQuit, onOpenUrl, onChangePort, onBackground, onStop, onForeground } = callbacks;
251
788
  const [, tick] = useReducer((n: number) => n + 1, 0);
789
+ const snap = source();
790
+ const background = snap.mode === "background";
791
+ const [showQr, setShowQr] = useState(false);
792
+ const [showDetail, setShowDetail] = useState(false);
793
+ const [menuIndex, setMenuIndex] = useState(0);
794
+ const [flash, setFlash] = useState<string | null>(null);
795
+ const [confirm, setConfirmState] = useState<ConfirmAction | null>(null);
796
+ const [portInput, setPortInputState] = useState<string | null>(null);
797
+ // Ink bisa memproses beberapa tombol sebelum re-render; handler membaca ref
798
+ // agar tidak memakai nilai state yang basi.
799
+ const confirmRef = useRef<ConfirmAction | null>(null);
800
+ const portRef = useRef<string | null>(null);
801
+ const setConfirm = (v: ConfirmAction | null) => {
802
+ confirmRef.current = v;
803
+ setConfirmState(v);
804
+ };
805
+ const setPortInput = (v: string | null) => {
806
+ portRef.current = v;
807
+ setPortInputState(v);
808
+ };
809
+ const [portError, setPortError] = useState<string | null>(null);
810
+ const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
811
+ const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
252
812
 
253
813
  useEffect(() => {
254
814
  const id = setInterval(tick, REFRESH_MS);
255
- return () => clearInterval(id);
815
+ return () => {
816
+ clearInterval(id);
817
+ if (flashTimer.current) clearTimeout(flashTimer.current);
818
+ if (confirmTimer.current) clearTimeout(confirmTimer.current);
819
+ };
256
820
  }, []);
257
821
 
822
+ const notify = (msg: string) => {
823
+ setFlash(msg);
824
+ if (flashTimer.current) clearTimeout(flashTimer.current);
825
+ flashTimer.current = setTimeout(() => setFlash(null), FLASH_MS);
826
+ };
827
+
828
+ /**
829
+ * Aksi yang memutus session running (quit / background) butuh tekan kedua
830
+ * dalam CONFIRM_MS. Tanpa session running langsung dijalankan.
831
+ */
832
+ const confirmed = (action: ConfirmAction): boolean => {
833
+ if (source().running === 0 || confirmRef.current === action) {
834
+ setConfirm(null);
835
+ return true;
836
+ }
837
+ setConfirm(action);
838
+ if (confirmTimer.current) clearTimeout(confirmTimer.current);
839
+ confirmTimer.current = setTimeout(() => setConfirm(null), CONFIRM_MS);
840
+ return false;
841
+ };
842
+
843
+ const doOpen = () => {
844
+ void onOpenUrl();
845
+ notify(`Opening ${shortUrl(source().localUrl)}…`);
846
+ };
847
+ const doCopy = () => {
848
+ // Tampilkan URL-nya juga: terminal tanpa dukungan OSC 52 tetap bisa disalin manual.
849
+ const t = source().target;
850
+ copyToClipboard(t.url);
851
+ notify(`Copied ${t.url}`);
852
+ };
853
+ const doQuit = () => {
854
+ // Background: q hanya menutup dashboard — tidak ada yang terputus.
855
+ if (background || confirmed("quit")) onQuit("quit");
856
+ };
857
+ const doStop = () => {
858
+ if (confirmed("stop")) onStop();
859
+ };
860
+ const doForeground = () => {
861
+ if (confirmed("foreground")) onForeground();
862
+ };
863
+ const doBackground = () => {
864
+ if (confirmed("background")) onBackground();
865
+ };
866
+ const openPortInput = () => {
867
+ setPortInput(String(source().port));
868
+ setPortError(null);
869
+ };
870
+ const submitPort = async (raw: string) => {
871
+ const err = portInputError(raw);
872
+ if (err) {
873
+ setPortError(err);
874
+ return;
875
+ }
876
+ const next = Number(raw);
877
+ const prev = source().port;
878
+ setPortError(null);
879
+ // onChangePort menyetel status restarting secara sinkron — render segera.
880
+ const pending = onChangePort(next);
881
+ tick();
882
+ const failed = await pending;
883
+ if (failed) {
884
+ setPortError(failed);
885
+ return;
886
+ }
887
+ setPortInput(null);
888
+ notify(next === prev ? `Port ${next} saved to config` : `Server restarted on port ${next}`);
889
+ };
890
+
891
+ const activate = (index: number) => {
892
+ const items = menuItems({ showQr, showDetail }, snap.mode);
893
+ const item = items[index];
894
+ if (!item) return;
895
+ if (item.id === "open") doOpen();
896
+ else if (item.id === "copy") doCopy();
897
+ else if (item.id === "qr") setShowQr((v) => !v);
898
+ else if (item.id === "detail") setShowDetail((v) => !v);
899
+ else if (item.id === "port") openPortInput();
900
+ else if (item.id === "background") doBackground();
901
+ else if (item.id === "foreground") doForeground();
902
+ else if (item.id === "stop") doStop();
903
+ else if (item.id === "quit") doQuit();
904
+ };
905
+
906
+ /** Mode input port: hanya angka, backspace, enter, esc yang diproses. */
907
+ const handlePortKey = (
908
+ current: string,
909
+ input: string,
910
+ key: Parameters<Parameters<typeof useInput>[0]>[1],
911
+ ) => {
912
+ // Abaikan input selama restart berjalan.
913
+ if (source().restarting) return;
914
+ if (key.escape) {
915
+ setPortInput(null);
916
+ setPortError(null);
917
+ } else if (key.return) {
918
+ void submitPort(current);
919
+ } else if (key.backspace || key.delete) {
920
+ setPortInput(current.slice(0, -1));
921
+ setPortError(null);
922
+ } else {
923
+ const digits = input.replace(/\D/g, "");
924
+ const next = (current + digits).slice(0, 5);
925
+ if (digits) {
926
+ setPortInput(next);
927
+ setPortError(null);
928
+ }
929
+ // Paste "4000⏎" tiba sebagai satu chunk tanpa flag return.
930
+ if (/[\r\n]/.test(input)) void submitPort(next);
931
+ }
932
+ };
933
+
258
934
  useInput((input, key) => {
259
935
  if (key.ctrl && input === "c") {
260
936
  onQuit("Ctrl+C");
261
937
  return;
262
938
  }
263
- if (input === "q" || input === "Q") {
264
- onQuit("quit");
939
+ if (portRef.current !== null) {
940
+ handlePortKey(portRef.current, input, key);
265
941
  return;
266
942
  }
267
- if (input === "o" || input === "O") {
268
- void onOpenUrl();
943
+ if (key.escape) {
944
+ if (confirmRef.current) {
945
+ setConfirm(null);
946
+ return;
947
+ }
948
+ if (showQr || showDetail) {
949
+ setShowQr(false);
950
+ setShowDetail(false);
951
+ return;
952
+ }
953
+ }
954
+ const menu = menuItems({}, snap.mode);
955
+ const count = menu.length;
956
+ if (key.upArrow) {
957
+ setMenuIndex((i) => (i + count - 1) % count);
958
+ return;
959
+ }
960
+ if (key.downArrow) {
961
+ setMenuIndex((i) => (i + 1) % count);
962
+ return;
963
+ }
964
+ if (key.return) {
965
+ activate(menuIndex);
966
+ return;
967
+ }
968
+ const k = input.toLowerCase();
969
+ // Shortcut huruf: sorot item terkait lalu jalankan, agar ada umpan balik visual.
970
+ const shortcutIdx = menu.findIndex((item) => item.key === k);
971
+ if (shortcutIdx >= 0) {
972
+ setMenuIndex(shortcutIdx);
973
+ activate(shortcutIdx);
974
+ } else if (k === "j") {
975
+ setMenuIndex((i) => (i + 1) % count);
976
+ } else if (k === "k") {
977
+ setMenuIndex((i) => (i + count - 1) % count);
978
+ } else if (/^[1-9]$/.test(k) && Number(k) <= count) {
979
+ setMenuIndex(Number(k) - 1);
980
+ activate(Number(k) - 1);
269
981
  }
270
982
  });
271
983
 
272
- return <DashboardUI info={info} version={version} />;
984
+ return (
985
+ <DashboardUI
986
+ snap={snap}
987
+ version={version}
988
+ view={{ showQr, showDetail, menuIndex, flash, confirm, portInput, portError }}
989
+ />
990
+ );
273
991
  }
274
992
 
275
993
  export interface RunningDashboard {
@@ -283,43 +1001,46 @@ export interface RunningDashboard {
283
1001
  * Pastikan `stop()` dipanggil saat shutdown agar terminal pulih.
284
1002
  */
285
1003
  export async function startDashboard(
286
- info: DashboardInfo & Partial<DashboardCallbacks>,
1004
+ source: SnapshotSource,
1005
+ callbacks: Partial<DashboardCallbacks> = {},
287
1006
  ): Promise<RunningDashboard> {
288
1007
  const version = await readPackageVersion();
289
1008
  const stdout = process.stdout;
290
1009
  const isTty = Boolean(stdout.isTTY);
291
1010
 
292
- const onQuit = info.onQuit ?? (() => {});
293
- const onOpenUrl = info.onOpenUrl ?? (() => {});
1011
+ const noop = () => {};
1012
+ const cb: DashboardCallbacks = {
1013
+ onQuit: callbacks.onQuit ?? noop,
1014
+ onOpenUrl: callbacks.onOpenUrl ?? noop,
1015
+ onChangePort: callbacks.onChangePort ?? (async () => "not supported"),
1016
+ onBackground: callbacks.onBackground ?? noop,
1017
+ onStop: callbacks.onStop ?? noop,
1018
+ onForeground: callbacks.onForeground ?? noop,
1019
+ };
294
1020
 
295
1021
  if (!isTty) {
296
- stdout.write(`${renderDashboard(info, version)}\n`);
1022
+ stdout.write(`${renderSnapshot(source(), version)}\n`);
297
1023
  return {
298
1024
  stop() {},
299
1025
  redraw() {
300
- stdout.write(`${renderDashboard(info, version)}\n`);
1026
+ stdout.write(`${renderSnapshot(source(), version)}\n`);
301
1027
  },
302
1028
  };
303
1029
  }
304
1030
 
305
- const instance = render(
306
- <DashboardApp info={info} version={version} onQuit={onQuit} onOpenUrl={onOpenUrl} />,
307
- {
308
- alternateScreen: true,
309
- patchConsole: true,
310
- exitOnCtrlC: false,
311
- incrementalRendering: true,
312
- },
313
- );
1031
+ const instance = render(<DashboardApp source={source} version={version} callbacks={cb} />, {
1032
+ alternateScreen: true,
1033
+ patchConsole: true,
1034
+ exitOnCtrlC: false,
1035
+ incrementalRendering: true,
1036
+ });
314
1037
 
315
1038
  return {
316
1039
  stop() {
317
1040
  instance.unmount();
318
1041
  },
319
1042
  redraw() {
320
- instance.rerender(
321
- <DashboardApp info={info} version={version} onQuit={onQuit} onOpenUrl={onOpenUrl} />,
322
- );
1043
+ instance.rerender(<DashboardApp source={source} version={version} callbacks={cb} />);
323
1044
  },
324
1045
  };
325
1046
  }