chore: remove dead code (59 unused files + ~125 unused exports) (#1835)
* chore: remove dead/unreferenced files across ui, vscode Remove 59 unused source files (components, hooks, lib utils, stores, barrels, and orphaned vscode github modules) that are not imported by any entry-reachable code. Also drop a stale test mock for the removed execCommands module. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove unused exported symbols (types, functions, consts, hooks) Remove exported symbols whose identifier is referenced nowhere in the repository (verified via repo-wide search), across ui types/contracts, lib utilities, sync layer, stores, and components. Also drop the few imports/private helpers orphaned by these removals. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove more unused exports (desktop, shortcuts, worktree, vscode) Continue removing repo-wide unreferenced exported functions, consts and types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and vscode gitService, with cascading orphaned helpers/imports cleaned up. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: add dead-code cleanup tooling * refactor: checkpoint dead-code cleanup * refactor: remove dead-code suppressions --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
4a37b9a005
commit
00821700de
@@ -30,8 +30,4 @@ export function getAgentColor(agentName: string | undefined) {
|
||||
|
||||
const paletteIndex = 1 + (Math.abs(hash) % (AGENT_COLOR_PALETTE.length - 1));
|
||||
return AGENT_COLOR_PALETTE[paletteIndex];
|
||||
}
|
||||
|
||||
export function getAgentColorPalette() {
|
||||
return AGENT_COLOR_PALETTE;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
|
||||
export type RuntimePlatform = 'web' | 'desktop' | 'vscode';
|
||||
type RuntimePlatform = 'web' | 'desktop' | 'vscode';
|
||||
|
||||
export interface RuntimeDescriptor {
|
||||
interface RuntimeDescriptor {
|
||||
platform: RuntimePlatform;
|
||||
|
||||
isDesktop: boolean;
|
||||
@@ -13,24 +13,18 @@ export interface RuntimeDescriptor {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
interface Subscription {
|
||||
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface RetryPolicy {
|
||||
interface RetryPolicy {
|
||||
maxRetries: number;
|
||||
initialDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
export interface TerminalTransportCapability {
|
||||
interface TerminalTransportCapability {
|
||||
preferred?: 'ws' | 'http' | 'sse';
|
||||
transports?: Array<'ws' | 'http' | 'sse'>;
|
||||
ws?: {
|
||||
@@ -99,7 +93,7 @@ export interface TerminalAPI {
|
||||
forceKill?(options: ForceKillOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export interface GitStatusFile {
|
||||
interface GitStatusFile {
|
||||
path: string;
|
||||
index: string;
|
||||
working_dir: string;
|
||||
@@ -181,7 +175,7 @@ export interface GitBranch {
|
||||
branches: Record<string, GitBranchDetails>;
|
||||
}
|
||||
|
||||
export interface GitCommitSummary {
|
||||
interface GitCommitSummary {
|
||||
changes: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
@@ -241,29 +235,18 @@ export interface CheckoutCommitResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface CherryPickRequest {
|
||||
hash: string;
|
||||
}
|
||||
export interface CherryPickResponse {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
export interface RevertCommitRequest {
|
||||
hash: string;
|
||||
}
|
||||
export interface RevertCommitResponse {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
export interface ResetToCommitRequest {
|
||||
hash: string;
|
||||
mode: 'soft' | 'mixed' | 'hard';
|
||||
force?: boolean;
|
||||
}
|
||||
export interface ResetToCommitResponse {
|
||||
success: boolean;
|
||||
}
|
||||
@@ -456,7 +439,7 @@ export interface GeneratedPullRequestDescription {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface GitWorktreeAPI {
|
||||
interface GitWorktreeAPI {
|
||||
list(directory: string): Promise<GitWorktreeInfo[]>;
|
||||
validate?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
|
||||
bootstrapStatus?(directory: string): Promise<GitWorktreeBootstrapStatus>;
|
||||
@@ -591,11 +574,11 @@ export interface CommandExecResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListDirectoryOptions {
|
||||
interface ListDirectoryOptions {
|
||||
respectGitignore?: boolean;
|
||||
}
|
||||
|
||||
export interface FileReadOptions {
|
||||
interface FileReadOptions {
|
||||
allowOutsideWorkspace?: boolean;
|
||||
outsideFileGrant?: string;
|
||||
optional?: boolean;
|
||||
@@ -707,7 +690,7 @@ export interface DirectoryPermissionRequest {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DirectoryPermissionResult {
|
||||
interface DirectoryPermissionResult {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
@@ -740,7 +723,7 @@ export interface NotificationsAPI {
|
||||
canNotify?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface DiagnosticsAPI {
|
||||
interface DiagnosticsAPI {
|
||||
downloadLogs(): Promise<{ fileName: string; content: string }>;
|
||||
}
|
||||
|
||||
@@ -796,13 +779,13 @@ export type GitHubUserSummary = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type GitHubRepoRef = {
|
||||
type GitHubRepoRef = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GitHubChecksSummary = {
|
||||
type GitHubChecksSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
@@ -865,7 +848,7 @@ export type GitHubPullRequest = {
|
||||
mergeableState?: string | null;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestHeadRepo = {
|
||||
type GitHubPullRequestHeadRepo = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
url: string;
|
||||
@@ -883,7 +866,7 @@ export type GitHubPullRequestSummary = GitHubPullRequest & {
|
||||
sourceRepo?: (GitHubRepoSelector & { source: string }) | null;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestFile = {
|
||||
type GitHubPullRequestFile = {
|
||||
filename: string;
|
||||
status?: string;
|
||||
additions?: number;
|
||||
@@ -892,7 +875,7 @@ export type GitHubPullRequestFile = {
|
||||
patch?: string;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestReviewComment = {
|
||||
type GitHubPullRequestReviewComment = {
|
||||
id: number;
|
||||
url: string;
|
||||
body: string;
|
||||
@@ -977,7 +960,7 @@ export type GitHubPullRequestMergeResult = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type GitHubIssueLabel = {
|
||||
type GitHubIssueLabel = {
|
||||
name: string;
|
||||
color?: string;
|
||||
};
|
||||
@@ -1052,7 +1035,7 @@ export type GitHubAuthStatus = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type GitHubAuthAccount = {
|
||||
type GitHubAuthAccount = {
|
||||
id: string;
|
||||
user: GitHubUserSummary;
|
||||
scope?: string;
|
||||
@@ -1156,9 +1139,9 @@ export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
|
||||
|
||||
// ============== Skills Catalog Types ==============
|
||||
|
||||
export type SkillsCatalogSourceId = string;
|
||||
type SkillsCatalogSourceId = string;
|
||||
|
||||
export type SkillsCatalogSourceType = 'github' | 'clawdhub';
|
||||
type SkillsCatalogSourceType = 'github' | 'clawdhub';
|
||||
|
||||
export interface SkillsCatalogSource {
|
||||
id: SkillsCatalogSourceId;
|
||||
@@ -1169,13 +1152,13 @@ export interface SkillsCatalogSource {
|
||||
sourceType?: SkillsCatalogSourceType;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogItemInstalledBadge {
|
||||
interface SkillsCatalogItemInstalledBadge {
|
||||
isInstalled: boolean;
|
||||
scope?: 'user' | 'project';
|
||||
source?: 'opencode' | 'agents' | 'claude';
|
||||
}
|
||||
|
||||
export interface ClawdHubSkillMetadata {
|
||||
interface ClawdHubSkillMetadata {
|
||||
slug: string;
|
||||
version: string;
|
||||
displayName?: string;
|
||||
@@ -1224,7 +1207,7 @@ export interface SkillsRepoScanRequest {
|
||||
gitIdentityId?: string;
|
||||
}
|
||||
|
||||
export type SkillsRepoScanError =
|
||||
type SkillsRepoScanError =
|
||||
| { kind: 'authRequired'; message: string; sshOnly: true; identities?: Array<{ id: string; name: string }> }
|
||||
| { kind: 'invalidSource'; message: string }
|
||||
| { kind: 'gitUnavailable'; message: string }
|
||||
@@ -1237,7 +1220,7 @@ export interface SkillsRepoScanResponse {
|
||||
error?: SkillsRepoScanError;
|
||||
}
|
||||
|
||||
export interface SkillsInstallSelection {
|
||||
interface SkillsInstallSelection {
|
||||
skillDir: string;
|
||||
/** ClawdHub-specific metadata for installation */
|
||||
clawdhub?: {
|
||||
|
||||
@@ -42,19 +42,6 @@ const extractRawAppearance = (data: unknown): RawAppearancePayload | null => {
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const saveAppearancePreferences = (preferences: AppearancePreferences): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem('appearance-preferences', JSON.stringify(preferences));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const applyAppearancePreferences = (preferences: AppearancePreferences): void => {
|
||||
const store = useUIStore.getState();
|
||||
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
|
||||
export const defaultCodeDark = {
|
||||
'code[class*="language-"]': {
|
||||
color: '#cdccc3',
|
||||
background: 'transparent',
|
||||
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
|
||||
fontSize: '1em',
|
||||
textAlign: 'left' as const,
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal' as const,
|
||||
wordWrap: 'normal' as const,
|
||||
lineHeight: '1.5',
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none' as const,
|
||||
MozHyphens: 'none' as const,
|
||||
msHyphens: 'none' as const,
|
||||
hyphens: 'none' as const,
|
||||
},
|
||||
'pre[class*="language-"]': {
|
||||
color: '#cdccc3',
|
||||
background: 'transparent',
|
||||
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
|
||||
fontSize: '1em',
|
||||
textAlign: 'left' as const,
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal' as const,
|
||||
wordWrap: 'normal' as const,
|
||||
lineHeight: '1.5',
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none' as const,
|
||||
MozHyphens: 'none' as const,
|
||||
msHyphens: 'none' as const,
|
||||
hyphens: 'none' as const,
|
||||
padding: '1em',
|
||||
margin: '0',
|
||||
overflow: 'auto',
|
||||
},
|
||||
comment: {
|
||||
color: '#6b6964',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
prolog: {
|
||||
color: '#6b6964',
|
||||
},
|
||||
doctype: {
|
||||
color: '#6b6964',
|
||||
},
|
||||
cdata: {
|
||||
color: '#6b6964',
|
||||
},
|
||||
punctuation: {
|
||||
color: '#6b6963',
|
||||
},
|
||||
property: {
|
||||
color: '#d29470',
|
||||
},
|
||||
tag: {
|
||||
color: '#d8886d',
|
||||
},
|
||||
boolean: {
|
||||
color: '#d39373',
|
||||
},
|
||||
number: {
|
||||
color: '#d39373',
|
||||
},
|
||||
constant: {
|
||||
color: '#6cacd6',
|
||||
},
|
||||
symbol: {
|
||||
color: '#6cacd6',
|
||||
},
|
||||
deleted: {
|
||||
color: '#d98678',
|
||||
},
|
||||
selector: {
|
||||
color: '#81af6c',
|
||||
},
|
||||
'attr-name': {
|
||||
color: '#c2974d',
|
||||
},
|
||||
string: {
|
||||
color: '#81af6c',
|
||||
},
|
||||
char: {
|
||||
color: '#81af6c',
|
||||
},
|
||||
builtin: {
|
||||
color: '#5aa9d9',
|
||||
},
|
||||
inserted: {
|
||||
color: '#81af6c',
|
||||
},
|
||||
operator: {
|
||||
color: '#d29470',
|
||||
},
|
||||
entity: {
|
||||
color: '#edb449',
|
||||
cursor: 'help',
|
||||
},
|
||||
url: {
|
||||
color: '#5aa9d9',
|
||||
},
|
||||
'.language-css .token.string': {
|
||||
color: '#81af6c',
|
||||
},
|
||||
'.style .token.string': {
|
||||
color: '#81af6c',
|
||||
},
|
||||
variable: {
|
||||
color: '#d29470',
|
||||
},
|
||||
atrule: {
|
||||
color: '#c2974d',
|
||||
},
|
||||
'attr-value': {
|
||||
color: '#81af6c',
|
||||
},
|
||||
function: {
|
||||
color: '#5aa9d9',
|
||||
},
|
||||
'class-name': {
|
||||
color: '#c2974d',
|
||||
},
|
||||
keyword: {
|
||||
color: '#d8886d',
|
||||
},
|
||||
regex: {
|
||||
color: '#81af6c',
|
||||
},
|
||||
important: {
|
||||
color: '#d8886d',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bold: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
namespace: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
title: {
|
||||
color: '#edb449',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'code-block': {
|
||||
color: '#81af6c',
|
||||
},
|
||||
'code-snippet': {
|
||||
color: '#81af6c',
|
||||
},
|
||||
list: {
|
||||
color: '#d29470',
|
||||
},
|
||||
hr: {
|
||||
color: '#6b6963',
|
||||
},
|
||||
table: {
|
||||
color: '#5aa9d9',
|
||||
},
|
||||
blockquote: {
|
||||
color: '#6b6964',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
strike: {
|
||||
textDecoration: 'line-through',
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultCodeLight = {
|
||||
'code[class*="language-"]': {
|
||||
color: '#403e3c',
|
||||
background: 'transparent',
|
||||
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
|
||||
fontSize: '1em',
|
||||
textAlign: 'left' as const,
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal' as const,
|
||||
wordWrap: 'normal' as const,
|
||||
lineHeight: '1.5',
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none' as const,
|
||||
MozHyphens: 'none' as const,
|
||||
msHyphens: 'none' as const,
|
||||
hyphens: 'none' as const,
|
||||
},
|
||||
'pre[class*="language-"]': {
|
||||
color: '#403e3c',
|
||||
background: 'transparent',
|
||||
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
|
||||
fontSize: '1em',
|
||||
textAlign: 'left' as const,
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal' as const,
|
||||
wordWrap: 'normal' as const,
|
||||
lineHeight: '1.5',
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none' as const,
|
||||
MozHyphens: 'none' as const,
|
||||
msHyphens: 'none' as const,
|
||||
hyphens: 'none' as const,
|
||||
padding: '1em',
|
||||
margin: '0',
|
||||
overflow: 'auto',
|
||||
},
|
||||
comment: {
|
||||
color: '#7a756a',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
prolog: {
|
||||
color: '#7a756a',
|
||||
},
|
||||
doctype: {
|
||||
color: '#7a756a',
|
||||
},
|
||||
cdata: {
|
||||
color: '#7a756a',
|
||||
},
|
||||
punctuation: {
|
||||
color: '#6b6963',
|
||||
},
|
||||
property: {
|
||||
color: '#c07845',
|
||||
},
|
||||
tag: {
|
||||
color: '#b96f55',
|
||||
},
|
||||
boolean: {
|
||||
color: '#b97659',
|
||||
},
|
||||
number: {
|
||||
color: '#b97659',
|
||||
},
|
||||
constant: {
|
||||
color: '#4791ba',
|
||||
},
|
||||
symbol: {
|
||||
color: '#4791ba',
|
||||
},
|
||||
deleted: {
|
||||
color: '#c15748',
|
||||
},
|
||||
selector: {
|
||||
color: '#6a9354',
|
||||
},
|
||||
'attr-name': {
|
||||
color: '#9f7d3e',
|
||||
},
|
||||
string: {
|
||||
color: '#6a9354',
|
||||
},
|
||||
char: {
|
||||
color: '#6a9354',
|
||||
},
|
||||
builtin: {
|
||||
color: '#4791ba',
|
||||
},
|
||||
inserted: {
|
||||
color: '#6a9354',
|
||||
},
|
||||
operator: {
|
||||
color: '#c07845',
|
||||
},
|
||||
entity: {
|
||||
color: '#d09930',
|
||||
cursor: 'help',
|
||||
},
|
||||
url: {
|
||||
color: '#4791ba',
|
||||
},
|
||||
'.language-css .token.string': {
|
||||
color: '#6a9354',
|
||||
},
|
||||
'.style .token.string': {
|
||||
color: '#6a9354',
|
||||
},
|
||||
variable: {
|
||||
color: '#c07845',
|
||||
},
|
||||
atrule: {
|
||||
color: '#9f7d3e',
|
||||
},
|
||||
'attr-value': {
|
||||
color: '#6a9354',
|
||||
},
|
||||
function: {
|
||||
color: '#4791ba',
|
||||
},
|
||||
'class-name': {
|
||||
color: '#9f7d3e',
|
||||
},
|
||||
keyword: {
|
||||
color: '#b96f55',
|
||||
},
|
||||
regex: {
|
||||
color: '#6a9354',
|
||||
},
|
||||
important: {
|
||||
color: '#b96f55',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bold: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
namespace: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
title: {
|
||||
color: '#d09930',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'code-block': {
|
||||
color: '#6a9354',
|
||||
},
|
||||
'code-snippet': {
|
||||
color: '#6a9354',
|
||||
},
|
||||
list: {
|
||||
color: '#c07845',
|
||||
},
|
||||
hr: {
|
||||
color: '#6b6963',
|
||||
},
|
||||
table: {
|
||||
color: '#4791ba',
|
||||
},
|
||||
blockquote: {
|
||||
color: '#7a756a',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
strike: {
|
||||
textDecoration: 'line-through',
|
||||
},
|
||||
};
|
||||
@@ -5,9 +5,10 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
export type AssistantNotificationPayload = {
|
||||
title?: string;
|
||||
body?: string;
|
||||
type ManagedRemoteTunnelPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
export type UpdateInfo = {
|
||||
@@ -35,12 +36,6 @@ export type SkillCatalogConfig = {
|
||||
gitIdentityId?: string;
|
||||
};
|
||||
|
||||
export type ManagedRemoteTunnelPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
export type DesktopSettings = {
|
||||
themeId?: string;
|
||||
useSystemTheme?: boolean;
|
||||
@@ -526,28 +521,6 @@ export const stopAccessingDirectory = async (
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const sendAssistantCompletionNotification = async (
|
||||
payload?: AssistantNotificationPayload
|
||||
): Promise<boolean> => {
|
||||
if (hasDesktopInvoke()) {
|
||||
try {
|
||||
await invokeDesktop('desktop_notify', {
|
||||
payload: {
|
||||
title: payload?.title,
|
||||
body: payload?.body,
|
||||
tag: 'openchamber-agent-complete',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to send assistant completion notification', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
||||
if (!hasDesktopInvoke()) {
|
||||
return null;
|
||||
@@ -787,58 +760,6 @@ export const openDesktopFileInApp = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const filterInstalledDesktopApps = async (apps: string[]): Promise<string[]> => {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : [];
|
||||
if (candidate.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invokeDesktop<string[]>('desktop_filter_installed_apps', {
|
||||
apps: candidate,
|
||||
});
|
||||
return Array.isArray(result) ? result.filter((value) => typeof value === 'string') : [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to check installed apps', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchDesktopAppIcons = async (apps: string[]): Promise<Record<string, string>> => {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : [];
|
||||
if (candidate.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invokeDesktop<unknown[]>('desktop_fetch_app_icons', {
|
||||
apps: candidate,
|
||||
});
|
||||
if (!Array.isArray(result)) {
|
||||
return {};
|
||||
}
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of result) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const candidateEntry = entry as { app?: unknown; data_url?: unknown };
|
||||
if (typeof candidateEntry.app !== 'string' || typeof candidateEntry.data_url !== 'string') continue;
|
||||
map[candidateEntry.app] = candidateEntry.data_url;
|
||||
}
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch installed app icons', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export type InstalledDesktopAppInfo = {
|
||||
name: string;
|
||||
iconDataUrl?: string | null;
|
||||
@@ -898,16 +819,3 @@ export const fetchDesktopInstalledApps = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const clearDesktopCache = async (): Promise<boolean> => {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await invokeDesktop('desktop_clear_cache');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear cache', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,27 +24,6 @@ export const startDesktopWindowDrag = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const isDesktopWindowFullscreen = async (): Promise<boolean> => {
|
||||
if (!isDesktopShell()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean(await invokeDesktopCommand('desktop_is_window_fullscreen'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const onDesktopWindowResized = (handler: () => void): (() => void) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
};
|
||||
|
||||
export const setDesktopWindowTitle = async (title: string): Promise<void> => {
|
||||
if (!isDesktopShell()) {
|
||||
return;
|
||||
@@ -84,25 +63,3 @@ export const getDesktopAppVersion = async (): Promise<string | null> => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const readDesktopFile = async (
|
||||
path: string,
|
||||
): Promise<{ mime: string; base64: string; size?: number }> => {
|
||||
return invokeDesktopCommand('desktop_read_file', { path });
|
||||
};
|
||||
|
||||
export const readDesktopFileAsDataUrl = async (path: string): Promise<string> => {
|
||||
const result = await readDesktopFile(path);
|
||||
return `data:${result.mime || 'application/octet-stream'};base64,${result.base64}`;
|
||||
};
|
||||
|
||||
export const listenDesktopNativeDragDrop = async (
|
||||
handler: (event: unknown) => void,
|
||||
): Promise<(() => void) | null> => {
|
||||
if (!isDesktopShell() || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
void handler;
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -9,11 +9,11 @@ type DesktopBridgeGlobal = {
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
|
||||
export type DesktopSshRemoteMode = 'managed' | 'external';
|
||||
export type DesktopSshInstallMethod = 'npm' | 'bun' | 'download_release' | 'upload_bundle';
|
||||
export type DesktopSshSecretStore = 'never' | 'settings';
|
||||
type DesktopSshRemoteMode = 'managed' | 'external';
|
||||
type DesktopSshInstallMethod = 'npm' | 'bun' | 'download_release' | 'upload_bundle';
|
||||
type DesktopSshSecretStore = 'never' | 'settings';
|
||||
|
||||
export type DesktopSshStoredSecret = {
|
||||
type DesktopSshStoredSecret = {
|
||||
enabled: boolean;
|
||||
value?: string;
|
||||
store: DesktopSshSecretStore;
|
||||
@@ -62,7 +62,7 @@ export type DesktopSshInstancesConfig = {
|
||||
instances: DesktopSshInstance[];
|
||||
};
|
||||
|
||||
export type DesktopSshPhase =
|
||||
type DesktopSshPhase =
|
||||
| 'idle'
|
||||
| 'config_resolved'
|
||||
| 'auth_check'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
||||
type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
||||
|
||||
export interface DeviceInfo {
|
||||
isMobile: boolean;
|
||||
@@ -14,13 +14,7 @@ export interface DeviceInfo {
|
||||
hasTouchOnlyPointer: boolean;
|
||||
}
|
||||
|
||||
export const CSS_DEVICE_VARIABLES = {
|
||||
IS_MOBILE: 'var(--is-mobile)',
|
||||
DEVICE_TYPE: 'var(--device-type)',
|
||||
HAS_TOUCH_INPUT: 'var(--has-touch-input)',
|
||||
} as const;
|
||||
|
||||
export const BREAKPOINTS = {
|
||||
const BREAKPOINTS = {
|
||||
xs: 0,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
@@ -291,7 +285,7 @@ export function isMobileDeviceViaCSS(): boolean {
|
||||
return isMobileValue === '1' || isMobileValue === 'true';
|
||||
}
|
||||
|
||||
export const isStandalonePwaRuntime = (): boolean => {
|
||||
const isStandalonePwaRuntime = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const standaloneNavigator = navigator as Navigator & { standalone?: boolean };
|
||||
@@ -302,7 +296,7 @@ export const isStandalonePwaRuntime = (): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
export const isTabletStandalonePwaRuntime = (): boolean => {
|
||||
const isTabletStandalonePwaRuntime = (): boolean => {
|
||||
if (typeof window === 'undefined' || isDesktopShell()) return false;
|
||||
|
||||
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
|
||||
|
||||
@@ -10,23 +10,6 @@ const PATCH_DIFF_CACHE_LIMIT = 64;
|
||||
const DEFAULT_PATCH_CONTEXT_LINES = 3;
|
||||
const patchFileDiffCache = new Map<string, FileDiffMetadata>();
|
||||
|
||||
export const fileDiffFromContent = (
|
||||
file: string,
|
||||
before: string,
|
||||
after: string,
|
||||
contextLines = DEFAULT_PATCH_CONTEXT_LINES
|
||||
): FileDiffMetadata => {
|
||||
if (!before && !after) {
|
||||
return emptyFileDiff(file);
|
||||
}
|
||||
|
||||
return parseDiffFromFile(
|
||||
{ name: file, contents: before },
|
||||
{ name: file, contents: after },
|
||||
{ context: contextLines },
|
||||
);
|
||||
};
|
||||
|
||||
export const fileDiffFromPatch = (
|
||||
file: string,
|
||||
patch: string,
|
||||
|
||||
@@ -20,7 +20,7 @@ const readStoredShowHidden = (): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
export const notifyDirectoryShowHiddenChanged = () => {
|
||||
const notifyDirectoryShowHiddenChanged = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -67,5 +67,3 @@ export const useDirectoryShowHidden = (): boolean => {
|
||||
|
||||
return showHidden;
|
||||
};
|
||||
|
||||
export const DIRECTORY_SHOW_HIDDEN_STORAGE_KEY = SHOW_HIDDEN_STORAGE_KEY;
|
||||
|
||||
@@ -34,8 +34,6 @@ export const BUILTIN_STARTERS: readonly BuiltInStarter[] = [
|
||||
const BUILTIN_BY_NAME = new Map<string, BuiltInStarter>(BUILTIN_STARTERS.map((s) => [s.name, s]));
|
||||
|
||||
export const getBuiltInStarter = (name: string): BuiltInStarter | undefined => BUILTIN_BY_NAME.get(name);
|
||||
export const isBuiltInStarter = (ref: DraftStarterRef): boolean =>
|
||||
ref.type === 'command' && BUILTIN_BY_NAME.has(ref.name);
|
||||
|
||||
// Default global starter set (used until the user customizes the global list).
|
||||
export const DEFAULT_GLOBAL_STARTERS: readonly DraftStarterRef[] = BUILTIN_STARTERS.map((s) => ({
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { CommandExecResult, FilesAPI } from '@/lib/api/types';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type ExecResult = { success: boolean; results: CommandExecResult[] };
|
||||
|
||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
if (typeof DEFAULT_BASE_URL === 'string' && DEFAULT_BASE_URL.startsWith('/')) {
|
||||
return DEFAULT_BASE_URL;
|
||||
}
|
||||
return DEFAULT_BASE_URL;
|
||||
};
|
||||
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function execCommands(commands: string[], cwd: string): Promise<ExecResult> {
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.execCommands) {
|
||||
return runtimeFiles.execCommands(commands, cwd);
|
||||
}
|
||||
|
||||
const response = await runtimeFetch(`${getBaseUrl()}/fs/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ commands, cwd, background: false }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Command exec failed');
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { success?: boolean; results?: CommandExecResult[] }
|
||||
| null;
|
||||
|
||||
return {
|
||||
success: Boolean(payload?.success),
|
||||
results: Array.isArray(payload?.results) ? payload!.results! : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function execCommand(command: string, cwd: string): Promise<CommandExecResult> {
|
||||
const result = await execCommands([command], cwd);
|
||||
const first = result.results[0];
|
||||
if (!first) {
|
||||
return { command, success: result.success };
|
||||
}
|
||||
return first;
|
||||
}
|
||||
@@ -245,5 +245,3 @@ export const getFileTypeIconHref = (
|
||||
const iconName = selectVariantIconName(baseIconName, options?.themeVariant || 'dark');
|
||||
return `#${iconName}`;
|
||||
};
|
||||
|
||||
export const getFileTypeIconUrl = getFileTypeIconHref;
|
||||
|
||||
@@ -18,7 +18,7 @@ const readStoredShowGitignored = (): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
export const notifyFilesViewShowGitignoredChanged = () => {
|
||||
const notifyFilesViewShowGitignoredChanged = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -65,5 +65,3 @@ export const useFilesViewShowGitignored = (): boolean => {
|
||||
|
||||
return showGitignored;
|
||||
};
|
||||
|
||||
export const FILES_VIEW_SHOW_GITIGNORED_STORAGE_KEY = SHOW_GITIGNORED_STORAGE_KEY;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Uses Ubuntu-style adjective-noun word pairs for memorable, collision-resistant naming.
|
||||
*/
|
||||
|
||||
import { getGitBranches } from '@/lib/gitApi';
|
||||
|
||||
const ADJECTIVES = [
|
||||
'artful', 'bionic', 'cosmic', 'disco', 'focal', 'groovy', 'jammy', 'kinetic',
|
||||
'lunar', 'noble', 'bold', 'brave', 'calm', 'eager', 'gentle', 'happy', 'keen',
|
||||
@@ -41,36 +39,3 @@ export function generateBranchName(prefix?: string): string {
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique branch name that doesn't conflict with existing branches.
|
||||
* @param projectDirectory - Project directory to check for existing branches
|
||||
* @param prefix - Optional branch prefix
|
||||
* @param maxAttempts - Maximum attempts to generate a unique name (default: 10)
|
||||
* @returns Unique branch name, or null if all attempts failed
|
||||
*/
|
||||
export async function generateUniqueBranchName(
|
||||
projectDirectory: string,
|
||||
prefix?: string,
|
||||
maxAttempts: number = 10
|
||||
): Promise<string | null> {
|
||||
let existingBranches: Set<string>;
|
||||
|
||||
try {
|
||||
const branches = await getGitBranches(projectDirectory);
|
||||
existingBranches = new Set(branches?.all ?? []);
|
||||
} catch {
|
||||
// If we can't get branches, just generate without checking
|
||||
return generateBranchName(prefix);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const candidate = generateBranchName(prefix);
|
||||
if (!existingBranches.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted, return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { I18nProvider } from './context';
|
||||
export { getBootstrapMessages, readStoredLocaleForBootstrap } from './bootstrap';
|
||||
export { getCurrentIntlLocale, getIntlLocale } from './intl';
|
||||
export { getCurrentIntlLocale } from './intl';
|
||||
export { useI18n } from './useI18n';
|
||||
export { formatMessage, initializeLocale, useI18nStore } from './store';
|
||||
export type { I18nKey, I18nParams, Locale } from './store';
|
||||
|
||||
@@ -13,6 +13,6 @@ const INTL_LOCALE_BY_LOCALE: Record<Locale, string> = {
|
||||
pl: 'pl-PL',
|
||||
};
|
||||
|
||||
export const getIntlLocale = (locale: Locale): string => INTL_LOCALE_BY_LOCALE[locale] ?? 'en-US';
|
||||
const getIntlLocale = (locale: Locale): string => INTL_LOCALE_BY_LOCALE[locale] ?? 'en-US';
|
||||
|
||||
export const getCurrentIntlLocale = (): string => getIntlLocale(useI18nStore.getState().locale);
|
||||
|
||||
@@ -61,7 +61,7 @@ export function normalizeLocale(value: string | undefined | null): Locale {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function readStoredLocale(): Locale | undefined {
|
||||
function readStoredLocale(): Locale | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides parsing, tree building, flattening, and path utilities.
|
||||
*/
|
||||
|
||||
export type JsonTreeNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
|
||||
type JsonTreeNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
|
||||
|
||||
export interface JsonTreeNode {
|
||||
id: string;
|
||||
@@ -44,7 +44,7 @@ function getType(value: unknown): JsonTreeNodeType {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
export function getNodePath(pathSegments: string[]): string {
|
||||
function getNodePath(pathSegments: string[]): string {
|
||||
if (pathSegments.length === 0) return 'root';
|
||||
let result = 'root';
|
||||
for (const segment of pathSegments) {
|
||||
@@ -57,38 +57,6 @@ export function getNodePath(pathSegments: string[]): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseNodePath(pathKey: string): string[] {
|
||||
if (pathKey === 'root' || pathKey === '') return [];
|
||||
const withoutRoot = pathKey.startsWith('root.') ? pathKey.slice(5) : pathKey.startsWith('root[') ? pathKey.slice(4) : pathKey;
|
||||
const segments: string[] = [];
|
||||
let current = '';
|
||||
let i = 0;
|
||||
while (i < withoutRoot.length) {
|
||||
const ch = withoutRoot[i];
|
||||
if (ch === '.') {
|
||||
if (current) segments.push(current);
|
||||
current = '';
|
||||
i++;
|
||||
} else if (ch === '[') {
|
||||
if (current) segments.push(current);
|
||||
current = '';
|
||||
i++;
|
||||
let bracket = '';
|
||||
while (i < withoutRoot.length && withoutRoot[i] !== ']') {
|
||||
bracket += withoutRoot[i];
|
||||
i++;
|
||||
}
|
||||
segments.push(bracket);
|
||||
i++;
|
||||
} else {
|
||||
current += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (current) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
let nodeCount = 0;
|
||||
|
||||
function buildTreeNode(
|
||||
@@ -208,15 +176,3 @@ export function getExpandableIdsAboveDepth(root: JsonTreeNode | null, maxDepth:
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function isJsonParseable(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') return false;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length < 2) return false;
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface MagicPromptOverridesPayload {
|
||||
|
||||
const API_ENDPOINT = '/api/magic-prompts';
|
||||
|
||||
export const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [
|
||||
const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [
|
||||
{
|
||||
id: 'git.commit.generate.visible',
|
||||
title: 'Commit Generation Visible Prompt',
|
||||
@@ -951,11 +951,6 @@ export const fetchMagicPromptOverrides = async (): Promise<Record<string, string
|
||||
return inFlightOverridesRequest;
|
||||
};
|
||||
|
||||
export const invalidateMagicPromptOverridesCache = () => {
|
||||
cachedOverrides = null;
|
||||
inFlightOverridesRequest = null;
|
||||
};
|
||||
|
||||
export const getMagicPromptDefinition = (id: MagicPromptId): MagicPromptDefinition => {
|
||||
const definition = MAGIC_PROMPT_DEFINITION_BY_ID.get(id);
|
||||
if (!definition) {
|
||||
@@ -968,7 +963,7 @@ export const getDefaultMagicPromptTemplate = (id: MagicPromptId): string => {
|
||||
return getMagicPromptDefinition(id).template;
|
||||
};
|
||||
|
||||
export const getEffectiveMagicPromptTemplate = async (id: MagicPromptId): Promise<string> => {
|
||||
const getEffectiveMagicPromptTemplate = async (id: MagicPromptId): Promise<string> => {
|
||||
const overrides = await fetchMagicPromptOverrides().catch((): Record<string, string> => ({}));
|
||||
const override = overrides[id];
|
||||
if (typeof override === 'string') {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Part } from "@opencode-ai/sdk/v2";
|
||||
import { isFullySyntheticMessage } from "@/lib/messages/synthetic";
|
||||
|
||||
export interface MessageInfo {
|
||||
interface MessageInfo {
|
||||
id: string;
|
||||
role: string;
|
||||
time?: {
|
||||
@@ -18,92 +17,3 @@ export interface MessageRecord {
|
||||
info: MessageInfo & Record<string, any>;
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
export function isMessageComplete(messageInfo: MessageInfo, parts: Part[] = []): boolean {
|
||||
if (isFullySyntheticMessage(parts)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const timeInfo = messageInfo?.time ?? {};
|
||||
const completedAt = typeof timeInfo?.completed === 'number' ? timeInfo.completed : undefined;
|
||||
const messageStatus = messageInfo?.status;
|
||||
|
||||
const hasStopFinish = messageInfo.finish === 'stop';
|
||||
|
||||
const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed';
|
||||
if (!hasCompletedFlag || !hasStopFinish) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasActiveTools = parts.some((part) => {
|
||||
switch (part.type) {
|
||||
case 'reasoning': {
|
||||
const time = (part as any)?.time;
|
||||
return !time || typeof time.end === 'undefined';
|
||||
}
|
||||
case 'tool': {
|
||||
const status = (part as any)?.state?.status;
|
||||
return status === 'running' || status === 'pending';
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return !hasActiveTools;
|
||||
}
|
||||
|
||||
export function getLatestAssistantMessageId(messages: MessageRecord[]): string | null {
|
||||
const assistantMessages = messages
|
||||
.filter(msg => msg.info.role === 'assistant' && !isFullySyntheticMessage(msg.parts))
|
||||
.sort((a, b) => (a.info.id || "").localeCompare(b.info.id || ""));
|
||||
|
||||
return assistantMessages.length > 0
|
||||
? assistantMessages[assistantMessages.length - 1].info.id
|
||||
: null;
|
||||
}
|
||||
|
||||
export function hasAnimatingWork(messages: MessageRecord[]): boolean {
|
||||
if (messages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFullySyntheticMessage(message.parts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isMessageComplete(message.info, message.parts)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldContinueStreaming(
|
||||
messages: MessageRecord[],
|
||||
currentStreamingId: string | null
|
||||
): boolean {
|
||||
const latestId = getLatestAssistantMessageId(messages);
|
||||
if (!latestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentStreamingId && currentStreamingId !== latestId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const latestMessage = messages.find(
|
||||
(msg) => msg.info.id === latestId && !isFullySyntheticMessage(msg.parts)
|
||||
);
|
||||
if (!latestMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isMessageComplete(latestMessage.info, latestMessage.parts);
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
const DB_NAME = 'openchamber-message-cursors';
|
||||
const STORE_NAME = 'cursors';
|
||||
const DB_VERSION = 1;
|
||||
const FALLBACK_KEY = 'openchamber.messageCursors';
|
||||
|
||||
type CursorRecord = {
|
||||
messageId: string;
|
||||
completedAt: number;
|
||||
};
|
||||
|
||||
const isBrowser = () => typeof window !== 'undefined';
|
||||
|
||||
const hasIndexedDbSupport = () => {
|
||||
return isBrowser() && typeof indexedDB !== 'undefined';
|
||||
};
|
||||
|
||||
const openDatabase = (): Promise<IDBDatabase> => {
|
||||
if (!hasIndexedDbSupport()) {
|
||||
return Promise.reject(new Error('IndexedDB not supported'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error ?? new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
const getDatabase = (): Promise<IDBDatabase> => {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDatabase()
|
||||
.then((db) => {
|
||||
db.onclose = () => {
|
||||
dbPromise = null;
|
||||
};
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
};
|
||||
return db;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
dbPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
};
|
||||
|
||||
const readFallback = (): Record<string, CursorRecord> => {
|
||||
if (!isBrowser()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(FALLBACK_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Record<string, CursorRecord>;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeFallback = (map: Record<string, CursorRecord>) => {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(FALLBACK_KEY, JSON.stringify(map));
|
||||
} catch { /* ignored */ }
|
||||
};
|
||||
|
||||
export const saveSessionCursor = async (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
completedAt: number
|
||||
) => {
|
||||
if (!sessionId || !messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasIndexedDbSupport()) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
store.put({ messageId, completedAt }, sessionId);
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error('Cursor write failed'));
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Cursor write aborted'));
|
||||
});
|
||||
return;
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
|
||||
const fallback = readFallback();
|
||||
fallback[sessionId] = { messageId, completedAt };
|
||||
writeFallback(fallback);
|
||||
};
|
||||
|
||||
export const readSessionCursor = async (
|
||||
sessionId: string
|
||||
): Promise<CursorRecord | null> => {
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasIndexedDbSupport()) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const record = await new Promise<CursorRecord | null>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(sessionId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve((request.result as CursorRecord) ?? null);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error ?? new Error('Cursor read failed'));
|
||||
tx.onerror = () => reject(tx.error ?? new Error('Cursor read failed'));
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Cursor read aborted'));
|
||||
});
|
||||
return record;
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
|
||||
const fallback = readFallback();
|
||||
return fallback[sessionId] ?? null;
|
||||
};
|
||||
|
||||
export const clearSessionCursor = async (sessionId: string) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasIndexedDbSupport()) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
store.delete(sessionId);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error('Cursor delete failed'));
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Cursor delete aborted'));
|
||||
});
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
|
||||
const fallback = readFallback();
|
||||
if (sessionId in fallback) {
|
||||
delete fallback[sessionId];
|
||||
writeFallback(fallback);
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Agent } from "@opencode-ai/sdk/v2";
|
||||
|
||||
export interface AgentMentionSource {
|
||||
interface AgentMentionSource {
|
||||
value: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ParsedAgentMention {
|
||||
interface ParsedAgentMention {
|
||||
name: string;
|
||||
source?: AgentMentionSource;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
export const EXECUTION_FORK_META_TEXT =
|
||||
"This message comes from an AI assistant in another session. The user wants you to respond according to its content: " +
|
||||
"if it is an implementation plan, your task is to implement that plan; " +
|
||||
"if it is a conclusion or summary, your task is to verify it, explain whether you agree or disagree, and correct it if needed. " +
|
||||
"Always clearly state what you understand your task to be, and wait for the user's approval of your conclusions before taking any further actions.";
|
||||
|
||||
export const MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT =
|
||||
"This message bellow comes from an AI agent in another session. I want you to act according to its content: " +
|
||||
"if it is an implementation plan, your task is to implement that plan; " +
|
||||
@@ -12,9 +6,6 @@ export const MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT =
|
||||
"Proceed with actions right away based on your understanding of the task. " +
|
||||
"Here is the content of the message: ";
|
||||
|
||||
export const isExecutionForkMetaText = (text: string | null | undefined): boolean =>
|
||||
typeof text === 'string' && text.trim() === EXECUTION_FORK_META_TEXT.trim();
|
||||
|
||||
// Default, user-editable instructions prefilled in the "Start new session from
|
||||
// this answer" dialog. Mirrors the previous fixed fork instruction so existing
|
||||
// behavior is preserved unless the user edits it.
|
||||
@@ -26,7 +17,7 @@ export const EXECUTION_FORK_DEFAULT_INSTRUCTIONS =
|
||||
|
||||
// Fixed connective that opens the forked assistant content. Not editable by the
|
||||
// user — it sits between the user's instructions and the assistant message.
|
||||
export const EXECUTION_FORK_CONTENT_PREFACE =
|
||||
const EXECUTION_FORK_CONTENT_PREFACE =
|
||||
"This message below comes from an AI agent in another session. Here is the content of the message:";
|
||||
|
||||
// Builds the final message sent to the new session:
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
* Format a single inline comment draft into the standard message format
|
||||
* used by diff, plan, and file viewers
|
||||
*/
|
||||
export function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
const { fileLabel, startLine, endLine, side, language, code, text } = draft;
|
||||
|
||||
// Diff format includes side (original/modified)
|
||||
@@ -28,7 +28,7 @@ export function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
* Format multiple inline comment drafts into a single string
|
||||
* with each comment separated by a blank line
|
||||
*/
|
||||
export function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string {
|
||||
function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string {
|
||||
if (drafts.length === 0) return '';
|
||||
|
||||
if (drafts.every((draft) => draft.source === 'preview-annotation')) {
|
||||
@@ -55,16 +55,3 @@ export function appendInlineComments(text: string, drafts: InlineCommentDraft[])
|
||||
return `${text}\n\n${formattedComments}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message text contains inline comments (for validation purposes)
|
||||
*/
|
||||
export function hasInlineComments(text: string): boolean {
|
||||
return text.includes('Comment on `') && text.includes('```');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the file label from a draft for display purposes
|
||||
*/
|
||||
export function getDraftDisplayLabel(draft: InlineCommentDraft): string {
|
||||
return `${draft.fileLabel}:${draft.startLine}-${draft.endLine}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const SKILL_LINK_PREFIX = '#openchamber-skill:';
|
||||
export const AGENT_LINK_PREFIX = '#openchamber-agent:';
|
||||
const SKILL_LINK_PREFIX = '#openchamber-skill:';
|
||||
const AGENT_LINK_PREFIX = '#openchamber-agent:';
|
||||
|
||||
export const buildAgentMentionUrl = (name: string): string => {
|
||||
const encoded = encodeURIComponent(name);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export type MobileKeyboardMode = 'native' | 'resize-content';
|
||||
|
||||
export const MOBILE_KEYBOARD_MODE_STORAGE_KEY = 'openchamber.mobileKeyboardMode';
|
||||
export const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]';
|
||||
export const VIEWPORT_CONTENT_BASE = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover';
|
||||
const MOBILE_KEYBOARD_MODE_STORAGE_KEY = 'openchamber.mobileKeyboardMode';
|
||||
const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]';
|
||||
const VIEWPORT_CONTENT_BASE = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover';
|
||||
|
||||
export const supportsMobileKeyboardResizeContent = (): boolean => {
|
||||
return true;
|
||||
@@ -21,7 +21,7 @@ export function normalizeMobileKeyboardMode(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export const getViewportContentForMobileKeyboardMode = (value: unknown): string => {
|
||||
const getViewportContentForMobileKeyboardMode = (value: unknown): string => {
|
||||
const mode = normalizeMobileKeyboardMode(value);
|
||||
return mode === 'resize-content'
|
||||
? `${VIEWPORT_CONTENT_BASE}, interactive-widget=resizes-content`
|
||||
|
||||
@@ -2,7 +2,7 @@ export type MobileLayoutPreference = 'default' | 'new';
|
||||
|
||||
const MOBILE_LAYOUT_PREFERENCE_KEY = 'openchamber-mobile-layout';
|
||||
|
||||
export const normalizeMobileLayoutPreference = (value: unknown): MobileLayoutPreference => {
|
||||
const normalizeMobileLayoutPreference = (value: unknown): MobileLayoutPreference => {
|
||||
// 'new' is the default; only an explicit 'default' (the legacy/"Old" layout)
|
||||
// opts out of it.
|
||||
return value === 'default' ? 'default' : 'new';
|
||||
|
||||
@@ -77,10 +77,6 @@ export const getMultiRunSessionTitle = (parts: {
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
export const isMultiRunSessionTitle = (title?: string | null): boolean => {
|
||||
return parseMultiRunSessionTitle(title) !== null;
|
||||
};
|
||||
|
||||
export const getFusionSessionTitle = (groupSlug: string, providerID: string, modelID: string, runGroup?: string): string => {
|
||||
const segments = [groupSlug];
|
||||
if (runGroup) segments.push(runGroup);
|
||||
|
||||
@@ -148,7 +148,7 @@ const formatLaunchRuntime = (wrapperType: string, node: string, bun: string): st
|
||||
return 'direct executable';
|
||||
};
|
||||
|
||||
export const buildOpenCodeStatusReport = async (): Promise<string> => {
|
||||
const buildOpenCodeStatusReport = async (): Promise<string> => {
|
||||
const now = new Date();
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
|
||||
const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)';
|
||||
|
||||
@@ -32,7 +32,6 @@ export const OPEN_IN_APPS: OpenInApp[] = [
|
||||
|
||||
export const DEFAULT_OPEN_IN_APP_ID = 'finder';
|
||||
export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']);
|
||||
export const OPEN_DIRECTORY_APP_IDS = new Set(['finder', 'terminal', 'iterm2', 'ghostty']);
|
||||
|
||||
export const getPlatformOpenInApp = (app: OpenInApp): OpenInApp => {
|
||||
if (typeof window !== 'undefined' && window.__OPENCHAMBER_PLATFORM__ === 'win32') {
|
||||
@@ -50,7 +49,3 @@ export const getOpenInAppById = (id: string | null | undefined): OpenInApp | nul
|
||||
const app = OPEN_IN_APPS.find((candidate) => candidate.id === id) ?? null;
|
||||
return app ? getPlatformOpenInApp(app) : null;
|
||||
};
|
||||
|
||||
export const getDefaultOpenInApp = (): OpenInApp => {
|
||||
return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? getPlatformOpenInApp(OPEN_IN_APPS[0]);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface OpenChamberConfig {
|
||||
interface OpenChamberConfig {
|
||||
projectPath?: string;
|
||||
'setup-worktree'?: string[];
|
||||
'setup-worktree-wait'?: boolean;
|
||||
@@ -42,7 +42,7 @@ export interface OpenChamberConfig {
|
||||
draftStarters?: DraftStarterRef[];
|
||||
}
|
||||
|
||||
export type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
|
||||
type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
|
||||
|
||||
export interface OpenChamberProjectAction {
|
||||
id: string;
|
||||
@@ -91,11 +91,11 @@ export interface OpenChamberProjectContextData extends OpenChamberProjectNotesTo
|
||||
|
||||
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 3000;
|
||||
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
|
||||
export const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160;
|
||||
const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
|
||||
const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
|
||||
const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
|
||||
const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
|
||||
const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160;
|
||||
|
||||
const OPENCHAMBER_ACTION_PLATFORM_SET = new Set<OpenChamberProjectActionPlatform>(['macos', 'linux', 'windows']);
|
||||
|
||||
@@ -520,7 +520,7 @@ const getProjectPlansDirectory = async (project: ProjectRef): Promise<string | n
|
||||
return joinPath(projectDirectory, 'plans');
|
||||
};
|
||||
|
||||
export const formatProjectPlanMarkdown = (title: string, body: string): string => {
|
||||
const formatProjectPlanMarkdown = (title: string, body: string): string => {
|
||||
const normalizedTitle = sanitizePlanTitle(title) || 'Plan';
|
||||
const normalizedBody = body.trim();
|
||||
return normalizedBody
|
||||
@@ -552,7 +552,7 @@ export const parseProjectPlanMarkdown = (raw: string): { title: string; body: st
|
||||
* Read the config for a project.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
export async function readOpenChamberConfig(project: ProjectRef): Promise<OpenChamberConfig | null> {
|
||||
async function readOpenChamberConfig(project: ProjectRef): Promise<OpenChamberConfig | null> {
|
||||
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
|
||||
if (!projectDirectory) {
|
||||
return null;
|
||||
@@ -624,7 +624,7 @@ export async function readOpenChamberConfig(project: ProjectRef): Promise<OpenCh
|
||||
* dedicated route and never round-trips them through this config write path to
|
||||
* avoid a read-then-write race clobbering a concurrent server update.
|
||||
*/
|
||||
export async function writeOpenChamberConfig(
|
||||
async function writeOpenChamberConfig(
|
||||
project: ProjectRef,
|
||||
config: OpenChamberConfig
|
||||
): Promise<boolean> {
|
||||
@@ -678,7 +678,7 @@ export async function writeOpenChamberConfig(
|
||||
/**
|
||||
* Update specific keys in the config, preserving other values.
|
||||
*/
|
||||
export async function updateOpenChamberConfig(
|
||||
async function updateOpenChamberConfig(
|
||||
project: ProjectRef,
|
||||
updates: Partial<OpenChamberConfig>
|
||||
): Promise<boolean> {
|
||||
@@ -753,12 +753,12 @@ export async function getProjectContextData(project: ProjectRef): Promise<OpenCh
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjectPlanFiles(project: ProjectRef): Promise<OpenChamberProjectPlanFileLink[]> {
|
||||
async function getProjectPlanFiles(project: ProjectRef): Promise<OpenChamberProjectPlanFileLink[]> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return sanitizeProjectPlanFileLinks(config?.projectPlanFiles);
|
||||
}
|
||||
|
||||
export async function saveProjectPlanFiles(
|
||||
async function saveProjectPlanFiles(
|
||||
project: ProjectRef,
|
||||
value: OpenChamberProjectPlanFileLink[]
|
||||
): Promise<boolean> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { subscribeRuntimeEndpointChanged } from './runtime-switch';
|
||||
|
||||
export type ScheduledTaskRanEvent = {
|
||||
type ScheduledTaskRanEvent = {
|
||||
type: 'scheduled-task-ran';
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
Part,
|
||||
Provider,
|
||||
Config,
|
||||
Model,
|
||||
Agent,
|
||||
TextPartInput,
|
||||
FilePartInput,
|
||||
@@ -170,7 +169,7 @@ interface App {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type FilesystemEntry = {
|
||||
type FilesystemEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
@@ -203,7 +202,7 @@ type FileInputLite = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type DirectorySwitchResult = {
|
||||
type DirectorySwitchResult = {
|
||||
success: boolean;
|
||||
restarted: boolean;
|
||||
path: string;
|
||||
@@ -1830,5 +1829,3 @@ class OpencodeService {
|
||||
export const opencodeClient = new OpencodeService();
|
||||
|
||||
// Exported types
|
||||
export type { Session, Message, Part, Provider, Config, Model };
|
||||
export type { App };
|
||||
|
||||
@@ -90,7 +90,7 @@ function isCircuitBreakerStatus(status?: number): boolean {
|
||||
return status !== undefined && RETRYABLE_STATUS_CODES.has(status)
|
||||
}
|
||||
|
||||
export function isCircuitOpen(providerID: string): boolean {
|
||||
function isCircuitOpen(providerID: string): boolean {
|
||||
const state = providers.get(providerID)
|
||||
if (!state?.circuitOpen) return false
|
||||
|
||||
@@ -124,11 +124,3 @@ export function getRetryDelayMs(attempt: number): number {
|
||||
const delay = DEFAULT_RETRY_BASE_DELAY_MS * 2 ** attempt
|
||||
return Math.min(delay, DEFAULT_RETRY_MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
export function resetCircuit(providerID: string): void {
|
||||
const state = providers.get(providerID)
|
||||
if (!state) return
|
||||
state.consecutiveErrors = 0
|
||||
state.circuitOpen = false
|
||||
state.circuitCooldownMs = DEFAULT_CIRCUIT_COOLDOWN_MS
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const getOutsideFileGrant = (path: string): string | undefined => {
|
||||
return entry.outsideFileGrant;
|
||||
};
|
||||
|
||||
export const rememberOutsideFileGrant = (
|
||||
const rememberOutsideFileGrant = (
|
||||
path: string,
|
||||
outsideFileGrant: string,
|
||||
expiresAt?: number,
|
||||
|
||||
@@ -54,7 +54,7 @@ const postJson = async (url: string, body?: unknown): Promise<Response> => runti
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const getPasskeyErrorMessage = async (response: Response, fallback: string): Promise<string> => {
|
||||
const getPasskeyErrorMessage = async (response: Response, fallback: string): Promise<string> => {
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const isAbsoluteFilePath = (value: string | null | undefined): boolean =>
|
||||
return normalized.startsWith('/') || WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(normalized);
|
||||
};
|
||||
|
||||
export const toComparableFilePath = (value: string | null | undefined): string => {
|
||||
const toComparableFilePath = (value: string | null | undefined): string => {
|
||||
const normalized = normalizeFilePath(value);
|
||||
return WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(normalized) || normalized.startsWith('//')
|
||||
? normalized.toLowerCase()
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
|
||||
export type BashPermissionValue = 'allow' | 'ask' | 'deny';
|
||||
export type BashPermissionSetting = BashPermissionValue | Record<string, BashPermissionValue | undefined>;
|
||||
export type SimplePermissionValue = 'allow' | 'ask' | 'deny' | undefined;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const isBashPermissionMap = (value: unknown): value is Record<string, BashPermissionValue | undefined> => {
|
||||
return isRecord(value);
|
||||
};
|
||||
|
||||
const hasBashAskEntry = (permission?: BashPermissionSetting): boolean => {
|
||||
if (!permission) {
|
||||
return false;
|
||||
}
|
||||
if (typeof permission === 'string') {
|
||||
return permission === 'ask';
|
||||
}
|
||||
if (isBashPermissionMap(permission)) {
|
||||
return Object.values(permission).some((value) => value === 'ask');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasBashDenyEntry = (permission?: BashPermissionSetting): boolean => {
|
||||
if (!permission) {
|
||||
return false;
|
||||
}
|
||||
if (typeof permission === 'string') {
|
||||
return permission === 'deny';
|
||||
}
|
||||
if (isBashPermissionMap(permission)) {
|
||||
return Object.values(permission).some((value) => value === 'deny');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export interface EditPermissionInputs {
|
||||
agentDefaultEditMode: EditPermissionMode;
|
||||
webfetchPermission?: SimplePermissionValue;
|
||||
bashPermission?: BashPermissionSetting;
|
||||
}
|
||||
|
||||
export interface EditPermissionUIState {
|
||||
cascadeDefaultMode: EditPermissionMode;
|
||||
modeAvailability: Record<EditPermissionMode, boolean>;
|
||||
autoApproveAvailable: boolean;
|
||||
bashHasAsk: boolean;
|
||||
bashHasDeny: boolean;
|
||||
bashAllAllow: boolean;
|
||||
webfetchIsAllow: boolean;
|
||||
webfetchNotDeny: boolean;
|
||||
}
|
||||
|
||||
export const calculateEditPermissionUIState = ({
|
||||
agentDefaultEditMode,
|
||||
webfetchPermission,
|
||||
bashPermission,
|
||||
}: EditPermissionInputs): EditPermissionUIState => {
|
||||
const bashHasAsk = hasBashAskEntry(bashPermission);
|
||||
const bashHasDeny = hasBashDenyEntry(bashPermission);
|
||||
const bashAllAllow = !bashHasAsk && !bashHasDeny;
|
||||
|
||||
const webfetchIsAllow = webfetchPermission === 'allow';
|
||||
const webfetchNotDeny = webfetchPermission !== 'deny';
|
||||
|
||||
const editIsAllow = agentDefaultEditMode === 'allow' || agentDefaultEditMode === 'full';
|
||||
const editIsAsk = agentDefaultEditMode === 'ask';
|
||||
|
||||
let cascadeDefaultMode: EditPermissionMode = agentDefaultEditMode;
|
||||
if (editIsAllow && webfetchIsAllow && bashAllAllow) {
|
||||
cascadeDefaultMode = 'full';
|
||||
} else if (editIsAllow && bashHasAsk) {
|
||||
cascadeDefaultMode = 'allow';
|
||||
} else if (editIsAsk) {
|
||||
cascadeDefaultMode = 'ask';
|
||||
}
|
||||
|
||||
const modeAvailability: Record<EditPermissionMode, boolean> = {
|
||||
ask: editIsAsk,
|
||||
allow: editIsAsk || (editIsAllow && bashHasAsk),
|
||||
full: agentDefaultEditMode !== 'deny' && webfetchNotDeny && bashHasAsk,
|
||||
deny: false,
|
||||
};
|
||||
|
||||
const autoApproveAvailable = modeAvailability.allow || modeAvailability.full;
|
||||
|
||||
return {
|
||||
cascadeDefaultMode,
|
||||
modeAvailability,
|
||||
autoApproveAvailable,
|
||||
bashHasAsk,
|
||||
bashHasDeny,
|
||||
bashAllAllow,
|
||||
webfetchIsAllow,
|
||||
webfetchNotDeny,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,3 @@
|
||||
import type {
|
||||
OpenChamberProjectAction,
|
||||
OpenChamberProjectActionPlatform,
|
||||
} from '@/lib/openchamberConfig';
|
||||
import type {
|
||||
DesktopSshInstance,
|
||||
DesktopSshPortForward,
|
||||
@@ -70,30 +66,6 @@ export const normalizeProjectActionDirectory = (value: string): string => {
|
||||
return trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed;
|
||||
};
|
||||
|
||||
export const getCurrentProjectActionPlatform = (): OpenChamberProjectActionPlatform => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'macos';
|
||||
}
|
||||
const ua = (navigator.userAgent || '').toLowerCase();
|
||||
if (ua.includes('windows')) {
|
||||
return 'windows';
|
||||
}
|
||||
if (ua.includes('linux')) {
|
||||
return 'linux';
|
||||
}
|
||||
return 'macos';
|
||||
};
|
||||
|
||||
export const isProjectActionEnabledOnPlatform = (
|
||||
action: OpenChamberProjectAction,
|
||||
platform: OpenChamberProjectActionPlatform
|
||||
): boolean => {
|
||||
if (!Array.isArray(action.platforms) || action.platforms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return action.platforms.includes(platform);
|
||||
};
|
||||
|
||||
export const toProjectActionRunKey = (directory: string, actionId: string): string => {
|
||||
return `${normalizeProjectActionDirectory(directory)}::${actionId}`;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
|
||||
type ThemeVariant = 'light' | 'dark';
|
||||
export type ProjectIconImageOptions = { themeVariant?: ThemeVariant; iconColor?: string };
|
||||
type ProjectIconImageOptions = { themeVariant?: ThemeVariant; iconColor?: string };
|
||||
|
||||
const PROJECT_ICON_OBJECT_URL_CACHE_LIMIT = 200;
|
||||
|
||||
@@ -150,7 +150,7 @@ const loadProjectIconObjectUrl = (
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const useProjectIconImageObjectUrl = (
|
||||
const useProjectIconImageObjectUrl = (
|
||||
project: Pick<ProjectEntry, 'id' | 'iconImage'>,
|
||||
options?: ProjectIconImageOptions,
|
||||
): string | null => {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const resolveProjectForDirectory = (
|
||||
return best;
|
||||
};
|
||||
|
||||
export const resolveProjectFromWorktreeDirectory = (
|
||||
const resolveProjectFromWorktreeDirectory = (
|
||||
projects: ProjectEntry[],
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||
directory: string | null,
|
||||
|
||||
@@ -8,7 +8,6 @@ export type PWADisplayMode =
|
||||
|
||||
const DISPLAY_MODES: Array<Exclude<PWADisplayMode, 'browser' | 'twa'>> = ['standalone', 'minimal-ui', 'fullscreen', 'window-controls-overlay'];
|
||||
|
||||
export const PWA_INSTALL_NAME_STORAGE_KEY = 'openchamber.pwaName';
|
||||
export const PWA_RECENT_SESSIONS_STORAGE_KEY = 'openchamber.pwaRecentSessions';
|
||||
|
||||
const matchesDisplayMode = (mode: Exclude<PWADisplayMode, 'browser' | 'twa'>): boolean => {
|
||||
@@ -35,7 +34,3 @@ export const getPWADisplayMode = (): PWADisplayMode => {
|
||||
const matched = DISPLAY_MODES.find((mode) => matchesDisplayMode(mode));
|
||||
return matched ?? 'browser';
|
||||
};
|
||||
|
||||
export const isInstalledPWARuntime = (): boolean => {
|
||||
return getPWADisplayMode() !== 'browser';
|
||||
};
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
export { QUOTA_PROVIDERS, QUOTA_PROVIDER_MAP } from './providers';
|
||||
export type { QuotaProviderMeta } from './providers';
|
||||
export { QUOTA_PROVIDERS } from './providers';
|
||||
export {
|
||||
clampPercent,
|
||||
formatPercent,
|
||||
formatQuotaValueLabel,
|
||||
formatQuotaResetLabel,
|
||||
resolveUsageTone,
|
||||
formatWindowLabel,
|
||||
calculatePace,
|
||||
inferWindowSeconds,
|
||||
getPaceStatusColor,
|
||||
formatRemainingTime,
|
||||
calculateExpectedUsagePercent,
|
||||
} from './utils';
|
||||
export type { PaceStatus, PaceInfo } from './utils';
|
||||
export type { PaceInfo } from './utils';
|
||||
|
||||
@@ -25,21 +25,6 @@ export function getDisplayModelName(modelName: string): string {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auth source label from a model name prefix.
|
||||
* e.g., "gemini/..." -> "Gemini"
|
||||
* "antigravity/..." -> "Antigravity"
|
||||
*/
|
||||
export function getAuthSourceLabel(modelName: string): string | null {
|
||||
const slashIndex = modelName.indexOf('/');
|
||||
if (slashIndex === -1) return null;
|
||||
|
||||
const prefix = modelName.substring(0, slashIndex);
|
||||
if (prefix === 'gemini') return 'Gemini';
|
||||
if (prefix === 'antigravity') return 'Antigravity';
|
||||
return null;
|
||||
}
|
||||
|
||||
const GOOGLE_MODEL_FAMILIES: ModelFamily[] = [
|
||||
{
|
||||
id: 'gemini-auth',
|
||||
@@ -55,11 +40,11 @@ const GOOGLE_MODEL_FAMILIES: ModelFamily[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PROVIDER_MODEL_FAMILIES: Record<string, ModelFamily[]> = {
|
||||
const PROVIDER_MODEL_FAMILIES: Record<string, ModelFamily[]> = {
|
||||
google: GOOGLE_MODEL_FAMILIES,
|
||||
};
|
||||
|
||||
export function getModelFamily(modelName: string, providerId: QuotaProviderId): ModelFamily | null {
|
||||
function getModelFamily(modelName: string, providerId: QuotaProviderId): ModelFamily | null {
|
||||
const families = PROVIDER_MODEL_FAMILIES[providerId] ?? [];
|
||||
for (const family of families) {
|
||||
if (family.matcher(modelName)) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { ProviderResult, QuotaProviderId } from '@/types';
|
||||
|
||||
export interface QuotaProvider {
|
||||
id: QuotaProviderId;
|
||||
name: string;
|
||||
isConfigured: () => Promise<boolean>;
|
||||
fetchQuota: () => Promise<ProviderResult>;
|
||||
}
|
||||
@@ -21,10 +21,3 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
||||
{ id: 'ollama-cloud', name: 'Ollama Cloud' },
|
||||
{ id: 'wafer', name: 'Wafer.ai' },
|
||||
];
|
||||
|
||||
export const QUOTA_PROVIDER_MAP = QUOTA_PROVIDERS.reduce<
|
||||
Record<string, QuotaProviderMeta>
|
||||
>((acc, provider) => {
|
||||
acc[provider.id] = provider;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -132,7 +132,7 @@ export interface PaceInfo {
|
||||
* Infer window duration in seconds from a window label.
|
||||
* Used when the API doesn't provide windowSeconds directly.
|
||||
*/
|
||||
export const inferWindowSeconds = (label: string): number | null => {
|
||||
const inferWindowSeconds = (label: string): number | null => {
|
||||
const normalized = label.toLowerCase().trim();
|
||||
|
||||
// Exact matches
|
||||
|
||||
@@ -26,7 +26,7 @@ export const getResponseStylePresetInstructions = (preset: ResponseStylePreset):
|
||||
}
|
||||
};
|
||||
|
||||
export const buildResponseStyleInstruction = ({
|
||||
const buildResponseStyleInstruction = ({
|
||||
enabled,
|
||||
preset,
|
||||
customInstructions,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import {
|
||||
getOriginalSessionID,
|
||||
getReviewSessionID,
|
||||
getSessionMetadata,
|
||||
isReviewSession,
|
||||
withoutReviewSessionLink,
|
||||
withReviewSessionLink,
|
||||
@@ -289,5 +288,3 @@ export const getReviewTransferDirection = (session: Session | null | undefined):
|
||||
if (getReviewSessionID(session)) return 'original-to-review';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const readSessionReviewMetadata = (session: Session | null | undefined) => getSessionMetadata(session);
|
||||
|
||||
@@ -17,15 +17,12 @@
|
||||
* - `/?tab=diff&file=src/main.ts` - Open diff view with file
|
||||
*/
|
||||
|
||||
export type { RouteState, RouterContext } from './types';
|
||||
export { VALID_TABS, VALID_SETTINGS_SECTIONS, ROUTE_PARAMS } from './types';
|
||||
export type { RouteState } from './types';
|
||||
|
||||
|
||||
export { parseRoute, hasRouteParams } from './parseRoute';
|
||||
|
||||
export type { AppRouteState } from './serializeRoute';
|
||||
export {
|
||||
serializeRoute,
|
||||
buildURL,
|
||||
routeMatchesURL,
|
||||
updateBrowserURL,
|
||||
} from './serializeRoute';
|
||||
|
||||
@@ -21,7 +21,7 @@ const DEFAULT_TAB: MainTab = 'chat';
|
||||
* Serialize application state to URL search parameters.
|
||||
* Only includes parameters that differ from defaults to keep URLs clean.
|
||||
*/
|
||||
export function serializeRoute(state: AppRouteState): URLSearchParams {
|
||||
function serializeRoute(state: AppRouteState): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Session ID - always include if present
|
||||
@@ -54,7 +54,7 @@ export function serializeRoute(state: AppRouteState): URLSearchParams {
|
||||
* Convert URLSearchParams to a URL string.
|
||||
* Returns just the pathname if no params, otherwise pathname + search string.
|
||||
*/
|
||||
export function buildURL(params: URLSearchParams, pathname?: string): string {
|
||||
function buildURL(params: URLSearchParams, pathname?: string): string {
|
||||
const path = pathname ?? (typeof window !== 'undefined' ? window.location.pathname : '/');
|
||||
const search = params.toString();
|
||||
|
||||
@@ -69,7 +69,7 @@ export function buildURL(params: URLSearchParams, pathname?: string): string {
|
||||
* Check if the current URL matches the given route state.
|
||||
* Used to avoid unnecessary URL updates.
|
||||
*/
|
||||
export function routeMatchesURL(state: AppRouteState): boolean {
|
||||
function routeMatchesURL(state: AppRouteState): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -16,16 +16,6 @@ export interface RouteState {
|
||||
diffFile: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for router operations - determines what capabilities are available.
|
||||
*/
|
||||
export interface RouterContext {
|
||||
/** Whether running in VS Code webview (limited URL capabilities) */
|
||||
isVSCode: boolean;
|
||||
/** Whether URL can be updated (false in VS Code, true elsewhere) */
|
||||
canUpdateURL: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid main tab values for URL routing.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type RuntimeAuthCredential =
|
||||
type RuntimeAuthCredential =
|
||||
| { type: 'bearer'; token: string }
|
||||
| null;
|
||||
|
||||
@@ -109,7 +109,7 @@ export const getRuntimeUrlAuthTokenSync = (): string => {
|
||||
return token;
|
||||
};
|
||||
|
||||
export const getRuntimeAuthCredential = async (): Promise<RuntimeAuthCredential> => {
|
||||
const getRuntimeAuthCredential = async (): Promise<RuntimeAuthCredential> => {
|
||||
const credential = await credentialProvider();
|
||||
const token = credential?.type === 'bearer'
|
||||
? normalizeBearerToken(credential.token)
|
||||
|
||||
@@ -150,5 +150,3 @@ export const configureRuntimeUrlResolver = (config: RuntimeUrlConfig): RuntimeUr
|
||||
activeRuntimeUrlResolver = createRuntimeUrlResolver(config);
|
||||
return activeRuntimeUrlResolver;
|
||||
};
|
||||
|
||||
export const runtimeUrl = activeRuntimeUrlResolver;
|
||||
|
||||
@@ -20,7 +20,7 @@ const isTouchOrCoarsePointer = (): boolean => {
|
||||
return coarsePointer || touchPoints > 0;
|
||||
};
|
||||
|
||||
export const detectHostedSurface = (): HostedSurface => {
|
||||
const detectHostedSurface = (): HostedSurface => {
|
||||
if (typeof window === 'undefined') return 'desktop';
|
||||
|
||||
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
|
||||
|
||||
@@ -89,24 +89,6 @@ function getFuzzyMatchMask<T>(
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function filterByFuzzyQuery<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getText: (item: T) => string,
|
||||
options?: FuzzySearchOptions
|
||||
): T[] {
|
||||
const matches = getFuzzyMatchMask(items, query, getText, options);
|
||||
const matching: T[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (matches[i]) {
|
||||
matching.push(items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return matching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score-sorted fuzzy ranking. Strict (low threshold), prioritizes substring
|
||||
* matches (especially prefix matches), and returns the top N.
|
||||
@@ -159,15 +141,6 @@ export function scoreByFuzzyQuery<T>(
|
||||
return scored.slice(0, limit);
|
||||
}
|
||||
|
||||
export function rankByFuzzyQuery<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getText: (item: T) => string,
|
||||
options?: { limit?: number; threshold?: number; noFuzzy?: boolean },
|
||||
): T[] {
|
||||
return scoreByFuzzyQuery(items, query, getText, options).map((x) => x.item);
|
||||
}
|
||||
|
||||
export function partitionByFuzzyQuery<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { evaluateServerCompatibility, REQUIRED_SERVER_CAPABILITIES } from './server-compatibility';
|
||||
|
||||
const compatiblePayload = () => ({
|
||||
status: 'ok',
|
||||
openchamberVersion: '1.10.4',
|
||||
runtime: 'web',
|
||||
compatibility: {
|
||||
apiVersion: 1,
|
||||
minClientApiVersion: 1,
|
||||
capabilities: [...REQUIRED_SERVER_CAPABILITIES],
|
||||
},
|
||||
});
|
||||
|
||||
describe('evaluateServerCompatibility', () => {
|
||||
test('accepts a compatible server', () => {
|
||||
const result = evaluateServerCompatibility(compatiblePayload());
|
||||
|
||||
expect(result.status).toBe('compatible');
|
||||
expect(result.openchamberVersion).toBe('1.10.4');
|
||||
expect(result.runtime).toBe('web');
|
||||
});
|
||||
|
||||
test('rejects invalid compatibility payloads', () => {
|
||||
expect(evaluateServerCompatibility({ status: 'ok' }).status).toBe('invalid-response');
|
||||
expect(evaluateServerCompatibility(null).status).toBe('invalid-response');
|
||||
});
|
||||
|
||||
test('detects old servers and old clients', () => {
|
||||
expect(evaluateServerCompatibility({
|
||||
...compatiblePayload(),
|
||||
compatibility: { ...compatiblePayload().compatibility, apiVersion: 1 },
|
||||
}, { clientApiVersion: 2 }).status).toBe('server-too-old');
|
||||
|
||||
expect(evaluateServerCompatibility({
|
||||
...compatiblePayload(),
|
||||
compatibility: { ...compatiblePayload().compatibility, minClientApiVersion: 2 },
|
||||
}, { clientApiVersion: 1 }).status).toBe('client-too-old');
|
||||
});
|
||||
|
||||
test('detects missing required capabilities', () => {
|
||||
const result = evaluateServerCompatibility({
|
||||
...compatiblePayload(),
|
||||
compatibility: {
|
||||
...compatiblePayload().compatibility,
|
||||
capabilities: ['api.health.v1'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe('missing-capability');
|
||||
expect(result.missingCapabilities).toContain('realtime.sse.v1');
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
|
||||
export const OPENCHAMBER_CLIENT_API_VERSION = 1;
|
||||
|
||||
export const REQUIRED_SERVER_CAPABILITIES = [
|
||||
'api.health.v1',
|
||||
'api.runtime-url.v1',
|
||||
'api.raw-file.v1',
|
||||
'realtime.sse.v1',
|
||||
'realtime.websocket.global-events.v1',
|
||||
] as const;
|
||||
|
||||
export type ServerCompatibilityStatus =
|
||||
| 'compatible'
|
||||
| 'auth-required'
|
||||
| 'unreachable'
|
||||
| 'invalid-response'
|
||||
| 'server-too-old'
|
||||
| 'client-too-old'
|
||||
| 'missing-capability';
|
||||
|
||||
export interface ServerCompatibilityPayload {
|
||||
status?: unknown;
|
||||
openchamberVersion?: unknown;
|
||||
runtime?: unknown;
|
||||
compatibility?: {
|
||||
apiVersion?: unknown;
|
||||
minClientApiVersion?: unknown;
|
||||
capabilities?: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ServerCompatibilityResult {
|
||||
status: ServerCompatibilityStatus;
|
||||
openchamberVersion: string | null;
|
||||
runtime: string | null;
|
||||
apiVersion: number | null;
|
||||
minClientApiVersion: number | null;
|
||||
missingCapabilities: string[];
|
||||
requiredCapabilities: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
const parsePositiveInteger = (value: unknown): number | null => {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseString = (value: unknown): string | null => {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
};
|
||||
|
||||
const parseCapabilities = (value: unknown): Set<string> => {
|
||||
if (!Array.isArray(value)) return new Set();
|
||||
return new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0));
|
||||
};
|
||||
|
||||
export const evaluateServerCompatibility = (
|
||||
payload: ServerCompatibilityPayload | null | undefined,
|
||||
options: {
|
||||
clientApiVersion?: number;
|
||||
requiredCapabilities?: readonly string[];
|
||||
} = {},
|
||||
): ServerCompatibilityResult => {
|
||||
const clientApiVersion = options.clientApiVersion ?? OPENCHAMBER_CLIENT_API_VERSION;
|
||||
const requiredCapabilities = [...(options.requiredCapabilities ?? REQUIRED_SERVER_CAPABILITIES)];
|
||||
const compatibility = payload?.compatibility ?? null;
|
||||
const apiVersion = parsePositiveInteger(compatibility?.apiVersion);
|
||||
const minClientApiVersion = parsePositiveInteger(compatibility?.minClientApiVersion);
|
||||
const openchamberVersion = parseString(payload?.openchamberVersion);
|
||||
const runtime = parseString(payload?.runtime);
|
||||
|
||||
const base = {
|
||||
openchamberVersion,
|
||||
runtime,
|
||||
apiVersion,
|
||||
minClientApiVersion,
|
||||
missingCapabilities: [] as string[],
|
||||
requiredCapabilities,
|
||||
};
|
||||
|
||||
if (!payload || payload.status !== 'ok' || !compatibility || !apiVersion || !minClientApiVersion) {
|
||||
return {
|
||||
...base,
|
||||
status: 'invalid-response',
|
||||
message: 'Server did not return OpenChamber compatibility metadata.',
|
||||
};
|
||||
}
|
||||
|
||||
if (apiVersion < clientApiVersion) {
|
||||
return {
|
||||
...base,
|
||||
status: 'server-too-old',
|
||||
message: `Server API version ${apiVersion} is older than required client API version ${clientApiVersion}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (minClientApiVersion > clientApiVersion) {
|
||||
return {
|
||||
...base,
|
||||
status: 'client-too-old',
|
||||
message: `Server requires client API version ${minClientApiVersion}, but this client supports ${clientApiVersion}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const capabilities = parseCapabilities(compatibility.capabilities);
|
||||
const missingCapabilities = requiredCapabilities.filter((capability) => !capabilities.has(capability));
|
||||
if (missingCapabilities.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
status: 'missing-capability',
|
||||
missingCapabilities,
|
||||
message: `Server is missing required capabilities: ${missingCapabilities.join(', ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: 'compatible',
|
||||
message: 'Server is compatible.',
|
||||
};
|
||||
};
|
||||
|
||||
export const checkServerCompatibility = async (): Promise<ServerCompatibilityResult> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await runtimeFetch('/api/version', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unreachable',
|
||||
openchamberVersion: null,
|
||||
runtime: null,
|
||||
apiVersion: null,
|
||||
minClientApiVersion: null,
|
||||
missingCapabilities: [],
|
||||
requiredCapabilities: [...REQUIRED_SERVER_CAPABILITIES],
|
||||
message: error instanceof Error ? error.message : 'Server is unreachable.',
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
status: 'auth-required',
|
||||
openchamberVersion: null,
|
||||
runtime: null,
|
||||
apiVersion: null,
|
||||
minClientApiVersion: null,
|
||||
missingCapabilities: [],
|
||||
requiredCapabilities: [...REQUIRED_SERVER_CAPABILITIES],
|
||||
message: 'Server requires authentication.',
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null) as ServerCompatibilityPayload | null;
|
||||
return evaluateServerCompatibility(payload);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export type SettingsPageSlug =
|
||||
| 'tunnel'
|
||||
| 'about';
|
||||
|
||||
export type SettingsPageGroup =
|
||||
type SettingsPageGroup =
|
||||
| 'appearance'
|
||||
| 'projects'
|
||||
| 'general'
|
||||
@@ -52,17 +52,6 @@ export interface SettingsPageMeta {
|
||||
isAvailable?: (ctx: SettingsRuntimeContext) => boolean;
|
||||
}
|
||||
|
||||
export const SETTINGS_GROUP_LABELS: Record<SettingsPageGroup, string> = {
|
||||
appearance: 'Appearance',
|
||||
projects: 'Projects',
|
||||
general: 'General',
|
||||
opencode: 'OpenCode',
|
||||
git: 'Git',
|
||||
skills: 'Skills',
|
||||
usage: 'Usage',
|
||||
advanced: 'Advanced',
|
||||
};
|
||||
|
||||
export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
{
|
||||
slug: 'home',
|
||||
@@ -209,7 +198,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
{ slug: 'about', title: 'About', group: 'advanced', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile },
|
||||
] as const;
|
||||
|
||||
export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
||||
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
||||
sessions: 'sessions',
|
||||
agents: 'agents',
|
||||
commands: 'commands',
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { I18nKey } from '@/lib/i18n/store';
|
||||
import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata';
|
||||
import { getSettingsPageMeta } from './metadata';
|
||||
|
||||
export interface SettingsSearchItem {
|
||||
interface SettingsSearchItem {
|
||||
id: string;
|
||||
page: SettingsPageSlug;
|
||||
titleKey: I18nKey;
|
||||
@@ -17,12 +17,12 @@ export interface SettingsSearchResult extends SettingsSearchItem {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
export interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
|
||||
interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
|
||||
isMobile: boolean;
|
||||
isDesktopLocalOrigin: boolean;
|
||||
}
|
||||
|
||||
export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
{
|
||||
id: 'appearance.language',
|
||||
page: 'appearance',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isMacOS } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
export type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl';
|
||||
export type ShortcutKey = string;
|
||||
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl';
|
||||
type ShortcutKey = string;
|
||||
export type ShortcutCombo = string;
|
||||
|
||||
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
|
||||
@@ -15,7 +15,7 @@ export interface ShortcutAction {
|
||||
customizable?: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedShortcut {
|
||||
interface ParsedShortcut {
|
||||
modifiers: Set<ShortcutModifier>;
|
||||
key: ShortcutKey;
|
||||
}
|
||||
@@ -417,7 +417,7 @@ export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
|
||||
return [...orderedModifiers, key].filter(Boolean).join('+');
|
||||
}
|
||||
|
||||
export function isValidShortcutCombo(combo: ShortcutCombo): boolean {
|
||||
function isValidShortcutCombo(combo: ShortcutCombo): boolean {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return true;
|
||||
}
|
||||
@@ -426,7 +426,7 @@ export function isValidShortcutCombo(combo: ShortcutCombo): boolean {
|
||||
return parsed.key.trim().length > 0;
|
||||
}
|
||||
|
||||
export function parseShortcut(combo: ShortcutCombo): ParsedShortcut {
|
||||
function parseShortcut(combo: ShortcutCombo): ParsedShortcut {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return { modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT };
|
||||
}
|
||||
@@ -479,10 +479,6 @@ export function getShortcutAction(id: string): ShortcutAction | undefined {
|
||||
return SHORTCUT_ACTIONS.find((action) => action.id === id);
|
||||
}
|
||||
|
||||
export function getAllShortcutActions(): ReadonlyArray<ShortcutAction> {
|
||||
return SHORTCUT_ACTIONS;
|
||||
}
|
||||
|
||||
export function getCustomizableShortcutActions(): ReadonlyArray<ShortcutAction> {
|
||||
return SHORTCUT_ACTIONS.filter((action) => action.customizable === true);
|
||||
}
|
||||
@@ -515,17 +511,6 @@ export function getEffectiveShortcutCombo(
|
||||
return action.defaultCombo;
|
||||
}
|
||||
|
||||
export function getEffectiveShortcutLabel(
|
||||
actionId: string,
|
||||
overrides?: Record<string, ShortcutCombo>
|
||||
): string {
|
||||
const combo = getEffectiveShortcutCombo(actionId, overrides);
|
||||
if (!combo) {
|
||||
return '';
|
||||
}
|
||||
return formatShortcutForDisplay(combo);
|
||||
}
|
||||
|
||||
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return false;
|
||||
@@ -607,14 +592,6 @@ export function eventMatchesShortcut(
|
||||
return eventKey === expectedKey;
|
||||
}
|
||||
|
||||
export function getShortcutLabel(id: string): string {
|
||||
const action = getShortcutAction(id);
|
||||
if (!action) return '';
|
||||
|
||||
const displayCombo = formatShortcutForDisplay(action.defaultCombo);
|
||||
return `${displayCombo} - ${action.label}`;
|
||||
}
|
||||
|
||||
export function getModifierLabel(): string {
|
||||
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type StartupTraceEvent = {
|
||||
type StartupTraceEvent = {
|
||||
t: number;
|
||||
name: string;
|
||||
data?: Record<string, unknown>;
|
||||
@@ -43,7 +43,7 @@ export const markStartupTrace = (name: string, data?: Record<string, unknown>) =
|
||||
}
|
||||
};
|
||||
|
||||
export const getStartupTraceSummary = () => {
|
||||
const getStartupTraceSummary = () => {
|
||||
const trace = typeof window !== 'undefined' ? window.__OPENCHAMBER_STARTUP_TRACE__ ?? [] : [];
|
||||
const readyIndex = trace.findIndex((event) => event.name === 'ModelControls:ready');
|
||||
const endIndex = readyIndex >= 0 ? Math.min(trace.length, readyIndex + 8) : trace.length;
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
/**
|
||||
* SerializeAddon for ghostty-web
|
||||
*
|
||||
* Port of xterm.js addon-serialize for ghostty-web terminal.
|
||||
* Enables serialization of terminal contents to restore state after reconnection.
|
||||
*
|
||||
* Features:
|
||||
* - ANSI color preservation (16-color, 256-color, RGB)
|
||||
* - Text attributes (bold, italic, underline, faint, strikethrough, blink, inverse, invisible, dim)
|
||||
* - Scrollback support with configurable limits
|
||||
* - Round-trip compatibility
|
||||
* - Cursor positioning
|
||||
*/
|
||||
|
||||
import type { Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||
|
||||
// Constants for ANSI escape codes
|
||||
const C0 = {
|
||||
ESC: '\u001b',
|
||||
};
|
||||
|
||||
const SGR = {
|
||||
RESET: 0,
|
||||
BOLD: 1,
|
||||
DIM: 2,
|
||||
ITALIC: 3,
|
||||
UNDERLINE: 4,
|
||||
SLOW_BLINK: 5,
|
||||
RAPID_BLINK: 6,
|
||||
INVERSE: 7,
|
||||
INVISIBLE: 8,
|
||||
STRIKETHROUGH: 9,
|
||||
NORMAL_INTENSITY: 22,
|
||||
NO_ITALIC: 23,
|
||||
NO_UNDERLINE: 24,
|
||||
NO_BLINK: 25,
|
||||
NO_INVERSE: 27,
|
||||
VISIBLE: 28,
|
||||
NO_STRIKETHROUGH: 29,
|
||||
FG_DEFAULT: 39,
|
||||
BG_DEFAULT: 49,
|
||||
};
|
||||
|
||||
export interface SerializeOptions {
|
||||
/**
|
||||
* The row range to serialize. When an explicit range is specified, the cursor
|
||||
* will get its final repositioning.
|
||||
*/
|
||||
range?: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The number of rows in the scrollback buffer to serialize, starting from
|
||||
* the bottom of the scrollback buffer. When not specified, all available
|
||||
* rows in the scrollback buffer will be serialized.
|
||||
*/
|
||||
scrollback?: number;
|
||||
|
||||
/**
|
||||
* Whether to exclude the terminal modes from the serialization.
|
||||
* Default: false
|
||||
*/
|
||||
excludeModes?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to exclude the alt buffer from the serialization.
|
||||
* Default: false
|
||||
*/
|
||||
excludeAltBuffer?: boolean;
|
||||
}
|
||||
|
||||
export interface TextSerializeOptions {
|
||||
/**
|
||||
* The number of rows in the scrollback buffer to serialize, starting from
|
||||
* the bottom of the scrollback buffer.
|
||||
*/
|
||||
scrollback?: number;
|
||||
|
||||
/**
|
||||
* Whether to trim trailing whitespace from lines.
|
||||
* Default: true
|
||||
*/
|
||||
trimWhitespace?: boolean;
|
||||
}
|
||||
|
||||
interface CellState {
|
||||
fg: number | null;
|
||||
bg: number | null;
|
||||
bold: boolean;
|
||||
dim: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
blink: boolean;
|
||||
inverse: boolean;
|
||||
invisible: boolean;
|
||||
strikethrough: boolean;
|
||||
}
|
||||
|
||||
const NULL_CELL_STATE: CellState = {
|
||||
fg: null,
|
||||
bg: null,
|
||||
bold: false,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
blink: false,
|
||||
inverse: false,
|
||||
invisible: false,
|
||||
strikethrough: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* SerializeAddon for ghostty-web terminal
|
||||
*/
|
||||
export class SerializeAddon {
|
||||
private _terminal: GhosttyTerminal | undefined;
|
||||
|
||||
/**
|
||||
* Activate the addon
|
||||
*/
|
||||
activate(terminal: GhosttyTerminal): void {
|
||||
this._terminal = terminal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the addon
|
||||
*/
|
||||
dispose(): void {
|
||||
this._terminal = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the terminal buffer to ANSI escape sequences
|
||||
*/
|
||||
serialize(options: SerializeOptions = {}): string {
|
||||
if (!this._terminal) {
|
||||
throw new Error('SerializeAddon not activated');
|
||||
}
|
||||
|
||||
const buffer = this._terminal.buffer.active;
|
||||
if (!buffer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
let currentState: CellState = { ...NULL_CELL_STATE };
|
||||
|
||||
// Determine range to serialize
|
||||
const scrollbackLimit = options.scrollback ?? buffer.length;
|
||||
|
||||
let startRow: number;
|
||||
let endRow: number;
|
||||
|
||||
if (options.range) {
|
||||
startRow = options.range.start;
|
||||
endRow = options.range.end;
|
||||
} else {
|
||||
// Serialize scrollback + viewport
|
||||
const totalRows = buffer.length;
|
||||
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
|
||||
startRow = Math.max(0, buffer.baseY - scrollbackRows);
|
||||
endRow = buffer.baseY + buffer.cursorY;
|
||||
}
|
||||
|
||||
// Clamp to valid range
|
||||
startRow = Math.max(0, startRow);
|
||||
endRow = Math.min(buffer.length - 1, endRow);
|
||||
|
||||
for (let y = startRow; y <= endRow; y++) {
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) {
|
||||
result.push('\r\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
let lineContent = '';
|
||||
let lastNonSpaceCol = -1;
|
||||
|
||||
// Find the last non-space column
|
||||
for (let x = line.length - 1; x >= 0; x--) {
|
||||
const cell = line.getCell(x);
|
||||
if (cell) {
|
||||
const char = this._getCellChar(cell);
|
||||
if (char !== ' ' && char !== '') {
|
||||
lastNonSpaceCol = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize each cell up to the last non-space
|
||||
for (let x = 0; x <= lastNonSpaceCol; x++) {
|
||||
const cell = line.getCell(x);
|
||||
if (!cell) {
|
||||
lineContent += ' ';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get cell attributes and generate SGR sequences if needed
|
||||
const newState = this._getCellState(cell);
|
||||
const sgrSequences = this._generateSgrDiff(currentState, newState);
|
||||
if (sgrSequences) {
|
||||
lineContent += sgrSequences;
|
||||
currentState = newState;
|
||||
}
|
||||
|
||||
// Get character
|
||||
const char = this._getCellChar(cell);
|
||||
lineContent += char || ' ';
|
||||
}
|
||||
|
||||
// Reset attributes at end of line if any were set
|
||||
if (this._hasAttributes(currentState)) {
|
||||
lineContent += `${C0.ESC}[${SGR.RESET}m`;
|
||||
currentState = { ...NULL_CELL_STATE };
|
||||
}
|
||||
|
||||
result.push(lineContent);
|
||||
|
||||
// Add newline unless it's the last row with cursor
|
||||
if (y < endRow) {
|
||||
result.push('\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Position cursor
|
||||
const cursorY = buffer.cursorY;
|
||||
const cursorX = buffer.cursorX;
|
||||
if (cursorY >= 0 && cursorX >= 0) {
|
||||
// Use CUP (Cursor Position) to move cursor to correct position
|
||||
// CUP is 1-based, so add 1 to both coordinates
|
||||
const relativeY = cursorY - (endRow - buffer.baseY);
|
||||
if (relativeY !== 0 || cursorX !== 0) {
|
||||
result.push(`${C0.ESC}[${cursorY + 1};${cursorX + 1}H`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the terminal buffer to plain text (no escape sequences)
|
||||
*/
|
||||
serializeAsText(options: TextSerializeOptions = {}): string {
|
||||
if (!this._terminal) {
|
||||
throw new Error('SerializeAddon not activated');
|
||||
}
|
||||
|
||||
const buffer = this._terminal.buffer.active;
|
||||
if (!buffer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimWhitespace = options.trimWhitespace ?? true;
|
||||
const scrollbackLimit = options.scrollback ?? buffer.length;
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
// Determine range
|
||||
const totalRows = buffer.length;
|
||||
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
|
||||
const startRow = Math.max(0, buffer.baseY - scrollbackRows);
|
||||
const endRow = buffer.baseY + buffer.cursorY;
|
||||
|
||||
for (let y = startRow; y <= endRow; y++) {
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) {
|
||||
result.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let lineContent = '';
|
||||
for (let x = 0; x < line.length; x++) {
|
||||
const cell = line.getCell(x);
|
||||
if (cell) {
|
||||
const char = this._getCellChar(cell);
|
||||
lineContent += char || ' ';
|
||||
} else {
|
||||
lineContent += ' ';
|
||||
}
|
||||
}
|
||||
|
||||
if (trimWhitespace) {
|
||||
lineContent = lineContent.trimEnd();
|
||||
}
|
||||
|
||||
result.push(lineContent);
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the character from a cell, handling wide characters and special codepoints
|
||||
*/
|
||||
private _getCellChar(cell: { getChars?: () => string; getCodepoint?: () => number }): string {
|
||||
// Try getChars() first (ghostty-web standard)
|
||||
if (typeof cell.getChars === 'function') {
|
||||
const chars = cell.getChars();
|
||||
if (chars) return chars;
|
||||
}
|
||||
|
||||
// Try getCodepoint()
|
||||
if (typeof cell.getCodepoint === 'function') {
|
||||
const codepoint = cell.getCodepoint();
|
||||
if (codepoint && codepoint > 0 && codepoint <= 0x10FFFF &&
|
||||
!(codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
|
||||
return String.fromCodePoint(codepoint);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of a cell (colors and attributes)
|
||||
*/
|
||||
private _getCellState(cell: {
|
||||
getFgColor?: () => number;
|
||||
getBgColor?: () => number;
|
||||
isBold?: () => boolean | number;
|
||||
isDim?: () => boolean | number;
|
||||
isFaint?: () => boolean | number;
|
||||
isItalic?: () => boolean | number;
|
||||
isUnderline?: () => boolean | number;
|
||||
isBlink?: () => boolean | number;
|
||||
isInverse?: () => boolean | number;
|
||||
isInvisible?: () => boolean | number;
|
||||
isStrikethrough?: () => boolean | number;
|
||||
}): CellState {
|
||||
const state: CellState = { ...NULL_CELL_STATE };
|
||||
|
||||
// Get foreground color
|
||||
if (typeof cell.getFgColor === 'function') {
|
||||
const fg = cell.getFgColor();
|
||||
if (fg !== undefined && fg !== null && fg !== -1) {
|
||||
state.fg = fg;
|
||||
}
|
||||
}
|
||||
|
||||
// Get background color
|
||||
if (typeof cell.getBgColor === 'function') {
|
||||
const bg = cell.getBgColor();
|
||||
if (bg !== undefined && bg !== null && bg !== -1) {
|
||||
state.bg = bg;
|
||||
}
|
||||
}
|
||||
|
||||
// Get attributes
|
||||
if (typeof cell.isBold === 'function') {
|
||||
state.bold = !!cell.isBold();
|
||||
}
|
||||
if (typeof cell.isDim === 'function') {
|
||||
state.dim = !!cell.isDim();
|
||||
} else if (typeof cell.isFaint === 'function') {
|
||||
state.dim = !!cell.isFaint();
|
||||
}
|
||||
if (typeof cell.isItalic === 'function') {
|
||||
state.italic = !!cell.isItalic();
|
||||
}
|
||||
if (typeof cell.isUnderline === 'function') {
|
||||
state.underline = !!cell.isUnderline();
|
||||
}
|
||||
if (typeof cell.isBlink === 'function') {
|
||||
state.blink = !!cell.isBlink();
|
||||
}
|
||||
if (typeof cell.isInverse === 'function') {
|
||||
state.inverse = !!cell.isInverse();
|
||||
}
|
||||
if (typeof cell.isInvisible === 'function') {
|
||||
state.invisible = !!cell.isInvisible();
|
||||
}
|
||||
if (typeof cell.isStrikethrough === 'function') {
|
||||
state.strikethrough = !!cell.isStrikethrough();
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SGR escape sequences for the difference between two cell states
|
||||
*/
|
||||
private _generateSgrDiff(from: CellState, to: CellState): string | null {
|
||||
const codes: number[] = [];
|
||||
|
||||
// Check if we need a full reset
|
||||
const needsReset =
|
||||
(from.bold && !to.bold) ||
|
||||
(from.dim && !to.dim) ||
|
||||
(from.italic && !to.italic) ||
|
||||
(from.underline && !to.underline) ||
|
||||
(from.blink && !to.blink) ||
|
||||
(from.inverse && !to.inverse) ||
|
||||
(from.invisible && !to.invisible) ||
|
||||
(from.strikethrough && !to.strikethrough);
|
||||
|
||||
if (needsReset) {
|
||||
codes.push(SGR.RESET);
|
||||
// After reset, we need to re-apply all 'to' attributes
|
||||
if (to.bold) codes.push(SGR.BOLD);
|
||||
if (to.dim) codes.push(SGR.DIM);
|
||||
if (to.italic) codes.push(SGR.ITALIC);
|
||||
if (to.underline) codes.push(SGR.UNDERLINE);
|
||||
if (to.blink) codes.push(SGR.SLOW_BLINK);
|
||||
if (to.inverse) codes.push(SGR.INVERSE);
|
||||
if (to.invisible) codes.push(SGR.INVISIBLE);
|
||||
if (to.strikethrough) codes.push(SGR.STRIKETHROUGH);
|
||||
|
||||
// Re-apply colors
|
||||
if (to.fg !== null) {
|
||||
this._appendColorCode(codes, to.fg, true);
|
||||
}
|
||||
if (to.bg !== null) {
|
||||
this._appendColorCode(codes, to.bg, false);
|
||||
}
|
||||
} else {
|
||||
// Apply only changed attributes
|
||||
if (!from.bold && to.bold) codes.push(SGR.BOLD);
|
||||
if (!from.dim && to.dim) codes.push(SGR.DIM);
|
||||
if (!from.italic && to.italic) codes.push(SGR.ITALIC);
|
||||
if (!from.underline && to.underline) codes.push(SGR.UNDERLINE);
|
||||
if (!from.blink && to.blink) codes.push(SGR.SLOW_BLINK);
|
||||
if (!from.inverse && to.inverse) codes.push(SGR.INVERSE);
|
||||
if (!from.invisible && to.invisible) codes.push(SGR.INVISIBLE);
|
||||
if (!from.strikethrough && to.strikethrough) codes.push(SGR.STRIKETHROUGH);
|
||||
|
||||
// Handle color changes
|
||||
if (from.fg !== to.fg) {
|
||||
if (to.fg === null) {
|
||||
codes.push(SGR.FG_DEFAULT);
|
||||
} else {
|
||||
this._appendColorCode(codes, to.fg, true);
|
||||
}
|
||||
}
|
||||
if (from.bg !== to.bg) {
|
||||
if (to.bg === null) {
|
||||
codes.push(SGR.BG_DEFAULT);
|
||||
} else {
|
||||
this._appendColorCode(codes, to.bg, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${C0.ESC}[${codes.join(';')}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append color code to the codes array
|
||||
*/
|
||||
private _appendColorCode(codes: number[], color: number, isForeground: boolean): void {
|
||||
const base = isForeground ? 30 : 40;
|
||||
const extBase = isForeground ? 38 : 48;
|
||||
|
||||
if (color < 8) {
|
||||
// Basic 8 colors
|
||||
codes.push(base + color);
|
||||
} else if (color < 16) {
|
||||
// Bright 8 colors
|
||||
codes.push(base + 60 + (color - 8));
|
||||
} else if (color < 256) {
|
||||
// 256-color palette
|
||||
codes.push(extBase, 5, color);
|
||||
} else {
|
||||
// RGB (24-bit) color encoded as 0xRRGGBB + 0x1000000
|
||||
const rgb = color - 0x1000000;
|
||||
const r = (rgb >> 16) & 0xFF;
|
||||
const g = (rgb >> 8) & 0xFF;
|
||||
const b = rgb & 0xFF;
|
||||
codes.push(extBase, 2, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the state has any attributes set
|
||||
*/
|
||||
private _hasAttributes(state: CellState): boolean {
|
||||
return (
|
||||
state.fg !== null ||
|
||||
state.bg !== null ||
|
||||
state.bold ||
|
||||
state.dim ||
|
||||
state.italic ||
|
||||
state.underline ||
|
||||
state.blink ||
|
||||
state.inverse ||
|
||||
state.invisible ||
|
||||
state.strikethrough
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
|
||||
export interface TerminalWebSocketDescriptor {
|
||||
interface TerminalWebSocketDescriptor {
|
||||
path: string;
|
||||
v?: number;
|
||||
enc?: string;
|
||||
}
|
||||
|
||||
export interface TerminalTransportCapability {
|
||||
interface TerminalTransportCapability {
|
||||
preferred?: 'ws' | 'http' | 'sse';
|
||||
transports?: Array<'ws' | 'http' | 'sse'>;
|
||||
ws?: TerminalWebSocketDescriptor;
|
||||
|
||||
@@ -62,35 +62,6 @@ export function convertThemeToXterm(theme: Theme): TerminalTheme {
|
||||
};
|
||||
}
|
||||
|
||||
export function getTerminalOptions(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
theme: TerminalTheme
|
||||
) {
|
||||
|
||||
const powerlineFallbacks =
|
||||
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
|
||||
|
||||
return {
|
||||
fontFamily: augmentedFontFamily,
|
||||
fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar' as const,
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
scrollback: 10_000,
|
||||
minimumContrastRatio: 1,
|
||||
fastScrollModifier: 'shift' as const,
|
||||
fastScrollSensitivity: 5,
|
||||
scrollSensitivity: 3,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terminal options for Ghostty Web terminal
|
||||
*/
|
||||
|
||||
@@ -6,10 +6,10 @@ import flexokiDarkRaw from './flexoki-dark.json';
|
||||
import openchamberLightRaw from './fields-of-the-shire-light.json';
|
||||
import openchamberDarkRaw from './fields-of-the-shire-dark.json';
|
||||
|
||||
export const flexokiLightTheme = withPrColors(flexokiLightRaw as Theme);
|
||||
export const flexokiDarkTheme = withPrColors(flexokiDarkRaw as Theme);
|
||||
export const openchamberLightTheme = withPrColors(openchamberLightRaw as Theme);
|
||||
export const openchamberDarkTheme = withPrColors(openchamberDarkRaw as Theme);
|
||||
const flexokiLightTheme = withPrColors(flexokiLightRaw as Theme);
|
||||
const flexokiDarkTheme = withPrColors(flexokiDarkRaw as Theme);
|
||||
const openchamberLightTheme = withPrColors(openchamberLightRaw as Theme);
|
||||
const openchamberDarkTheme = withPrColors(openchamberDarkRaw as Theme);
|
||||
|
||||
export const DEFAULT_LIGHT_THEME_ID = 'flexoki-light' as const;
|
||||
export const DEFAULT_DARK_THEME_ID = 'flexoki-dark' as const;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
|
||||
export type VSCodeThemeKind = 'light' | 'dark' | 'high-contrast';
|
||||
|
||||
export type VSCodeThemeColorToken =
|
||||
type VSCodeThemeColorToken =
|
||||
// Editor core
|
||||
| 'editor.background'
|
||||
| 'editor.foreground'
|
||||
|
||||
@@ -9,21 +9,6 @@ const getHour12Option = (preference: TimeFormatPreference): boolean | undefined
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getUses24HourForPreference = (preference: TimeFormatPreference, locale: string): boolean => {
|
||||
if (preference === '24h') return true;
|
||||
if (preference === '12h') return false;
|
||||
|
||||
try {
|
||||
const options = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions();
|
||||
if (typeof options.hour12 === 'boolean') {
|
||||
return !options.hour12;
|
||||
}
|
||||
return options.hourCycle === 'h23' || options.hourCycle === 'h24';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatTimeForPreference = (
|
||||
timestamp: number | Date,
|
||||
preference: TimeFormatPreference,
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface ToolMetadata {
|
||||
category: 'file' | 'search' | 'code' | 'system' | 'ai' | 'web';
|
||||
}
|
||||
|
||||
export const TOOL_METADATA: Record<string, ToolMetadata> = {
|
||||
const TOOL_METADATA: Record<string, ToolMetadata> = {
|
||||
|
||||
read: {
|
||||
displayName: 'Read File',
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
const ACTIVE_TOOL_STATUSES = new Set([
|
||||
'pending',
|
||||
'running',
|
||||
'started',
|
||||
'inprogress',
|
||||
'processing',
|
||||
'executing',
|
||||
]);
|
||||
|
||||
const FINAL_TOOL_STATUSES = new Set([
|
||||
'completed',
|
||||
'complete',
|
||||
'error',
|
||||
'failed',
|
||||
'aborted',
|
||||
'timeout',
|
||||
'timedout',
|
||||
'done',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
]);
|
||||
|
||||
const readTimestamp = (value: unknown): number | undefined => {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
|
||||
export const normalizeToolStatus = (status: unknown): string | undefined => {
|
||||
if (typeof status !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = status.toLowerCase().trim().replace(/[\s_-]+/g, '');
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
export const isActiveToolStatus = (status: unknown): boolean => {
|
||||
const normalized = normalizeToolStatus(status);
|
||||
return normalized ? ACTIVE_TOOL_STATUSES.has(normalized) : false;
|
||||
};
|
||||
|
||||
export const isFinalToolStatus = (status: unknown): boolean => {
|
||||
const normalized = normalizeToolStatus(status);
|
||||
return normalized ? FINAL_TOOL_STATUSES.has(normalized) : false;
|
||||
};
|
||||
|
||||
export type ToolLifecycleState = {
|
||||
status?: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
hasStarted: boolean;
|
||||
hasEnded: boolean;
|
||||
isStatusActive: boolean;
|
||||
isStatusFinal: boolean;
|
||||
isInFlight: boolean;
|
||||
isFinalized: boolean;
|
||||
};
|
||||
|
||||
export const getToolLifecycleState = (
|
||||
state?: { status?: unknown; time?: { start?: unknown; end?: unknown } } | null,
|
||||
): ToolLifecycleState => {
|
||||
const status = normalizeToolStatus(state?.status);
|
||||
const start = readTimestamp(state?.time?.start);
|
||||
const rawEnd = readTimestamp(state?.time?.end);
|
||||
const hasEnded = typeof rawEnd === 'number' && (typeof start !== 'number' || rawEnd >= start);
|
||||
const end = hasEnded ? rawEnd : undefined;
|
||||
const isStatusActive = status ? ACTIVE_TOOL_STATUSES.has(status) : false;
|
||||
const isStatusFinal = status ? FINAL_TOOL_STATUSES.has(status) : false;
|
||||
const isUnknownNonFinal = status ? !isStatusActive && !isStatusFinal : true;
|
||||
const isInFlight = !hasEnded && (isStatusActive || isUnknownNonFinal);
|
||||
const isFinalized = hasEnded || isStatusFinal;
|
||||
|
||||
return {
|
||||
status,
|
||||
start,
|
||||
end,
|
||||
hasStarted: typeof start === 'number',
|
||||
hasEnded,
|
||||
isStatusActive,
|
||||
isStatusFinal,
|
||||
isInFlight,
|
||||
isFinalized,
|
||||
};
|
||||
};
|
||||
@@ -7,28 +7,6 @@ export const SEMANTIC_TYPOGRAPHY = {
|
||||
micro: '0.875rem',
|
||||
} as const;
|
||||
|
||||
export const FONT_SIZE_SCALES = {
|
||||
small: {
|
||||
markdown: '0.875rem',
|
||||
code: '0.8125rem',
|
||||
uiHeader: '0.875rem',
|
||||
uiLabel: '0.8125rem',
|
||||
meta: '0.8125rem',
|
||||
micro: '0.75rem',
|
||||
},
|
||||
medium: SEMANTIC_TYPOGRAPHY,
|
||||
large: {
|
||||
markdown: '1rem',
|
||||
code: '0.9375rem',
|
||||
uiHeader: '1rem',
|
||||
uiLabel: '0.9375rem',
|
||||
meta: '0.9375rem',
|
||||
micro: '0.9375rem',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type FontSizeOption = keyof typeof FONT_SIZE_SCALES;
|
||||
|
||||
export const VSCODE_TYPOGRAPHY = {
|
||||
// Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density.
|
||||
markdown: '0.9063rem',
|
||||
@@ -39,35 +17,12 @@ export const VSCODE_TYPOGRAPHY = {
|
||||
micro: '0.7813rem',
|
||||
} as const;
|
||||
|
||||
export const SEMANTIC_TYPOGRAPHY_CSS = {
|
||||
'--text-markdown': SEMANTIC_TYPOGRAPHY.markdown,
|
||||
'--text-code': SEMANTIC_TYPOGRAPHY.code,
|
||||
'--text-ui-header': SEMANTIC_TYPOGRAPHY.uiHeader,
|
||||
'--text-ui-label': SEMANTIC_TYPOGRAPHY.uiLabel,
|
||||
'--text-meta': SEMANTIC_TYPOGRAPHY.meta,
|
||||
'--text-micro': SEMANTIC_TYPOGRAPHY.micro,
|
||||
} as const;
|
||||
|
||||
export const TYPOGRAPHY_CLASSES = {
|
||||
markdown: 'typography-markdown',
|
||||
code: 'typography-code',
|
||||
uiHeader: 'typography-ui-header',
|
||||
uiLabel: 'typography-ui-label',
|
||||
meta: 'typography-meta',
|
||||
micro: 'typography-micro',
|
||||
} as const;
|
||||
|
||||
export type SemanticTypographyKey = keyof typeof SEMANTIC_TYPOGRAPHY;
|
||||
export type TypographyClassKey = keyof typeof TYPOGRAPHY_CLASSES;
|
||||
|
||||
export function getTypographyVariable(key: SemanticTypographyKey): string {
|
||||
return `--text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function getTypographyClass(key: TypographyClassKey): string {
|
||||
return TYPOGRAPHY_CLASSES[key];
|
||||
}
|
||||
|
||||
export const typography = {
|
||||
|
||||
semanticMarkdown: {
|
||||
@@ -248,22 +203,6 @@ export const typography = {
|
||||
},
|
||||
};
|
||||
|
||||
export function getTypographyStyle(path: string, fallback?: React.CSSProperties): React.CSSProperties {
|
||||
const parts = path.split('.');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let current: any = typography;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && current[part]) {
|
||||
current = current[part];
|
||||
} else {
|
||||
return fallback || {};
|
||||
}
|
||||
}
|
||||
|
||||
return current || fallback || {};
|
||||
}
|
||||
|
||||
export const toolDisplayStyles = {
|
||||
|
||||
padding: {
|
||||
@@ -309,49 +248,3 @@ export const toolDisplayStyles = {
|
||||
...typography.tool.inline,
|
||||
}),
|
||||
};
|
||||
|
||||
export const typographyClasses = {
|
||||
|
||||
'heading-1': 'typography-h1',
|
||||
'heading-2': 'typography-h2',
|
||||
'heading-3': 'typography-h3',
|
||||
'heading-4': 'typography-h4',
|
||||
'heading-5': 'typography-h5',
|
||||
'heading-6': 'typography-h6',
|
||||
|
||||
'ui-button': 'typography-ui-button',
|
||||
'ui-button-small': 'typography-ui-button-small',
|
||||
'ui-button-large': 'typography-ui-button-large',
|
||||
'ui-label': 'typography-ui-label',
|
||||
'ui-caption': 'typography-ui-caption',
|
||||
'ui-badge': 'typography-ui-badge',
|
||||
'ui-tooltip': 'typography-ui-tooltip',
|
||||
'ui-input': 'typography-ui-input',
|
||||
'ui-helper': 'typography-ui-helper-text',
|
||||
|
||||
'code-inline': 'typography-code-inline',
|
||||
'code-block': 'typography-code-block',
|
||||
'code-line-numbers': 'typography-code-line-numbers',
|
||||
|
||||
'markdown-h1': 'typography-markdown-h1',
|
||||
'markdown-h2': 'typography-markdown-h2',
|
||||
'markdown-h3': 'typography-markdown-h3',
|
||||
'markdown-h4': 'typography-markdown-h4',
|
||||
'markdown-h5': 'typography-markdown-h5',
|
||||
'markdown-h6': 'typography-markdown-h6',
|
||||
'markdown-body': 'typography-markdown-body',
|
||||
'markdown-body-small': 'typography-markdown-body-small',
|
||||
'markdown-body-large': 'typography-markdown-body-large',
|
||||
'markdown-blockquote': 'typography-markdown-blockquote',
|
||||
'markdown-list': 'typography-markdown-list',
|
||||
'markdown-link': 'typography-markdown-link',
|
||||
'markdown-code': 'typography-markdown-code',
|
||||
'markdown-code-block': 'typography-markdown-code-block',
|
||||
|
||||
'semantic-markdown': 'typography-markdown',
|
||||
'semantic-code': 'typography-code',
|
||||
'semantic-ui-header': 'typography-ui-header',
|
||||
'semantic-ui-label': 'typography-ui-label',
|
||||
'semantic-meta': 'typography-meta',
|
||||
'semantic-micro': 'typography-micro',
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const isMacOS = (): boolean => {
|
||||
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||
};
|
||||
|
||||
export const isWindows = (): boolean => {
|
||||
const isWindows = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /Windows/.test(navigator.userAgent || '');
|
||||
};
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type SpeechResultCallback = (text: string, isFinal: boolean) => void;
|
||||
export type ErrorCallback = (error: string) => void;
|
||||
type SpeechResultCallback = (text: string, isFinal: boolean) => void;
|
||||
type ErrorCallback = (error: string) => void;
|
||||
|
||||
export interface AudioStreamConfig {
|
||||
interface AudioStreamConfig {
|
||||
/** Base URL of the OpenAI-compatible STT server (e.g. http://localhost:8001/v1) */
|
||||
baseURL: string;
|
||||
/** Whisper-compatible model name */
|
||||
@@ -395,4 +395,3 @@ class AudioStreamService {
|
||||
}
|
||||
|
||||
export const audioStreamService = new AudioStreamService();
|
||||
export { AudioStreamService };
|
||||
|
||||
@@ -34,9 +34,9 @@ declare global {
|
||||
}
|
||||
|
||||
// Callback types
|
||||
export type SpeechResultCallback = (text: string, isFinal: boolean) => void;
|
||||
export type SpeechEndCallback = () => void;
|
||||
export type ErrorCallback = (error: string) => void;
|
||||
type SpeechResultCallback = (text: string, isFinal: boolean) => void;
|
||||
type SpeechEndCallback = () => void;
|
||||
type ErrorCallback = (error: string) => void;
|
||||
|
||||
/**
|
||||
* Browser Voice Service class
|
||||
@@ -234,7 +234,7 @@ class BrowserVoiceService {
|
||||
this.isListening = true;
|
||||
this.restartOnEnd = true;
|
||||
};
|
||||
|
||||
|
||||
this.recognition.onaudiostart = () => {
|
||||
console.log('[BrowserVoiceService] Audio recording started');
|
||||
};
|
||||
@@ -652,6 +652,3 @@ class BrowserVoiceService {
|
||||
|
||||
// Export singleton instance
|
||||
export const browserVoiceService = new BrowserVoiceService();
|
||||
|
||||
// Also export the class for testing/customization
|
||||
export { BrowserVoiceService };
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface VoiceMessage {
|
||||
* @param message - The message to format
|
||||
* @returns Formatted text for voice, or null if should not be spoken
|
||||
*/
|
||||
export function formatMessage(message: VoiceMessage): string | null {
|
||||
function formatMessage(message: VoiceMessage): string | null {
|
||||
// Handle edge cases
|
||||
if (!message || typeof message.content !== "string") {
|
||||
return null;
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
/**
|
||||
* Voice module barrel export
|
||||
* Provides clean import path for voice configuration and client tools
|
||||
* Provides a clean import path for voice session hooks.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { VOICE_CONFIG, realtimeClientTools, voiceHooks } from '@/lib/voice';
|
||||
* import { voiceHooks } from '@/lib/voice';
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
export { VOICE_CONFIG } from "./voiceConfig";
|
||||
|
||||
// Client tools for ElevenLabs voice agent
|
||||
export { realtimeClientTools } from "./realtimeClientTools";
|
||||
export type { RealtimeClientTools } from "./realtimeClientTools";
|
||||
|
||||
// Voice session registry (from voiceSession.ts)
|
||||
export {
|
||||
registerVoiceSession,
|
||||
unregisterVoiceSession,
|
||||
getVoiceSession,
|
||||
isVoiceSessionStarted,
|
||||
} from "./voiceSession";
|
||||
|
||||
// Voice hooks for session-to-voice event routing (from voiceHooks.ts)
|
||||
export { voiceHooks } from "./voiceHooks";
|
||||
|
||||
// Context formatters for voice-native output
|
||||
export {
|
||||
formatMessage,
|
||||
formatNewMessages,
|
||||
formatPermissionRequest,
|
||||
formatReadyEvent,
|
||||
type VoiceMessage,
|
||||
} from "./contextFormatters";
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useConfigStore } from "@/stores/useConfigStore";
|
||||
import { getSyncPermissions } from "@/sync/sync-refs";
|
||||
import { respondToPermission } from "@/sync/session-actions";
|
||||
|
||||
/**
|
||||
* Static client tools for the realtime voice interface.
|
||||
* These tools allow the voice agent to interact with Claude Code.
|
||||
*/
|
||||
export const realtimeClientTools = {
|
||||
/**
|
||||
* Send a message to Claude Code via the current session.
|
||||
* Validates parameters with Zod and returns status strings.
|
||||
*/
|
||||
messageClaudeCode: async (parameters: unknown): Promise<string> => {
|
||||
// Validate parameters with Zod
|
||||
const schema = z.object({
|
||||
message: z.string().min(1, "Message cannot be empty"),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(parameters);
|
||||
if (!parsed.success) {
|
||||
console.error("[Voice] Invalid message parameter:", parsed.error);
|
||||
return "error (invalid message parameter)";
|
||||
}
|
||||
|
||||
// Get current session ID from store
|
||||
const sessionId = useSessionUIStore.getState().currentSessionId;
|
||||
if (!sessionId) {
|
||||
console.error("[Voice] No active session");
|
||||
return "error (no active session)";
|
||||
}
|
||||
|
||||
// Get current provider and model from config store
|
||||
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
||||
if (!currentProviderId || !currentModelId) {
|
||||
console.error("[Voice] No provider/model selected");
|
||||
return "error (no provider or model selected)";
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[Voice] Sending message to session:", sessionId);
|
||||
await useSessionUIStore
|
||||
.getState()
|
||||
.sendMessage(
|
||||
parsed.data.message,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentAgentName ?? undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
currentVariant ?? undefined,
|
||||
);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to send message:", error);
|
||||
return "error (failed to send message)";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Process a permission request from voice.
|
||||
* Validates decision with Zod enum and interacts with permission store.
|
||||
*/
|
||||
processPermissionRequest: async (parameters: unknown): Promise<string> => {
|
||||
// Validate parameters with Zod
|
||||
const schema = z.object({
|
||||
decision: z.enum(["allow", "deny"]),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(parameters);
|
||||
if (!parsed.success) {
|
||||
console.error("[Voice] Invalid decision parameter:", parsed.error);
|
||||
return "error (invalid decision parameter, expected 'allow' or 'deny')";
|
||||
}
|
||||
|
||||
// Get current session ID from store
|
||||
const sessionId = useSessionUIStore.getState().currentSessionId;
|
||||
if (!sessionId) {
|
||||
console.error("[Voice] No active session");
|
||||
return "error (no active session)";
|
||||
}
|
||||
|
||||
// Get pending permissions for this session
|
||||
const permissions = getSyncPermissions(sessionId);
|
||||
if (!permissions || permissions.length === 0) {
|
||||
console.error("[Voice] No pending permission requests");
|
||||
return "error (no pending permission request)";
|
||||
}
|
||||
|
||||
// Get the first pending permission request
|
||||
const request = permissions[0];
|
||||
if (!request) {
|
||||
return "error (no pending permission request)";
|
||||
}
|
||||
|
||||
try {
|
||||
const decision = parsed.data.decision;
|
||||
console.log(`[Voice] Processing permission request ${request.id}: ${decision}`);
|
||||
|
||||
// Respond to the permission based on decision
|
||||
const response: "once" | "always" | "reject" = decision === "allow" ? "once" : "reject";
|
||||
await respondToPermission(sessionId, request.id, response);
|
||||
|
||||
return "done";
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to process permission:", error);
|
||||
return `error (failed to ${parsed.data.decision} permission)`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Type for the realtime client tools */
|
||||
export type RealtimeClientTools = typeof realtimeClientTools;
|
||||
@@ -1,114 +1,3 @@
|
||||
/**
|
||||
* Text summarization utility
|
||||
*
|
||||
* Calls the server-side text summarization endpoint which uses
|
||||
* the opencode.ai zen API with gpt-5-nano.
|
||||
*/
|
||||
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
/**
|
||||
* Summarize text using the server-side zen API endpoint
|
||||
*
|
||||
* @param text - The text to summarize
|
||||
* @param options - Optional configuration
|
||||
* @returns The summarized text, or original text if summarization fails
|
||||
*/
|
||||
export async function summarizeText(
|
||||
text: string,
|
||||
options?: {
|
||||
/** Character threshold - don't summarize if under this length */
|
||||
threshold?: number;
|
||||
/** Max characters for the summary output */
|
||||
maxLength?: number;
|
||||
/** Summarization mode */
|
||||
mode?: 'tts' | 'note';
|
||||
}
|
||||
): Promise<string> {
|
||||
const store = useConfigStore.getState();
|
||||
const threshold = options?.threshold ?? store.summarizeCharacterThreshold;
|
||||
const maxLength = options?.maxLength ?? store.summarizeMaxLength;
|
||||
const mode = options?.mode ?? 'tts';
|
||||
const normalizedSource = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Don't summarize if text is under threshold
|
||||
if (text.length <= threshold) {
|
||||
if (mode === 'note') {
|
||||
throw new Error('Note summarization threshold bypass is not allowed');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await runtimeFetch('/api/text/summarize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text, threshold, maxLength, mode }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`[summarize] HTTP error ${response.status}:`, errorText);
|
||||
throw new Error(`Summarization failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
summarized: boolean;
|
||||
summary?: string;
|
||||
reason?: string;
|
||||
originalLength?: number;
|
||||
summaryLength?: number;
|
||||
};
|
||||
|
||||
if (typeof data.summary === 'string' && data.summary.trim().length > 0) {
|
||||
const summary = data.summary.trim();
|
||||
if (mode === 'note') {
|
||||
const normalizedSummary = summary.replace(/\s+/g, ' ').trim();
|
||||
if (normalizedSummary === normalizedSource) {
|
||||
throw new Error('Note distillation returned source text unchanged');
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (mode === 'note') {
|
||||
throw new Error('Note summarization returned no distilled result');
|
||||
}
|
||||
// Return original text if the server produced nothing usable
|
||||
return text;
|
||||
} catch (err) {
|
||||
console.error('[summarize] Failed to summarize:', err);
|
||||
if (mode === 'note') {
|
||||
throw err instanceof Error ? err : new Error('Note summarization failed');
|
||||
}
|
||||
// Return original text on error
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text should be summarized based on settings
|
||||
*/
|
||||
export function shouldSummarize(
|
||||
text: string,
|
||||
context: 'message' | 'voice'
|
||||
): boolean {
|
||||
const store = useConfigStore.getState();
|
||||
|
||||
const isEnabled = context === 'message'
|
||||
? store.summarizeMessageTTS
|
||||
: store.summarizeVoiceConversation;
|
||||
|
||||
if (!isEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return text.length > store.summarizeCharacterThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side text sanitization for TTS output.
|
||||
* Removes markdown, URLs, file paths, and other non-speakable content.
|
||||
|
||||
@@ -30,6 +30,3 @@ export const VOICE_CONFIG = {
|
||||
/** Enable debug logging for voice context updates */
|
||||
ENABLE_DEBUG_LOGGING: true,
|
||||
} as const;
|
||||
|
||||
/** Type for VOICE_CONFIG keys */
|
||||
export type VoiceConfigKey = keyof typeof VOICE_CONFIG;
|
||||
|
||||
@@ -24,14 +24,6 @@ import {
|
||||
} from "./contextFormatters";
|
||||
import { getVoiceSession, isVoiceSessionStarted } from "./voiceSession";
|
||||
|
||||
// Re-export registry functions from voiceSession.ts for convenience
|
||||
export {
|
||||
registerVoiceSession,
|
||||
unregisterVoiceSession,
|
||||
getVoiceSession,
|
||||
isVoiceSessionStarted,
|
||||
} from "./voiceSession";
|
||||
|
||||
/**
|
||||
* Report a contextual update to the voice session
|
||||
* Internal helper that checks preconditions and handles errors
|
||||
|
||||
@@ -10,25 +10,7 @@ interface VoiceSession {
|
||||
* Global storage for the active voice session.
|
||||
* Used by voiceHooks to send contextual updates to the voice agent.
|
||||
*/
|
||||
let activeVoiceSession: VoiceSession | null = null;
|
||||
|
||||
/**
|
||||
* Register a voice session for use by voiceHooks.
|
||||
* Called by useVoice when a conversation is established.
|
||||
*/
|
||||
export function registerVoiceSession(session: VoiceSession): void {
|
||||
activeVoiceSession = session;
|
||||
console.log("[Voice] Session registered");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the active voice session.
|
||||
* Called by useVoice when the session ends.
|
||||
*/
|
||||
export function unregisterVoiceSession(): void {
|
||||
activeVoiceSession = null;
|
||||
console.log("[Voice] Session unregistered");
|
||||
}
|
||||
const activeVoiceSession: VoiceSession | null = null;
|
||||
|
||||
/**
|
||||
* Get the currently registered voice session.
|
||||
|
||||
@@ -48,8 +48,8 @@ export const WASM_MODELS: WasmModelInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export type SpeechResultCallback = (text: string, isFinal: boolean) => void;
|
||||
export type ErrorCallback = (error: string) => void;
|
||||
type SpeechResultCallback = (text: string, isFinal: boolean) => void;
|
||||
type ErrorCallback = (error: string) => void;
|
||||
|
||||
const VAD_POLL_MS = 80;
|
||||
const MIN_UTTERANCE_MS = 300;
|
||||
@@ -553,4 +553,3 @@ class WasmSttService {
|
||||
}
|
||||
|
||||
export const wasmSttService = new WasmSttService();
|
||||
export { WasmSttService };
|
||||
|
||||
@@ -285,173 +285,10 @@ export async function createWorktreeSession(): Promise<string | null> {
|
||||
/**
|
||||
* Check if a worktree session is currently being created.
|
||||
*/
|
||||
export function isCreatingWorktree(): boolean {
|
||||
return isCreatingWorktreeSession;
|
||||
}
|
||||
|
||||
export async function createWorktreeDraft(options?: { initialPrompt?: string; title?: string }): Promise<string | null> {
|
||||
return createInstantWorktreeDraft(options);
|
||||
}
|
||||
|
||||
export async function createWorktreeOnly(): Promise<string | null> {
|
||||
if (isCreatingWorktreeSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProject = useProjectsStore.getState().getActiveProject();
|
||||
if (!activeProject?.path) {
|
||||
toast.error('No active project', {
|
||||
description: 'Please select a project first.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectDirectory = activeProject.path;
|
||||
let isGitRepo = false;
|
||||
try {
|
||||
isGitRepo = await checkIsGitRepository(projectDirectory);
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
if (!isGitRepo) {
|
||||
toast.error('Not a Git repository', {
|
||||
description: 'Worktrees can only be created in Git repositories.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
isCreatingWorktreeSession = true;
|
||||
|
||||
try {
|
||||
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
|
||||
const preferredName = generateBranchName();
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
setupCommands,
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
|
||||
|
||||
return metadata.path;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree';
|
||||
toast.error('Failed to create worktree', {
|
||||
description: message,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session with a worktree for a specific branch.
|
||||
* Unlike createWorktreeSession(), this allows specifying the project and branch explicitly.
|
||||
*
|
||||
* @param projectDirectory - The root directory of the git repository
|
||||
* @param branchName - The name of the branch to create a worktree for
|
||||
* @returns The created session, or null if creation failed
|
||||
*/
|
||||
export async function createWorktreeSessionForBranch(
|
||||
projectDirectory: string,
|
||||
branchName: string,
|
||||
options?: {
|
||||
kind?: 'pr' | 'standard';
|
||||
existingBranch?: string;
|
||||
worktreeName?: string;
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
createdFromBranch?: string;
|
||||
returnAfterDirectoryCreated?: boolean;
|
||||
}
|
||||
): Promise<{ id: string } | null> {
|
||||
if (isCreatingWorktreeSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
isCreatingWorktreeSession = true;
|
||||
|
||||
try {
|
||||
const projectRef = resolveProjectRef(projectDirectory);
|
||||
if (!projectRef) {
|
||||
throw new Error('Project is not registered in OpenChamber');
|
||||
}
|
||||
|
||||
// Check if it's a git repo (root project path)
|
||||
let isGitRepo = false;
|
||||
try {
|
||||
isGitRepo = await checkIsGitRepository(projectRef.path);
|
||||
} catch {
|
||||
// Ignore errors, treat as not a git repo
|
||||
}
|
||||
|
||||
if (!isGitRepo) {
|
||||
toast.error('Not a Git repository', {
|
||||
description: 'Worktrees can only be created in Git repositories.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const rootBranch = await getRootBranch(projectRef.path);
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName: branchName,
|
||||
mode: 'existing',
|
||||
existingBranch: options?.existingBranch || branchName,
|
||||
branchName,
|
||||
worktreeName: options?.worktreeName || branchName,
|
||||
setUpstream: options?.setUpstream,
|
||||
upstreamRemote: options?.upstreamRemote,
|
||||
upstreamBranch: options?.upstreamBranch,
|
||||
ensureRemoteName: options?.ensureRemoteName,
|
||||
ensureRemoteUrl: options?.ensureRemoteUrl,
|
||||
setupCommands,
|
||||
returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated,
|
||||
});
|
||||
|
||||
const kind = options?.kind ?? 'standard';
|
||||
const createdMetadata = {
|
||||
...metadata,
|
||||
createdFromBranch: options?.createdFromBranch || rootBranch,
|
||||
kind,
|
||||
};
|
||||
|
||||
await waitForWorktreeBootstrapIfEnabled(projectRef, metadata.path);
|
||||
|
||||
// Create the session
|
||||
const sessionStore = useSessionUIStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
// Clean up the worktree if session creation failed
|
||||
await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined);
|
||||
toast.error('Failed to create session', {
|
||||
description: 'Could not create a session for the worktree.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
initializeSessionForWorktree(session.id, createdMetadata);
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
|
||||
toast.error('Failed to create worktree', {
|
||||
description: message,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a worktree session for a new branch name.
|
||||
* Callers can still use startPoint for metadata or follow-up git operations.
|
||||
@@ -550,36 +387,3 @@ export async function createWorktreeSessionForNewBranch(
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as createWorktreeSessionForNewBranch, but preserves the exact branch name.
|
||||
* Use when the worktree must be tied to a specific ref (e.g. PR head ref).
|
||||
*/
|
||||
export async function createWorktreeSessionForNewBranchExact(
|
||||
projectDirectory: string,
|
||||
branchName: string,
|
||||
startPoint: string,
|
||||
options?: {
|
||||
kind?: 'pr' | 'standard';
|
||||
worktreeName?: string;
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
createdFromBranch?: string;
|
||||
returnAfterDirectoryCreated?: boolean;
|
||||
}
|
||||
): Promise<{ id: string; branch: string; path: string } | null> {
|
||||
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
|
||||
kind: options?.kind,
|
||||
worktreeName: options?.worktreeName,
|
||||
setUpstream: options?.setUpstream,
|
||||
upstreamRemote: options?.upstreamRemote,
|
||||
upstreamBranch: options?.upstreamBranch,
|
||||
ensureRemoteName: options?.ensureRemoteName,
|
||||
ensureRemoteUrl: options?.ensureRemoteUrl,
|
||||
createdFromBranch: options?.createdFromBranch,
|
||||
returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const resolveRootTrackingRemote = async (projectDirectory: string): Promi
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveWorktreeUpstreamDefaults = async (
|
||||
const resolveWorktreeUpstreamDefaults = async (
|
||||
projectDirectory: string,
|
||||
localBranch: string
|
||||
): Promise<{ setUpstream: true; upstreamRemote: string; upstreamBranch: string } | null> => {
|
||||
|
||||
@@ -163,7 +163,7 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
};
|
||||
|
||||
export const buildSdkStartCommand = (args: {
|
||||
const buildSdkStartCommand = (args: {
|
||||
projectDirectory: string;
|
||||
setupCommands: string[];
|
||||
}): string | undefined => {
|
||||
@@ -181,7 +181,7 @@ export const buildSdkStartCommand = (args: {
|
||||
return joined.trim().length > 0 ? joined : undefined;
|
||||
};
|
||||
|
||||
export const toCreatePayload = (args: {
|
||||
const toCreatePayload = (args: {
|
||||
preferredName?: string;
|
||||
setupCommands?: string[];
|
||||
mode?: 'new' | 'existing';
|
||||
|
||||
@@ -9,10 +9,6 @@ let statusImpl: (directory: string) => { current: string } = () => ({ current: '
|
||||
const resolveRootCalls: string[] = [];
|
||||
const statusCalls: string[] = [];
|
||||
|
||||
mock.module('@/lib/execCommands', () => ({
|
||||
execCommands: () => Promise.resolve({ success: false, results: [] }),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/gitApi', () => ({
|
||||
getGitStatus: (directory: string) => {
|
||||
statusCalls.push(directory);
|
||||
|
||||
Reference in New Issue
Block a user