kcg-code 0.0.0-stage → 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 +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- 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__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -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/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard terminal live untuk server kcgcode — dirender lewat Ink.
|
|
3
|
+
*
|
|
4
|
+
* Alternate screen + frame update in-place; layout modern dengan card.
|
|
5
|
+
*/
|
|
6
|
+
import { networkInterfaces } from "node:os";
|
|
7
|
+
import { Box, render, renderToString, Text, useInput } from "ink";
|
|
8
|
+
import { type ReactNode, useEffect, useReducer, useRef, useState } from "react";
|
|
9
|
+
import { renderUnicodeCompact } from "uqr";
|
|
10
|
+
import type { KcgServer } from "../server/app";
|
|
11
|
+
import type { KeepAwake } from "./keep-awake";
|
|
12
|
+
import { logoLines, readPackageVersion } from "./theme";
|
|
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
|
+
|
|
33
|
+
export interface DashboardInfo {
|
|
34
|
+
server: KcgServer & {
|
|
35
|
+
lhr?: { status: () => LhrTunnelStatus };
|
|
36
|
+
tunnelProvider?: () => TunnelProviderId;
|
|
37
|
+
};
|
|
38
|
+
port: number;
|
|
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;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DashboardCallbacks {
|
|
91
|
+
/** q / Ctrl+C. Foreground: hentikan server. Background: tutup dashboard saja. */
|
|
92
|
+
onQuit: (reason: string) => void;
|
|
93
|
+
/** Buka URL dashboard di browser default. */
|
|
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;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const REFRESH_MS = 1000;
|
|
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
|
+
}
|
|
232
|
+
|
|
233
|
+
function localUrls(hostname: string, port: number): { label: string; url: string }[] {
|
|
234
|
+
const urls: { label: string; url: string }[] = [];
|
|
235
|
+
const host = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
|
|
236
|
+
urls.push({ label: "local", url: `http://${host}:${port}` });
|
|
237
|
+
|
|
238
|
+
if (hostname === "127.0.0.1" || hostname === "localhost") {
|
|
239
|
+
return urls;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const infos of Object.values(networkInterfaces())) {
|
|
243
|
+
for (const info of infos ?? []) {
|
|
244
|
+
if (info.family === "IPv4" && !info.internal) {
|
|
245
|
+
urls.push({ label: "network", url: `http://${info.address}:${port}` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return urls;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function activityStats(app: KcgServer) {
|
|
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
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
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?:\/\//, "");
|
|
295
|
+
}
|
|
296
|
+
|
|
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
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
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
|
+
}) {
|
|
383
|
+
return (
|
|
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>
|
|
405
|
+
</Box>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function Brand({ version, compact }: { version: string; compact?: boolean }) {
|
|
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
|
+
}
|
|
426
|
+
return (
|
|
427
|
+
<Box gap={3} alignItems="center">
|
|
428
|
+
<Box flexDirection="column">
|
|
429
|
+
{logos.map((line, i) => (
|
|
430
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static logo lines
|
|
431
|
+
<Text key={i} color="cyan" bold>
|
|
432
|
+
{line}
|
|
433
|
+
</Text>
|
|
434
|
+
))}
|
|
435
|
+
</Box>
|
|
436
|
+
<Box flexDirection="column" gap={0}>
|
|
437
|
+
<Box gap={1} alignItems="baseline">
|
|
438
|
+
<Text bold color="white">
|
|
439
|
+
kcgcode
|
|
440
|
+
</Text>
|
|
441
|
+
<Text color="cyan" bold>
|
|
442
|
+
v{version}
|
|
443
|
+
</Text>
|
|
444
|
+
</Box>
|
|
445
|
+
<Text dimColor>mobile control for CLI agents</Text>
|
|
446
|
+
<Text dimColor>opencode · claude code</Text>
|
|
447
|
+
</Box>
|
|
448
|
+
</Box>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const TUNNEL_COLOR: Record<string, string> = {
|
|
453
|
+
online: "green",
|
|
454
|
+
connecting: "yellow",
|
|
455
|
+
starting: "yellow",
|
|
456
|
+
reconnecting: "yellow",
|
|
457
|
+
error: "red",
|
|
458
|
+
};
|
|
459
|
+
|
|
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;
|
|
469
|
+
return (
|
|
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>
|
|
493
|
+
</Box>
|
|
494
|
+
</Card>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface DashboardUIProps {
|
|
499
|
+
snap: DashboardSnapshot;
|
|
500
|
+
version: string;
|
|
501
|
+
view?: DashboardViewState;
|
|
502
|
+
}
|
|
503
|
+
|
|
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";
|
|
518
|
+
|
|
519
|
+
return (
|
|
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>
|
|
546
|
+
</Box>
|
|
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>
|
|
592
|
+
</Box>
|
|
593
|
+
|
|
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
|
+
/>
|
|
605
|
+
)}
|
|
606
|
+
|
|
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
|
+
})}
|
|
639
|
+
</Box>
|
|
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>
|
|
649
|
+
</Box>
|
|
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
|
+
)}
|
|
660
|
+
</Box>
|
|
661
|
+
</Box>
|
|
662
|
+
</Box>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
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
|
+
|
|
747
|
+
/** Frame string (non-TTY / test). */
|
|
748
|
+
export function renderDashboard(
|
|
749
|
+
info: DashboardInfo,
|
|
750
|
+
version: string,
|
|
751
|
+
view: DashboardViewState = {},
|
|
752
|
+
): string {
|
|
753
|
+
return renderSnapshot(takeSnapshot(info), version, view);
|
|
754
|
+
}
|
|
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
|
+
|
|
767
|
+
interface DashboardAppProps {
|
|
768
|
+
source: SnapshotSource;
|
|
769
|
+
version: string;
|
|
770
|
+
callbacks: DashboardCallbacks;
|
|
771
|
+
}
|
|
772
|
+
|
|
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;
|
|
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);
|
|
808
|
+
|
|
809
|
+
useEffect(() => {
|
|
810
|
+
const id = setInterval(tick, REFRESH_MS);
|
|
811
|
+
return () => {
|
|
812
|
+
clearInterval(id);
|
|
813
|
+
if (flashTimer.current) clearTimeout(flashTimer.current);
|
|
814
|
+
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
|
815
|
+
};
|
|
816
|
+
}, []);
|
|
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
|
+
|
|
930
|
+
useInput((input, key) => {
|
|
931
|
+
if (key.ctrl && input === "c") {
|
|
932
|
+
onQuit("Ctrl+C");
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (portRef.current !== null) {
|
|
936
|
+
handlePortKey(portRef.current, input, key);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
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);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
return (
|
|
981
|
+
<DashboardUI
|
|
982
|
+
snap={snap}
|
|
983
|
+
version={version}
|
|
984
|
+
view={{ showQr, showDetail, menuIndex, flash, confirm, portInput, portError }}
|
|
985
|
+
/>
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export interface RunningDashboard {
|
|
990
|
+
stop(): void;
|
|
991
|
+
/** Frame sudah live via Ink; API dipertahankan untuk kompatibilitas. */
|
|
992
|
+
redraw(): void;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Mulai dashboard live di alternate screen. Kembalikan handle untuk stop.
|
|
997
|
+
* Pastikan `stop()` dipanggil saat shutdown agar terminal pulih.
|
|
998
|
+
*/
|
|
999
|
+
export async function startDashboard(
|
|
1000
|
+
source: SnapshotSource,
|
|
1001
|
+
callbacks: Partial<DashboardCallbacks> = {},
|
|
1002
|
+
): Promise<RunningDashboard> {
|
|
1003
|
+
const version = await readPackageVersion();
|
|
1004
|
+
const stdout = process.stdout;
|
|
1005
|
+
const isTty = Boolean(stdout.isTTY);
|
|
1006
|
+
|
|
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
|
+
};
|
|
1016
|
+
|
|
1017
|
+
if (!isTty) {
|
|
1018
|
+
stdout.write(`${renderSnapshot(source(), version)}\n`);
|
|
1019
|
+
return {
|
|
1020
|
+
stop() {},
|
|
1021
|
+
redraw() {
|
|
1022
|
+
stdout.write(`${renderSnapshot(source(), version)}\n`);
|
|
1023
|
+
},
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const instance = render(<DashboardApp source={source} version={version} callbacks={cb} />, {
|
|
1028
|
+
alternateScreen: true,
|
|
1029
|
+
patchConsole: true,
|
|
1030
|
+
exitOnCtrlC: false,
|
|
1031
|
+
incrementalRendering: true,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
stop() {
|
|
1036
|
+
instance.unmount();
|
|
1037
|
+
},
|
|
1038
|
+
redraw() {
|
|
1039
|
+
instance.rerender(<DashboardApp source={source} version={version} callbacks={cb} />);
|
|
1040
|
+
},
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Coba buka URL di browser default OS. */
|
|
1045
|
+
export async function openBrowser(url: string): Promise<void> {
|
|
1046
|
+
const platform = process.platform;
|
|
1047
|
+
let cmd: string[];
|
|
1048
|
+
if (platform === "win32") {
|
|
1049
|
+
cmd = ["cmd", "/c", "start", "", url];
|
|
1050
|
+
} else if (platform === "darwin") {
|
|
1051
|
+
cmd = ["open", url];
|
|
1052
|
+
} else {
|
|
1053
|
+
cmd = ["xdg-open", url];
|
|
1054
|
+
}
|
|
1055
|
+
try {
|
|
1056
|
+
Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
|
|
1057
|
+
} catch {
|
|
1058
|
+
// diam — user bisa buka manual
|
|
1059
|
+
}
|
|
1060
|
+
}
|