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,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State collapsible thinking block per pesan (Requirement 8.3).
|
|
3
|
+
*
|
|
4
|
+
* Logika murni dan bebas DOM agar dapat diuji dengan property test
|
|
5
|
+
* (Property 25) lewat `bun test` tanpa rendering React.
|
|
6
|
+
*
|
|
7
|
+
* Representasi `Record<messageId, boolean>` dijaga aman terhadap key khusus
|
|
8
|
+
* prototipe (`__proto__`, `toString`, dst.): seluruh baca/tulis memakai
|
|
9
|
+
* own-property eksplisit sehingga id pesan apa pun (hasil generator property
|
|
10
|
+
* test) tidak mencemari prototipe objek state.
|
|
11
|
+
*/
|
|
12
|
+
export type CollapsibleState = Record<string, boolean>;
|
|
13
|
+
|
|
14
|
+
/** Definisikan key sebagai own-property enumerable (aman untuk `__proto__`). */
|
|
15
|
+
function setOwn(state: CollapsibleState, key: string, value: boolean): void {
|
|
16
|
+
Object.defineProperty(state, key, {
|
|
17
|
+
value,
|
|
18
|
+
enumerable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** State awal: seluruh pesan tampil (expanded) sebelum toggle pertama (Req 8.3). */
|
|
25
|
+
export function initialCollapsibleState(messageIds: readonly string[]): CollapsibleState {
|
|
26
|
+
const state: CollapsibleState = {};
|
|
27
|
+
for (const id of messageIds) setOwn(state, id, true);
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* State awal: seluruh reasoning tertutup (collapsed) — hanya baris "Thinking"
|
|
33
|
+
* yang tampil; isi dibuka lewat toggle. Tinggi konten tetap dibatasi
|
|
34
|
+
* (`max-h` + scroll) walau di-expand.
|
|
35
|
+
*/
|
|
36
|
+
export function initialCollapsedState(messageIds: readonly string[]): CollapsibleState {
|
|
37
|
+
return extendCollapsed({}, messageIds);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Perluas state dengan key baru (mis. reasoning pesan baru lewat event
|
|
42
|
+
* `message`) dalam keadaan collapsed, tanpa mengubah status key yang sudah
|
|
43
|
+
* ada. Mengembalikan state baru (immutable).
|
|
44
|
+
*/
|
|
45
|
+
export function extendCollapsed(
|
|
46
|
+
state: CollapsibleState,
|
|
47
|
+
messageIds: readonly string[],
|
|
48
|
+
): CollapsibleState {
|
|
49
|
+
if (messageIds.length === 0) return state;
|
|
50
|
+
const next: CollapsibleState = {};
|
|
51
|
+
for (const [key, value] of Object.entries(state)) setOwn(next, key, value);
|
|
52
|
+
for (const id of messageIds) {
|
|
53
|
+
if (!Object.hasOwn(next, id)) setOwn(next, id, false);
|
|
54
|
+
}
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Status tampil saat ini untuk sebuah pesan (default: expanded, Req 8.3). */
|
|
59
|
+
export function isCollapsibleExpanded(state: CollapsibleState, messageId: string): boolean {
|
|
60
|
+
if (Object.hasOwn(state, messageId)) {
|
|
61
|
+
return Boolean(state[messageId]);
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Toggle satu pesan tanpa memengaruhi pesan lain (Property 25).
|
|
68
|
+
* Mengembalikan state baru (immutable) agar mudah diverifikasi.
|
|
69
|
+
*/
|
|
70
|
+
export function toggleCollapsible(state: CollapsibleState, messageId: string): CollapsibleState {
|
|
71
|
+
const next: CollapsibleState = {};
|
|
72
|
+
for (const [key, value] of Object.entries(state)) setOwn(next, key, value);
|
|
73
|
+
setOwn(next, messageId, !isCollapsibleExpanded(state, messageId));
|
|
74
|
+
return next;
|
|
75
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer chat — logika murni (tanpa DOM), dipisah agar dapat diuji
|
|
3
|
+
* `bun test` seperti helper `lib/` lainnya.
|
|
4
|
+
*
|
|
5
|
+
* Isinya keputusan teks placeholder: di layar HP input jauh lebih sempit,
|
|
6
|
+
* sehingga placeholder panjang terpotong di tengah kata dan justru tidak
|
|
7
|
+
* memberi informasi apa pun. Versi mobile dibuat pendek; petunjuk lengkap
|
|
8
|
+
* (`@` untuk file, drop/tempel file) tetap tersedia di layar lebar.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Kondisi composer yang menentukan teks placeholder. */
|
|
12
|
+
export interface ComposerPlaceholderState {
|
|
13
|
+
/** Model sedang merespon (turn aktif). */
|
|
14
|
+
busy: boolean;
|
|
15
|
+
/** Input dapat dipakai (WS terhubung, Session running, tidak sibuk). */
|
|
16
|
+
canInput: boolean;
|
|
17
|
+
/** Viewport sempit — pakai teks pendek. */
|
|
18
|
+
compact: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Ada lampiran (file) — tombol kirim muncul dan ruang teks menyempit,
|
|
21
|
+
* jadi petunjuk panjang diganti ajakan singkat yang relevan.
|
|
22
|
+
*/
|
|
23
|
+
hasAttachments?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Placeholder textarea composer. Urutan pemeriksaan penting: status sibuk
|
|
28
|
+
* dan Session mati lebih informatif daripada petunjuk mengetik, jadi
|
|
29
|
+
* keduanya diperiksa lebih dulu.
|
|
30
|
+
*/
|
|
31
|
+
export function composerPlaceholder(state: ComposerPlaceholderState): string {
|
|
32
|
+
if (state.busy) return state.compact ? "Responding…" : "Model is responding…";
|
|
33
|
+
if (!state.canInput) return "Session is not active";
|
|
34
|
+
if (state.hasAttachments) return "Add a message…";
|
|
35
|
+
return state.compact
|
|
36
|
+
? "Type a message…"
|
|
37
|
+
: "Type a message… type @ for file references, or drop files here";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ekstrak referensi `@path` dari teks untuk dikirim sebagai part `file`
|
|
42
|
+
* terpisah di prompt. Referensi dianggap file bila path-nya pernah disarankan
|
|
43
|
+
* autocomplete ATAU memuat `/` (kemungkinan besar path file, bukan kata
|
|
44
|
+
* biasa seperti `@user`).
|
|
45
|
+
*
|
|
46
|
+
* Teks asli tidak diubah — part `file` hanya penanda tambahan agar isi file
|
|
47
|
+
* benar-benar dibaca opencode; urutan kemunculan & duplikat dipertahankan
|
|
48
|
+
* (tiap kemunculan menjadi satu part `file`).
|
|
49
|
+
*/
|
|
50
|
+
export function extractMentionedFiles(text: string, knownPaths: ReadonlySet<string>): string[] {
|
|
51
|
+
const files: string[] = [];
|
|
52
|
+
for (const match of text.matchAll(/(^|\s)@([^\s]+)/g)) {
|
|
53
|
+
const path = match[2] ?? "";
|
|
54
|
+
if (path.length > 0 && (knownPaths.has(path) || path.includes("/"))) {
|
|
55
|
+
files.push(path);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return files;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Potongan teks composer untuk lapisan sorot: teks biasa atau mention file. */
|
|
62
|
+
export type ComposerSegment =
|
|
63
|
+
| { kind: "text"; text: string }
|
|
64
|
+
| { kind: "mention"; text: string; path: string };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pecah teks composer menjadi potongan teks biasa & mention `@path` yang
|
|
68
|
+
* dikenali (aturan sama dengan `extractMentionedFiles`: path pernah
|
|
69
|
+
* disarankan autocomplete ATAU memuat `/`). Gabungan `text` seluruh
|
|
70
|
+
* potongan SELALU identik dengan input — lapisan sorot di belakang textarea
|
|
71
|
+
* bergantung pada ini agar posisinya pas karakter demi karakter.
|
|
72
|
+
*/
|
|
73
|
+
export function splitComposerMentions(
|
|
74
|
+
text: string,
|
|
75
|
+
knownPaths: ReadonlySet<string>,
|
|
76
|
+
): ComposerSegment[] {
|
|
77
|
+
const out: ComposerSegment[] = [];
|
|
78
|
+
let last = 0;
|
|
79
|
+
for (const match of text.matchAll(/(^|\s)@([^\s]+)/g)) {
|
|
80
|
+
const path = match[2] ?? "";
|
|
81
|
+
if (path.length === 0 || !(knownPaths.has(path) || path.includes("/"))) continue;
|
|
82
|
+
const start = (match.index ?? 0) + (match[1]?.length ?? 0);
|
|
83
|
+
if (start > last) out.push({ kind: "text", text: text.slice(last, start) });
|
|
84
|
+
out.push({ kind: "mention", text: `@${path}`, path });
|
|
85
|
+
last = start + 1 + path.length;
|
|
86
|
+
}
|
|
87
|
+
if (last < text.length) out.push({ kind: "text", text: text.slice(last) });
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sinyal "data Project/Session berubah" antar bagian UI yang memuat datanya
|
|
3
|
+
* sendiri (mis. homepage membuat Project -> sidebar perlu memuat ulang).
|
|
4
|
+
*/
|
|
5
|
+
const DATA_CHANGED_EVENT = "kcg-data-changed";
|
|
6
|
+
|
|
7
|
+
export function notifyDataChanged(): void {
|
|
8
|
+
if (typeof window === "undefined") return;
|
|
9
|
+
window.dispatchEvent(new Event(DATA_CHANGED_EVENT));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Berlangganan sinyal; mengembalikan fungsi berhenti berlangganan. */
|
|
13
|
+
export function onDataChanged(listener: () => void): () => void {
|
|
14
|
+
if (typeof window === "undefined") return () => {};
|
|
15
|
+
window.addEventListener(DATA_CHANGED_EVENT, listener);
|
|
16
|
+
return () => window.removeEventListener(DATA_CHANGED_EVENT, listener);
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headline sambutan homepage — dipilih acak tiap kali halaman dibuka.
|
|
3
|
+
* Logika murni (tanpa DOM) agar dapat diuji `bun test`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const HOME_HEADLINES: readonly string[] = [
|
|
7
|
+
"What are we building today?",
|
|
8
|
+
"Ready when you are.",
|
|
9
|
+
"Let's ship something great.",
|
|
10
|
+
"What should we tackle next?",
|
|
11
|
+
"Got a bug? Let's hunt it down.",
|
|
12
|
+
"Describe it, I'll code it.",
|
|
13
|
+
"Pick a project and let's go.",
|
|
14
|
+
"Where were we?",
|
|
15
|
+
"Time to make the code purr.",
|
|
16
|
+
"What's on your mind?",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pilih satu headline secara acak. `random` diinjeksi agar deterministik
|
|
21
|
+
* saat diuji (default `Math.random`). List kosong -> string kosong.
|
|
22
|
+
*/
|
|
23
|
+
export function pickHeadline(
|
|
24
|
+
headlines: readonly string[] = HOME_HEADLINES,
|
|
25
|
+
random: () => number = Math.random,
|
|
26
|
+
): string {
|
|
27
|
+
if (headlines.length === 0) return "";
|
|
28
|
+
const index = Math.min(headlines.length - 1, Math.floor(random() * headlines.length));
|
|
29
|
+
return headlines[Math.max(0, index)] ?? "";
|
|
30
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax highlighting blok kode — Shiki v4 (`shiki/core`, fine-grained).
|
|
3
|
+
*
|
|
4
|
+
* - Engine JavaScript RegExp (tanpa WASM Oniguruma): lebih ringan untuk
|
|
5
|
+
* browser; semua bahasa bawaan Shiki didukung sejak 3.9.
|
|
6
|
+
* - Bahasa & tema di-import dinamis (`shiki/langs/*.mjs`,
|
|
7
|
+
* `shiki/themes/*.mjs` — subpath resmi paket `shiki`) per kebutuhan: bundle awal tidak
|
|
8
|
+
* membawa grammar apa pun; grammar dimuat saat blok kode bahasa itu
|
|
9
|
+
* pertama kali tampil, lalu di-cache (highlighter singleton).
|
|
10
|
+
* - Dual theme (github-light / github-dark) sebagai CSS variable per token
|
|
11
|
+
* (`--shiki-light` / `--shiki-dark`); warna dipilih lewat kelas `.dark`
|
|
12
|
+
* aplikasi di `styles/globals.css`, jadi ganti tema tidak perlu
|
|
13
|
+
* highlight ulang.
|
|
14
|
+
* - Hasilnya berupa TOKEN (bukan string HTML) yang dirender sebagai elemen
|
|
15
|
+
* React — tidak ada `dangerouslySetInnerHTML`, isi kode tetap teks.
|
|
16
|
+
*/
|
|
17
|
+
import type { HighlighterCore, TokenStyles } from "shiki/core";
|
|
18
|
+
|
|
19
|
+
/** Loader grammar per nama bahasa kanonis Shiki. */
|
|
20
|
+
const LANG_LOADERS = {
|
|
21
|
+
javascript: () => import("shiki/langs/javascript.mjs"),
|
|
22
|
+
typescript: () => import("shiki/langs/typescript.mjs"),
|
|
23
|
+
jsx: () => import("shiki/langs/jsx.mjs"),
|
|
24
|
+
tsx: () => import("shiki/langs/tsx.mjs"),
|
|
25
|
+
json: () => import("shiki/langs/json.mjs"),
|
|
26
|
+
jsonc: () => import("shiki/langs/jsonc.mjs"),
|
|
27
|
+
shellscript: () => import("shiki/langs/shellscript.mjs"),
|
|
28
|
+
powershell: () => import("shiki/langs/powershell.mjs"),
|
|
29
|
+
python: () => import("shiki/langs/python.mjs"),
|
|
30
|
+
go: () => import("shiki/langs/go.mjs"),
|
|
31
|
+
rust: () => import("shiki/langs/rust.mjs"),
|
|
32
|
+
java: () => import("shiki/langs/java.mjs"),
|
|
33
|
+
kotlin: () => import("shiki/langs/kotlin.mjs"),
|
|
34
|
+
c: () => import("shiki/langs/c.mjs"),
|
|
35
|
+
cpp: () => import("shiki/langs/cpp.mjs"),
|
|
36
|
+
csharp: () => import("shiki/langs/csharp.mjs"),
|
|
37
|
+
php: () => import("shiki/langs/php.mjs"),
|
|
38
|
+
ruby: () => import("shiki/langs/ruby.mjs"),
|
|
39
|
+
swift: () => import("shiki/langs/swift.mjs"),
|
|
40
|
+
dart: () => import("shiki/langs/dart.mjs"),
|
|
41
|
+
lua: () => import("shiki/langs/lua.mjs"),
|
|
42
|
+
sql: () => import("shiki/langs/sql.mjs"),
|
|
43
|
+
graphql: () => import("shiki/langs/graphql.mjs"),
|
|
44
|
+
yaml: () => import("shiki/langs/yaml.mjs"),
|
|
45
|
+
toml: () => import("shiki/langs/toml.mjs"),
|
|
46
|
+
ini: () => import("shiki/langs/ini.mjs"),
|
|
47
|
+
xml: () => import("shiki/langs/xml.mjs"),
|
|
48
|
+
html: () => import("shiki/langs/html.mjs"),
|
|
49
|
+
css: () => import("shiki/langs/css.mjs"),
|
|
50
|
+
scss: () => import("shiki/langs/scss.mjs"),
|
|
51
|
+
vue: () => import("shiki/langs/vue.mjs"),
|
|
52
|
+
svelte: () => import("shiki/langs/svelte.mjs"),
|
|
53
|
+
markdown: () => import("shiki/langs/markdown.mjs"),
|
|
54
|
+
diff: () => import("shiki/langs/diff.mjs"),
|
|
55
|
+
docker: () => import("shiki/langs/docker.mjs"),
|
|
56
|
+
make: () => import("shiki/langs/make.mjs"),
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export type HighlightLang = keyof typeof LANG_LOADERS;
|
|
60
|
+
|
|
61
|
+
/** Alias umum di fence Markdown -> nama kanonis. */
|
|
62
|
+
const ALIASES: Record<string, HighlightLang> = {
|
|
63
|
+
js: "javascript",
|
|
64
|
+
mjs: "javascript",
|
|
65
|
+
cjs: "javascript",
|
|
66
|
+
ts: "typescript",
|
|
67
|
+
mts: "typescript",
|
|
68
|
+
cts: "typescript",
|
|
69
|
+
sh: "shellscript",
|
|
70
|
+
bash: "shellscript",
|
|
71
|
+
zsh: "shellscript",
|
|
72
|
+
shell: "shellscript",
|
|
73
|
+
console: "shellscript",
|
|
74
|
+
ps1: "powershell",
|
|
75
|
+
ps: "powershell",
|
|
76
|
+
py: "python",
|
|
77
|
+
golang: "go",
|
|
78
|
+
rs: "rust",
|
|
79
|
+
kt: "kotlin",
|
|
80
|
+
"c++": "cpp",
|
|
81
|
+
cs: "csharp",
|
|
82
|
+
"c#": "csharp",
|
|
83
|
+
rb: "ruby",
|
|
84
|
+
gql: "graphql",
|
|
85
|
+
yml: "yaml",
|
|
86
|
+
htm: "html",
|
|
87
|
+
md: "markdown",
|
|
88
|
+
patch: "diff",
|
|
89
|
+
dockerfile: "docker",
|
|
90
|
+
makefile: "make",
|
|
91
|
+
svg: "xml",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Nama bahasa kanonis dari info-string fence (`ts`, `language-tsx`,
|
|
96
|
+
* `JSON`), atau null bila tidak didukung (tetap tampil sebagai teks polos).
|
|
97
|
+
*/
|
|
98
|
+
export function resolveLang(raw: string | null | undefined): HighlightLang | null {
|
|
99
|
+
if (!raw) return null;
|
|
100
|
+
const name = raw
|
|
101
|
+
.trim()
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.replace(/^language-/, "");
|
|
104
|
+
if (name in LANG_LOADERS) return name as HighlightLang;
|
|
105
|
+
return ALIASES[name] ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let highlighter: Promise<HighlighterCore> | null = null;
|
|
109
|
+
const loading = new Map<HighlightLang, Promise<void>>();
|
|
110
|
+
|
|
111
|
+
function getHighlighter(): Promise<HighlighterCore> {
|
|
112
|
+
highlighter ??= (async () => {
|
|
113
|
+
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([
|
|
114
|
+
import("shiki/core"),
|
|
115
|
+
import("shiki/engine/javascript"),
|
|
116
|
+
]);
|
|
117
|
+
return createHighlighterCore({
|
|
118
|
+
themes: [import("shiki/themes/github-light.mjs"), import("shiki/themes/github-dark.mjs")],
|
|
119
|
+
langs: [],
|
|
120
|
+
// `forgiving`: pola grammar yang tak bisa dikonversi tidak melempar
|
|
121
|
+
// error — kode tetap tampil (sebagian mungkin tak berwarna).
|
|
122
|
+
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
|
123
|
+
});
|
|
124
|
+
})();
|
|
125
|
+
return highlighter;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function ensureLang(h: HighlighterCore, lang: HighlightLang): Promise<void> {
|
|
129
|
+
if (h.getLoadedLanguages().includes(lang)) return;
|
|
130
|
+
let p = loading.get(lang);
|
|
131
|
+
if (!p) {
|
|
132
|
+
p = h.loadLanguage(LANG_LOADERS[lang]()).catch((e) => {
|
|
133
|
+
loading.delete(lang); // izinkan coba lagi bila import gagal (mis. offline)
|
|
134
|
+
throw e;
|
|
135
|
+
});
|
|
136
|
+
loading.set(lang, p);
|
|
137
|
+
}
|
|
138
|
+
await p;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Satu token hasil highlight: teks + warna light/dark (CSS variable). */
|
|
142
|
+
export interface CodeToken {
|
|
143
|
+
content: string;
|
|
144
|
+
/** `--shiki-light` / `--shiki-dark` + gaya font (italic/bold). */
|
|
145
|
+
style: Record<string, string>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Highlight kode jadi baris token. Gagal (bahasa tak dikenal / grammar
|
|
150
|
+
* gagal dimuat) -> null; pemanggil menampilkan teks polos.
|
|
151
|
+
*/
|
|
152
|
+
export async function highlightCode(
|
|
153
|
+
code: string,
|
|
154
|
+
lang: HighlightLang,
|
|
155
|
+
): Promise<CodeToken[][] | null> {
|
|
156
|
+
try {
|
|
157
|
+
const h = await getHighlighter();
|
|
158
|
+
await ensureLang(h, lang);
|
|
159
|
+
const lines = h.codeToTokensWithThemes(code, {
|
|
160
|
+
lang,
|
|
161
|
+
themes: { light: "github-light", dark: "github-dark" },
|
|
162
|
+
});
|
|
163
|
+
return lines.map((line) =>
|
|
164
|
+
line.map((t) => ({ content: t.content, style: tokenStyle(t.variants) })),
|
|
165
|
+
);
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Gaya satu token dari varian tema (hanya warna & font style). */
|
|
172
|
+
function tokenStyle(variants: Record<string, TokenStyles>) {
|
|
173
|
+
const style: Record<string, string> = {};
|
|
174
|
+
const light = variants.light;
|
|
175
|
+
const dark = variants.dark;
|
|
176
|
+
if (light?.color) style["--shiki-light"] = light.color;
|
|
177
|
+
if (dark?.color) style["--shiki-dark"] = dark.color;
|
|
178
|
+
// FontStyle bitmask Shiki: 1 = italic, 2 = bold, 4 = underline.
|
|
179
|
+
const fs = light?.fontStyle ?? 0;
|
|
180
|
+
if (fs > 0) {
|
|
181
|
+
if (fs & 1) style.fontStyle = "italic";
|
|
182
|
+
if (fs & 2) style.fontWeight = "600";
|
|
183
|
+
if (fs & 4) style.textDecoration = "underline";
|
|
184
|
+
}
|
|
185
|
+
return style;
|
|
186
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lebar kolom percakapan — SATU sumber untuk timeline, composer, dan kartu
|
|
3
|
+
* prompt, supaya tepi kiri-kanannya selalu sejajar.
|
|
4
|
+
*
|
|
5
|
+
* Kenapa bukan `max-w-3xl` (768px) di tiap tempat: timeline punya padding
|
|
6
|
+
* horizontal DI DALAM kolomnya (teks mulai 16px dari tepi), sedangkan
|
|
7
|
+
* composer & kartu prompt tidak — jadi dengan max-w yang sama, kotak input
|
|
8
|
+
* tampak 32px lebih lebar dari teks chat. `CONVERSATION_MAX_W` adalah lebar
|
|
9
|
+
* TEKS (736px = 768 - 2×16); timeline menambahkan padding-nya di luar nilai
|
|
10
|
+
* ini (`TIMELINE_MAX_W`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Lebar isi kolom (teks chat, kotak input, kartu prompt). */
|
|
14
|
+
export const CONVERSATION_MAX_W = "max-w-[46rem]";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lebar kotak timeline = isi + padding horizontalnya (`sm:px-4` = 2×1rem),
|
|
18
|
+
* sehingga TEKS timeline tepat selebar `CONVERSATION_MAX_W`.
|
|
19
|
+
*/
|
|
20
|
+
export const TIMELINE_MAX_W = "max-w-[48rem]";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logika murni lock screen (tanpa DOM) — dipisah agar dapat diuji `bun test`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const PIN_MAX = 12;
|
|
6
|
+
|
|
7
|
+
export type PinKeyAction =
|
|
8
|
+
| { type: "digit"; digit: string }
|
|
9
|
+
| { type: "backspace" }
|
|
10
|
+
| { type: "submit" }
|
|
11
|
+
| { type: "clear" };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Terjemahkan tombol keyboard fisik menjadi aksi PIN. Angka baris atas
|
|
15
|
+
* menghasilkan `key` "0"-"9". Numpad dibaca dari `code` (`Numpad0`-`Numpad9`)
|
|
16
|
+
* agar tetap jalan walau NumLock mati (saat itu `key` berisi panah/Home/End).
|
|
17
|
+
* Kombinasi dengan Ctrl/Cmd/Alt diabaikan agar pintasan browser tetap jalan.
|
|
18
|
+
*/
|
|
19
|
+
export function pinKeyAction(e: {
|
|
20
|
+
key: string;
|
|
21
|
+
code?: string;
|
|
22
|
+
ctrlKey?: boolean;
|
|
23
|
+
metaKey?: boolean;
|
|
24
|
+
altKey?: boolean;
|
|
25
|
+
}): PinKeyAction | null {
|
|
26
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return null;
|
|
27
|
+
if (/^[0-9]$/.test(e.key)) return { type: "digit", digit: e.key };
|
|
28
|
+
const numpad = /^Numpad([0-9])$/.exec(e.code ?? "");
|
|
29
|
+
if (numpad?.[1]) return { type: "digit", digit: numpad[1] };
|
|
30
|
+
if (e.key === "Backspace" || e.key === "Delete") return { type: "backspace" };
|
|
31
|
+
// `NumpadEnter` juga ber-key "Enter".
|
|
32
|
+
if (e.key === "Enter") return { type: "submit" };
|
|
33
|
+
if (e.key === "Escape") return { type: "clear" };
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Terapkan aksi ke PIN saat ini (batas panjang dijaga). */
|
|
38
|
+
export function applyPinAction(pin: string, action: PinKeyAction): string {
|
|
39
|
+
switch (action.type) {
|
|
40
|
+
case "digit":
|
|
41
|
+
return pin.length >= PIN_MAX ? pin : pin + action.digit;
|
|
42
|
+
case "backspace":
|
|
43
|
+
return pin.slice(0, -1);
|
|
44
|
+
case "clear":
|
|
45
|
+
return "";
|
|
46
|
+
case "submit":
|
|
47
|
+
return pin;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Ambil angka dari teks tempelan (mis. "48 29 15" -> "482915"). */
|
|
52
|
+
export function digitsFromPaste(text: string, current: string): string {
|
|
53
|
+
return (current + text.replace(/\D/g, "")).slice(0, PIN_MAX);
|
|
54
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pemilihan model LLM — logika murni (tanpa DOM / fetch), dipisah agar dapat
|
|
3
|
+
* diuji `bun test` seperti `lib/routes.ts` dan `lib/session-summary.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Dipakai bersama oleh `components/sessions/ModelPicker.tsx` (ganti model
|
|
6
|
+
* Session yang sedang berjalan) dan `components/sessions/SessionList.tsx`
|
|
7
|
+
* (pilih model saat membuat Session) supaya keduanya memakai aturan kunci,
|
|
8
|
+
* pencarian, dan pengelompokan yang sama.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ModelOption } from "@/server/services/opencode-client";
|
|
12
|
+
import type { SessionModel } from "@/types";
|
|
13
|
+
|
|
14
|
+
/** Nilai khusus "pakai model default opencode" (mengirim `model: null`). */
|
|
15
|
+
export const DEFAULT_MODEL_KEY = "default";
|
|
16
|
+
|
|
17
|
+
/** Pemisah dalam kunci gabungan; modelID boleh memuat "/" sehingga bukan "/". */
|
|
18
|
+
const KEY_SEPARATOR = "\u0000";
|
|
19
|
+
|
|
20
|
+
/** Kunci gabungan provider+model untuk dipakai sebagai nilai/`key` React. */
|
|
21
|
+
export function modelKey(providerID: string, modelID: string): string {
|
|
22
|
+
return `${providerID}${KEY_SEPARATOR}${modelID}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Kunci untuk sebuah `ModelOption`. */
|
|
26
|
+
export function optionKey(m: ModelOption): string {
|
|
27
|
+
return modelKey(m.providerID, m.modelID);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Kunci untuk model Session saat ini; `null` -> `DEFAULT_MODEL_KEY`. */
|
|
31
|
+
export function keyOfSessionModel(model: SessionModel | null): string {
|
|
32
|
+
return model ? modelKey(model.providerID, model.modelID) : DEFAULT_MODEL_KEY;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Kunci -> `SessionModel`. `DEFAULT_MODEL_KEY` dan kunci tak lengkap
|
|
37
|
+
* menghasilkan `null` (biarkan opencode memakai model default-nya).
|
|
38
|
+
*/
|
|
39
|
+
export function parseModelKey(key: string): SessionModel | null {
|
|
40
|
+
if (key === DEFAULT_MODEL_KEY) return null;
|
|
41
|
+
const [providerID, modelID] = key.split(KEY_SEPARATOR);
|
|
42
|
+
if (!providerID || !modelID) return null;
|
|
43
|
+
return { providerID, modelID };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Satu provider beserta model-modelnya, untuk render berkelompok. */
|
|
47
|
+
export interface ModelGroup {
|
|
48
|
+
providerID: string;
|
|
49
|
+
providerName: string;
|
|
50
|
+
models: ModelOption[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Kelompokkan model per provider, mempertahankan urutan kemunculan pertama
|
|
55
|
+
* tiap provider (urutan dari server dianggap sudah bermakna).
|
|
56
|
+
*/
|
|
57
|
+
export function groupModels(models: readonly ModelOption[]): ModelGroup[] {
|
|
58
|
+
const byProvider = new Map<string, ModelGroup>();
|
|
59
|
+
for (const m of models) {
|
|
60
|
+
let g = byProvider.get(m.providerID);
|
|
61
|
+
if (!g) {
|
|
62
|
+
g = { providerID: m.providerID, providerName: m.providerName, models: [] };
|
|
63
|
+
byProvider.set(m.providerID, g);
|
|
64
|
+
}
|
|
65
|
+
g.models.push(m);
|
|
66
|
+
}
|
|
67
|
+
return [...byProvider.values()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Cari model berdasarkan kata kunci: cocok pada nama model, modelID, atau
|
|
72
|
+
* nama provider (tanpa membedakan huruf besar/kecil). Seluruh kata dalam
|
|
73
|
+
* query harus cocok, sehingga "opus 5" menemukan "Claude Opus 5" walau
|
|
74
|
+
* urutan katanya berbeda dari teks aslinya.
|
|
75
|
+
*/
|
|
76
|
+
export function filterModels(models: readonly ModelOption[], query: string): ModelOption[] {
|
|
77
|
+
const terms = query
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.split(/\s+/)
|
|
80
|
+
.filter((t) => t.length > 0);
|
|
81
|
+
if (terms.length === 0) return [...models];
|
|
82
|
+
return models.filter((m) => {
|
|
83
|
+
const haystack = `${m.name} ${m.modelID} ${m.providerName}`.toLowerCase();
|
|
84
|
+
return terms.every((t) => haystack.includes(t));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Label ringkas model aktif untuk trigger picker. Model belum termuat dari
|
|
90
|
+
* server (daftar kosong) tetap tampil sebagai `modelID` — informasi yang
|
|
91
|
+
* sudah dimiliki Session, jadi trigger tidak pernah kosong.
|
|
92
|
+
*/
|
|
93
|
+
export function activeModelLabel(
|
|
94
|
+
model: SessionModel | null,
|
|
95
|
+
models: readonly ModelOption[],
|
|
96
|
+
): string {
|
|
97
|
+
if (model === null) return "Default model";
|
|
98
|
+
const found = models.find(
|
|
99
|
+
(m) => m.providerID === model.providerID && m.modelID === model.modelID,
|
|
100
|
+
);
|
|
101
|
+
return found?.name ?? model.modelID;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Batas panjang nama model di header (karakter) sebelum dipotong. */
|
|
105
|
+
export const MODEL_NAME_MAX_LENGTH = 22;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Nama model sependek mungkin untuk judul header.
|
|
109
|
+
*
|
|
110
|
+
* Provider sering memberi prefiks vendor pada `modelID`
|
|
111
|
+
* (`kiro/claude-opus-5`, `anthropic/claude-sonnet-4`), dan prefiks itu tidak
|
|
112
|
+
* menambah informasi saat nama sudah tampil di bawah judul Session. Segmen
|
|
113
|
+
* terakhir setelah `/` diambil, lalu dipotong bila masih terlalu panjang untuk
|
|
114
|
+
* lebar layar HP.
|
|
115
|
+
*/
|
|
116
|
+
export function shortModelName(label: string, maxLength: number = MODEL_NAME_MAX_LENGTH): string {
|
|
117
|
+
// Segmen kosong dibuang lebih dulu, sehingga trailing slash diabaikan
|
|
118
|
+
// (`a/b/` -> `b`) dan label yang hanya berisi slash menghasilkan "".
|
|
119
|
+
const segments = label.split("/").filter((s) => s.trim().length > 0);
|
|
120
|
+
const name = segments.at(-1)?.trim() ?? "";
|
|
121
|
+
if (maxLength <= 0) return "";
|
|
122
|
+
if (name.length <= maxLength) return name;
|
|
123
|
+
// Sisakan satu karakter untuk elipsis agar total tepat `maxLength`.
|
|
124
|
+
return `${name.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Ringkasan jumlah hasil pencarian untuk pembaca layar / footer dialog.
|
|
129
|
+
*/
|
|
130
|
+
export function describeModelCount(count: number): string {
|
|
131
|
+
if (count === 0) return "No matching models";
|
|
132
|
+
return count === 1 ? "1 model" : `${count} models`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alur welcome screen (onboarding) — logika murni, diuji tanpa DOM.
|
|
3
|
+
*
|
|
4
|
+
* Langkah: welcome -> profile -> lock -> done.
|
|
5
|
+
* - `profile` (nickname) boleh dikosongkan.
|
|
6
|
+
* - `lock` boleh dilewati (app tetap terbuka; Settings menampilkan
|
|
7
|
+
* peringatan seperti biasa).
|
|
8
|
+
*/
|
|
9
|
+
export const ONBOARDING_STEPS = ["welcome", "profile", "lock", "done"] as const;
|
|
10
|
+
export type OnboardingStep = (typeof ONBOARDING_STEPS)[number];
|
|
11
|
+
|
|
12
|
+
export function stepIndex(step: OnboardingStep): number {
|
|
13
|
+
return ONBOARDING_STEPS.indexOf(step);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function nextStep(step: OnboardingStep): OnboardingStep {
|
|
17
|
+
return ONBOARDING_STEPS[Math.min(stepIndex(step) + 1, ONBOARDING_STEPS.length - 1)] ?? "done";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function prevStep(step: OnboardingStep): OnboardingStep {
|
|
21
|
+
return ONBOARDING_STEPS[Math.max(stepIndex(step) - 1, 0)] ?? "welcome";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Arah animasi geser saat berpindah langkah (1 maju, -1 mundur). */
|
|
25
|
+
export function stepDirection(from: OnboardingStep, to: OnboardingStep): 1 | -1 {
|
|
26
|
+
return stepIndex(to) >= stepIndex(from) ? 1 : -1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Langkah yang ditampilkan sebagai titik progres (tanpa layar sambutan/akhir). */
|
|
30
|
+
export const PROGRESS_STEPS: readonly OnboardingStep[] = ["profile", "lock"];
|
|
31
|
+
|
|
32
|
+
/** Sapaan sesuai jam lokal. */
|
|
33
|
+
export function greeting(hour: number): string {
|
|
34
|
+
if (hour < 5) return "Good evening";
|
|
35
|
+
if (hour < 12) return "Good morning";
|
|
36
|
+
if (hour < 18) return "Good afternoon";
|
|
37
|
+
return "Good evening";
|
|
38
|
+
}
|