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