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.
- package/README.md +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +876 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
package/src/cli/dashboard.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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={
|
|
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="
|
|
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
|
|
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
|
-
/**
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
<
|
|
152
|
-
<Text color={
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
499
|
+
snap: DashboardSnapshot;
|
|
171
500
|
version: string;
|
|
501
|
+
view?: DashboardViewState;
|
|
172
502
|
}
|
|
173
503
|
|
|
174
|
-
function DashboardUI({
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
{
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
<Box
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
<Text dimColor>
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
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(
|
|
240
|
-
|
|
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
|
-
|
|
768
|
+
source: SnapshotSource;
|
|
245
769
|
version: string;
|
|
246
|
-
|
|
247
|
-
onOpenUrl: DashboardCallbacks["onOpenUrl"];
|
|
770
|
+
callbacks: DashboardCallbacks;
|
|
248
771
|
}
|
|
249
772
|
|
|
250
|
-
|
|
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 () =>
|
|
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 (
|
|
264
|
-
|
|
935
|
+
if (portRef.current !== null) {
|
|
936
|
+
handlePortKey(portRef.current, input, key);
|
|
265
937
|
return;
|
|
266
938
|
}
|
|
267
|
-
if (
|
|
268
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
293
|
-
const
|
|
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(`${
|
|
1018
|
+
stdout.write(`${renderSnapshot(source(), version)}\n`);
|
|
297
1019
|
return {
|
|
298
1020
|
stop() {},
|
|
299
1021
|
redraw() {
|
|
300
|
-
stdout.write(`${
|
|
1022
|
+
stdout.write(`${renderSnapshot(source(), version)}\n`);
|
|
301
1023
|
},
|
|
302
1024
|
};
|
|
303
1025
|
}
|
|
304
1026
|
|
|
305
|
-
const instance = render(
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
}
|