Initial public release
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
|
||||
|
||||
const AGENT_COLOR_PALETTE = [
|
||||
{ var: '--status-success', class: 'agent-success' },
|
||||
{ var: '--syntax-keyword', class: 'agent-keyword' },
|
||||
{ var: '--syntax-type', class: 'agent-type' },
|
||||
{ var: '--syntax-function', class: 'agent-function' },
|
||||
{ var: '--syntax-number', class: 'agent-number' },
|
||||
{ var: '--status-info', class: 'agent-info' },
|
||||
{ var: '--status-warning', class: 'agent-warning' },
|
||||
{ var: '--syntax-variable', class: 'agent-variable' },
|
||||
];
|
||||
|
||||
export function getAgentColor(agentName: string | undefined) {
|
||||
|
||||
if (!agentName) {
|
||||
return AGENT_COLOR_PALETTE[0];
|
||||
}
|
||||
|
||||
if (agentName === 'build') {
|
||||
return AGENT_COLOR_PALETTE[0];
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < agentName.length; i++) {
|
||||
const char = agentName.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
|
||||
const paletteIndex = 1 + (Math.abs(hash) % (AGENT_COLOR_PALETTE.length - 1));
|
||||
return AGENT_COLOR_PALETTE[paletteIndex];
|
||||
}
|
||||
|
||||
export function getAgentColorPalette() {
|
||||
return AGENT_COLOR_PALETTE;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
export type RuntimePlatform = 'web' | 'desktop';
|
||||
|
||||
export interface RuntimeDescriptor {
|
||||
platform: RuntimePlatform;
|
||||
|
||||
isDesktop: boolean;
|
||||
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface RetryPolicy {
|
||||
maxRetries: number;
|
||||
initialDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TerminalStreamEvent {
|
||||
type: 'connected' | 'data' | 'exit' | 'reconnecting';
|
||||
data?: string;
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface CreateTerminalOptions {
|
||||
cwd: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface TerminalStreamOptions {
|
||||
retry?: Partial<RetryPolicy>;
|
||||
connectionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ResizeTerminalPayload {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TerminalHandlers {
|
||||
onEvent: (event: TerminalStreamEvent) => void;
|
||||
onError?: (error: Error, fatal?: boolean) => void;
|
||||
}
|
||||
|
||||
export interface TerminalAPI {
|
||||
createSession(options: CreateTerminalOptions): Promise<TerminalSession>;
|
||||
connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions): Subscription;
|
||||
sendInput(sessionId: string, input: string): Promise<void>;
|
||||
resize(payload: ResizeTerminalPayload): Promise<void>;
|
||||
close(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface GitStatusFile {
|
||||
path: string;
|
||||
index: string;
|
||||
working_dir: string;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
current: string;
|
||||
tracking: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: GitStatusFile[];
|
||||
isClean: boolean;
|
||||
diffStats?: Record<string, { insertions: number; deletions: number }>;
|
||||
}
|
||||
|
||||
export interface GitDiffResponse {
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export interface GetGitDiffOptions {
|
||||
path: string;
|
||||
staged?: boolean;
|
||||
contextLines?: number;
|
||||
}
|
||||
|
||||
export interface GitFileDiffResponse {
|
||||
original: string;
|
||||
modified: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface GetGitFileDiffOptions {
|
||||
path: string;
|
||||
staged?: boolean;
|
||||
}
|
||||
|
||||
export interface GitBranchDetails {
|
||||
current: boolean;
|
||||
name: string;
|
||||
commit: string;
|
||||
label: string;
|
||||
tracking?: string;
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
}
|
||||
|
||||
export interface GitBranch {
|
||||
all: string[];
|
||||
current: string;
|
||||
branches: Record<string, GitBranchDetails>;
|
||||
}
|
||||
|
||||
export interface GitCommitSummary {
|
||||
changes: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface GitCommitResult {
|
||||
success: boolean;
|
||||
commit: string;
|
||||
branch: string;
|
||||
summary: GitCommitSummary;
|
||||
}
|
||||
|
||||
export interface GitPushResult {
|
||||
success: boolean;
|
||||
pushed: Array<{
|
||||
local: string;
|
||||
remote: string;
|
||||
}>;
|
||||
repo: string;
|
||||
ref: unknown;
|
||||
}
|
||||
|
||||
export interface GitPullResult {
|
||||
success: boolean;
|
||||
summary: GitCommitSummary;
|
||||
files: string[];
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface GitIdentityProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
sshKey?: string | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface GitIdentitySummary {
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
sshCommand: string | null;
|
||||
}
|
||||
|
||||
export interface GitLogEntry {
|
||||
hash: string;
|
||||
date: string;
|
||||
message: string;
|
||||
refs: string;
|
||||
body: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
filesChanged: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface GitLogResponse {
|
||||
all: GitLogEntry[];
|
||||
latest: GitLogEntry | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CommitFileEntry {
|
||||
path: string;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
isBinary: boolean;
|
||||
changeType: 'A' | 'M' | 'D' | 'R' | 'C' | string;
|
||||
}
|
||||
|
||||
export interface GitCommitFilesResponse {
|
||||
files: CommitFileEntry[];
|
||||
}
|
||||
|
||||
export interface GitWorktreeInfo {
|
||||
worktree: string;
|
||||
head?: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface GitAddWorktreePayload {
|
||||
path: string;
|
||||
branch: string;
|
||||
createBranch?: boolean;
|
||||
}
|
||||
|
||||
export interface GitRemoveWorktreePayload {
|
||||
path: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface GitDeleteBranchPayload {
|
||||
branch: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface GitDeleteRemoteBranchPayload {
|
||||
branch: string;
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
export interface CreateGitCommitOptions {
|
||||
addAll?: boolean;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface GitLogOptions {
|
||||
maxCount?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedCommitMessage {
|
||||
subject: string;
|
||||
highlights: string[];
|
||||
}
|
||||
|
||||
export interface GitAPI {
|
||||
checkIsGitRepository(directory: string): Promise<boolean>;
|
||||
getGitStatus(directory: string): Promise<GitStatus>;
|
||||
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
||||
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
||||
revertGitFile(directory: string, filePath: string): Promise<void>;
|
||||
isLinkedWorktree(directory: string): Promise<boolean>;
|
||||
getGitBranches(directory: string): Promise<GitBranch>;
|
||||
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
||||
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
|
||||
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
|
||||
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
||||
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
|
||||
removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>;
|
||||
ensureOpenChamberIgnored(directory: string): Promise<void>;
|
||||
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
|
||||
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>;
|
||||
gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult>;
|
||||
gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }>;
|
||||
checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }>;
|
||||
createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }>;
|
||||
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
|
||||
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
|
||||
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
|
||||
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
|
||||
getGitIdentities(): Promise<GitIdentityProfile[]>;
|
||||
createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>;
|
||||
updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile>;
|
||||
deleteGitIdentity(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FileListEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size?: number;
|
||||
modifiedTime?: number;
|
||||
}
|
||||
|
||||
export interface DirectoryListResult {
|
||||
directory: string;
|
||||
entries: FileListEntry[];
|
||||
}
|
||||
|
||||
export interface FileSearchQuery {
|
||||
directory: string;
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
export interface FileSearchResult {
|
||||
path: string;
|
||||
score?: number;
|
||||
preview?: string[];
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
listDirectory(path: string): Promise<DirectoryListResult>;
|
||||
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
||||
}
|
||||
|
||||
export interface SettingsPayload {
|
||||
themeId?: string;
|
||||
useSystemTheme?: boolean;
|
||||
themeVariant?: 'light' | 'dark';
|
||||
lightThemeId?: string;
|
||||
darkThemeId?: string;
|
||||
lastDirectory?: string;
|
||||
homeDirectory?: string;
|
||||
approvedDirectories?: string[];
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SettingsLoadResult {
|
||||
settings: SettingsPayload;
|
||||
source: 'desktop' | 'web';
|
||||
}
|
||||
|
||||
export interface SettingsAPI {
|
||||
load(): Promise<SettingsLoadResult>;
|
||||
save(changes: Partial<SettingsPayload>): Promise<SettingsPayload>;
|
||||
|
||||
restartOpenCode?: () => Promise<{ restarted: boolean }>;
|
||||
}
|
||||
|
||||
export interface DirectoryPermissionRequest {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DirectoryPermissionResult {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StartAccessingResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PermissionsAPI {
|
||||
requestDirectoryAccess(request: DirectoryPermissionRequest): Promise<DirectoryPermissionResult>;
|
||||
startAccessingDirectory(path: string): Promise<StartAccessingResult>;
|
||||
stopAccessingDirectory(path: string): Promise<StartAccessingResult>;
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface NotificationsAPI {
|
||||
notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean>;
|
||||
canNotify?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface DiagnosticsAPI {
|
||||
downloadLogs(): Promise<{ fileName: string; content: string }>;
|
||||
}
|
||||
|
||||
export interface ToolsAPI {
|
||||
|
||||
getAvailableTools(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface RuntimeAPIs {
|
||||
runtime: RuntimeDescriptor;
|
||||
terminal: TerminalAPI;
|
||||
git: GitAPI;
|
||||
files: FilesAPI;
|
||||
settings: SettingsAPI;
|
||||
permissions: PermissionsAPI;
|
||||
notifications: NotificationsAPI;
|
||||
diagnostics?: DiagnosticsAPI;
|
||||
tools: ToolsAPI;
|
||||
|
||||
worktrees?: WorktreeMetadata[];
|
||||
}
|
||||
|
||||
export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
|
||||
type AppearanceSlice = {
|
||||
showReasoningTraces: boolean;
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export const startAppearanceAutoSave = (): void => {
|
||||
if (initialized || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
|
||||
let previous: AppearanceSlice = {
|
||||
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
||||
};
|
||||
|
||||
let pending: Partial<DesktopSettings> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flush = () => {
|
||||
const payload = pending;
|
||||
pending = null;
|
||||
timer = null;
|
||||
if (payload && Object.keys(payload).length > 0) {
|
||||
void updateDesktopSettings(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = (changes: Partial<DesktopSettings>) => {
|
||||
pending = { ...(pending ?? {}), ...changes };
|
||||
if (timer) {
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(flush, 150);
|
||||
};
|
||||
|
||||
useUIStore.subscribe((state) => {
|
||||
const current: AppearanceSlice = {
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
};
|
||||
|
||||
const diff: Partial<DesktopSettings> = {};
|
||||
|
||||
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
||||
diff.showReasoningTraces = current.showReasoningTraces;
|
||||
}
|
||||
|
||||
previous = current;
|
||||
|
||||
if (Object.keys(diff).length > 0) {
|
||||
schedule(diff);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { isDesktopRuntime } from '@/lib/desktop';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
export interface AppearancePreferences {
|
||||
showReasoningTraces?: boolean;
|
||||
}
|
||||
|
||||
type RawAppearancePayload = {
|
||||
showReasoningTraces?: unknown;
|
||||
};
|
||||
|
||||
const sanitizePreferences = (payload?: RawAppearancePayload | null): AppearancePreferences | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: AppearancePreferences = {};
|
||||
|
||||
if (typeof payload.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = payload.showReasoningTraces;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
};
|
||||
|
||||
const extractRawAppearance = (data: unknown): RawAppearancePayload | null => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = data as Record<string, unknown>;
|
||||
const payload: RawAppearancePayload = {
|
||||
showReasoningTraces: candidate.showReasoningTraces,
|
||||
};
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const saveAppearancePreferences = (preferences: AppearancePreferences): boolean => {
|
||||
if (typeof window === 'undefined' || !isDesktopRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const api = window.opencodeAppearance;
|
||||
if (!api || typeof api.save !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
void api.save(preferences);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to save appearance preferences to desktop storage:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const applyAppearancePreferences = (preferences: AppearancePreferences): void => {
|
||||
const store = useUIStore.getState();
|
||||
|
||||
if (typeof preferences.showReasoningTraces === 'boolean') {
|
||||
store.setShowReasoningTraces(preferences.showReasoningTraces);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAppearancePreferences = async (): Promise<AppearancePreferences | null> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isDesktopRuntime()) {
|
||||
const api = window.opencodeAppearance;
|
||||
if (!api || typeof api.load !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await api.load();
|
||||
const payload = typeof raw === 'object' && raw !== null ? (raw as RawAppearancePayload) : null;
|
||||
return sanitizePreferences(payload);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load appearance preferences from desktop storage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem('appearance-preferences');
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stored) as unknown;
|
||||
const payload = extractRawAppearance(data);
|
||||
return sanitizePreferences(payload);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse stored appearance preferences:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,352 @@
|
||||
|
||||
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',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
export type ConfigChangeScope = "agents" | "providers" | "commands" | "all";
|
||||
|
||||
export interface ConfigChangeEvent {
|
||||
scopes: ConfigChangeScope[];
|
||||
source?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type ConfigChangeListener = (event: ConfigChangeEvent) => void | Promise<void>;
|
||||
|
||||
const listeners = new Set<ConfigChangeListener>();
|
||||
|
||||
export function subscribeToConfigChanges(
|
||||
listener: ConfigChangeListener,
|
||||
): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitConfigChange(
|
||||
scopes: ConfigChangeScope | ConfigChangeScope[],
|
||||
options?: { source?: string },
|
||||
): void {
|
||||
const normalized = Array.isArray(scopes) ? scopes : [scopes];
|
||||
const uniqueScopes = Array.from(new Set(normalized));
|
||||
|
||||
if (uniqueScopes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (uniqueScopes.includes("all")) {
|
||||
uniqueScopes.splice(0, uniqueScopes.length, "all");
|
||||
}
|
||||
|
||||
const event: ConfigChangeEvent = {
|
||||
scopes: uniqueScopes,
|
||||
source: options?.source,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
const result = listener(event);
|
||||
if (result instanceof Promise) {
|
||||
result.catch((error) => {
|
||||
console.error("[ConfigSync] Async listener failed:", error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ConfigSync] Listener threw:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scopeMatches(
|
||||
event: ConfigChangeEvent,
|
||||
scope: ConfigChangeScope,
|
||||
): boolean {
|
||||
return event.scopes.includes("all") || event.scopes.includes(scope);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
const DEFAULT_MESSAGE = "Updating OpenCode configuration...";
|
||||
|
||||
type ConfigUpdateListener = (state: {
|
||||
isUpdating: boolean;
|
||||
message: string;
|
||||
}) => void;
|
||||
|
||||
let pendingCount = 0;
|
||||
let currentMessage = DEFAULT_MESSAGE;
|
||||
const listeners = new Set<ConfigUpdateListener>();
|
||||
|
||||
function notify() {
|
||||
const snapshot = {
|
||||
isUpdating: pendingCount > 0,
|
||||
message: currentMessage,
|
||||
};
|
||||
listeners.forEach((listener) => listener(snapshot));
|
||||
}
|
||||
|
||||
export function startConfigUpdate(message?: string) {
|
||||
pendingCount += 1;
|
||||
if (pendingCount === 1) {
|
||||
currentMessage = message || DEFAULT_MESSAGE;
|
||||
notify();
|
||||
} else if (message) {
|
||||
currentMessage = message;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function finishConfigUpdate() {
|
||||
if (pendingCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCount -= 1;
|
||||
if (pendingCount === 0) {
|
||||
currentMessage = DEFAULT_MESSAGE;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfigUpdateMessage(message: string) {
|
||||
if (currentMessage === message && pendingCount > 0) {
|
||||
return;
|
||||
}
|
||||
currentMessage = message;
|
||||
if (pendingCount > 0) {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeConfigUpdate(listener: ConfigUpdateListener) {
|
||||
listeners.add(listener);
|
||||
listener({
|
||||
isUpdating: pendingCount > 0,
|
||||
message: currentMessage,
|
||||
});
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfigUpdateSnapshot() {
|
||||
return {
|
||||
isUpdating: pendingCount > 0,
|
||||
message: currentMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
export interface DebugMessageInfo {
|
||||
messageId: string;
|
||||
role: string;
|
||||
timestamp: number;
|
||||
partsCount: number;
|
||||
parts: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
text?: string;
|
||||
textLength?: number;
|
||||
tool?: string;
|
||||
state?: any;
|
||||
}>;
|
||||
isEmpty: boolean;
|
||||
isEmptyResponse: boolean;
|
||||
raw: any;
|
||||
}
|
||||
|
||||
export const debugUtils = {
|
||||
|
||||
getLastAssistantMessage(): DebugMessageInfo | null {
|
||||
const state = useSessionStore.getState();
|
||||
const currentSessionId = state.currentSessionId;
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('[ERROR] No active session');
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = state.messages.get(currentSessionId);
|
||||
if (!messages || messages.length === 0) {
|
||||
console.log('[ERROR] No messages in current session');
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.info.role === 'assistant') {
|
||||
const parts = msg.parts.map((part: any) => {
|
||||
const info: any = {
|
||||
id: part.id,
|
||||
type: part.type,
|
||||
};
|
||||
|
||||
if (part.type === 'text') {
|
||||
info.text = part.text;
|
||||
info.textLength = part.text?.length || 0;
|
||||
} else if (part.type === 'tool') {
|
||||
info.tool = part.tool;
|
||||
info.state = part.state?.status;
|
||||
} else if (part.type === 'step-start' || part.type === 'step-finish') {
|
||||
info.isStepMarker = true;
|
||||
}
|
||||
|
||||
return info;
|
||||
});
|
||||
|
||||
const hasText = parts.some((p: any) => p.type === 'text' && p.text && p.text.trim().length > 0);
|
||||
const hasTools = parts.some((p: any) => p.type === 'tool');
|
||||
const hasStepMarkers = parts.some((p: any) => p.type === 'step-start' || p.type === 'step-finish');
|
||||
const isEmpty = parts.length === 0;
|
||||
const isEmptyResponse = !hasText && !hasTools && (!isEmpty || hasStepMarkers);
|
||||
|
||||
const info: DebugMessageInfo = {
|
||||
messageId: msg.info.id,
|
||||
role: msg.info.role,
|
||||
timestamp: msg.info.time?.created || 0,
|
||||
partsCount: parts.length,
|
||||
parts,
|
||||
isEmpty,
|
||||
isEmptyResponse,
|
||||
raw: msg,
|
||||
};
|
||||
|
||||
console.log('[INSPECT] Last Assistant Message:', info);
|
||||
console.log('[SUMMARY] Summary:', {
|
||||
messageId: info.messageId,
|
||||
partsCount: info.partsCount,
|
||||
isEmpty: info.isEmpty,
|
||||
isEmptyResponse: info.isEmptyResponse,
|
||||
hasText,
|
||||
hasTools,
|
||||
hasStepMarkers,
|
||||
onlyStepMarkers: hasStepMarkers && !hasText && !hasTools,
|
||||
});
|
||||
|
||||
if (info.isEmpty) {
|
||||
console.warn('[WARNING] Message has NO parts!');
|
||||
}
|
||||
|
||||
if (info.isEmptyResponse) {
|
||||
console.warn('[WARNING] Message has parts but NO meaningful content (empty text, no tools)!');
|
||||
|
||||
if (hasStepMarkers && !hasText && !hasTools) {
|
||||
console.warn('[CRITICAL] CLAUDE EMPTY RESPONSE BUG: Only step-start/step-finish markers, no actual content!');
|
||||
console.log('This is a known issue with Claude models (anthropic provider)');
|
||||
console.log('Recommendation: Send a follow-up message or try a different model');
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ERROR] No assistant messages found in current session');
|
||||
return null;
|
||||
},
|
||||
|
||||
truncateString(value: string | undefined, maxLength: number = 80): string | undefined {
|
||||
if (!value || typeof value !== 'string') return value;
|
||||
if (value.length <= maxLength) return value;
|
||||
return value.substring(0, maxLength) + '…';
|
||||
},
|
||||
|
||||
truncateMessages(messages: any[]): any[] {
|
||||
return messages.map((msg) => ({
|
||||
...msg,
|
||||
parts: (msg.parts || []).map((part: any) => {
|
||||
const truncatedPart: any = { ...part };
|
||||
|
||||
if ('text' in part) {
|
||||
truncatedPart.text = this.truncateString(part.text);
|
||||
}
|
||||
if ('textPreview' in part) {
|
||||
truncatedPart.textPreview = this.truncateString(part.textPreview);
|
||||
}
|
||||
|
||||
if (part.state) {
|
||||
truncatedPart.state = { ...part.state };
|
||||
|
||||
if ('output' in part.state) {
|
||||
truncatedPart.state.output = this.truncateString(part.state.output);
|
||||
}
|
||||
if ('error' in part.state) {
|
||||
truncatedPart.state.error = this.truncateString(part.state.error);
|
||||
}
|
||||
if (part.state.metadata && 'preview' in part.state.metadata) {
|
||||
truncatedPart.state.metadata = {
|
||||
...part.state.metadata,
|
||||
preview: this.truncateString(part.state.metadata.preview),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return truncatedPart;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
getAllMessages(truncate: boolean = false) {
|
||||
const state = useSessionStore.getState();
|
||||
const currentSessionId = state.currentSessionId;
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('[ERROR] No active session');
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = state.messages.get(currentSessionId) || [];
|
||||
console.log(`[MESSAGES] Total messages in session: ${messages.length}`);
|
||||
|
||||
messages.forEach((msg, idx) => {
|
||||
console.log(`[${idx}] ${msg.info.role} - ${msg.info.id} - ${msg.parts.length} parts`);
|
||||
});
|
||||
|
||||
return truncate ? this.truncateMessages(messages) : messages;
|
||||
},
|
||||
|
||||
checkLastMessage() {
|
||||
const info = this.getLastAssistantMessage();
|
||||
if (!info) return false;
|
||||
|
||||
const isProblematic = info.isEmpty || info.isEmptyResponse;
|
||||
|
||||
if (isProblematic) {
|
||||
console.error('[ALERT] PROBLEMATIC MESSAGE DETECTED!');
|
||||
console.log('Details:', {
|
||||
messageId: info.messageId,
|
||||
isEmpty: info.isEmpty,
|
||||
isEmptyResponse: info.isEmptyResponse,
|
||||
partsCount: info.partsCount,
|
||||
});
|
||||
|
||||
if (info.parts.length > 0) {
|
||||
console.log('Parts:', info.parts);
|
||||
}
|
||||
} else {
|
||||
console.log('[OK] Last message looks good!');
|
||||
}
|
||||
|
||||
return isProblematic;
|
||||
},
|
||||
|
||||
getStreamingState() {
|
||||
const state = useSessionStore.getState();
|
||||
const currentStreamingId = state.currentSessionId
|
||||
? state.streamingMessageIds.get(state.currentSessionId) ?? null
|
||||
: null;
|
||||
console.log('[STREAM] Streaming State:', {
|
||||
streamingMessageId: currentStreamingId,
|
||||
streamingMessageIds: Array.from(state.streamingMessageIds.entries()),
|
||||
messageStreamStates: Array.from(state.messageStreamStates.entries()),
|
||||
});
|
||||
return {
|
||||
streamingMessageId: currentStreamingId,
|
||||
streamingMessageIds: state.streamingMessageIds,
|
||||
streamStates: state.messageStreamStates,
|
||||
};
|
||||
},
|
||||
|
||||
findEmptyMessages() {
|
||||
const state = useSessionStore.getState();
|
||||
const currentSessionId = state.currentSessionId;
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('[ERROR] No active session');
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = state.messages.get(currentSessionId) || [];
|
||||
const emptyMessages = messages
|
||||
.filter((msg) => msg.info.role === 'assistant')
|
||||
.filter((msg) => {
|
||||
const parts = msg.parts || [];
|
||||
const hasTextContent = parts.some(
|
||||
(p: any) => p.type === 'text' && p.text && p.text.trim().length > 0
|
||||
);
|
||||
const hasTools = parts.some((p: any) => p.type === 'tool');
|
||||
|
||||
return parts.length === 0 || (!hasTextContent && !hasTools);
|
||||
});
|
||||
|
||||
console.log(`[INSPECT] Found ${emptyMessages.length} empty assistant messages`);
|
||||
|
||||
emptyMessages.forEach((msg, idx) => {
|
||||
console.log(`[${idx}] Empty message:`, {
|
||||
messageId: msg.info.id,
|
||||
partsCount: msg.parts.length,
|
||||
provider: (msg.info as any).providerID,
|
||||
model: (msg.info as any).modelID,
|
||||
timestamp: msg.info.time?.created,
|
||||
});
|
||||
});
|
||||
|
||||
return emptyMessages;
|
||||
},
|
||||
|
||||
showRetryHelp() {
|
||||
console.log('[DEBUG] How to handle empty Claude responses:\n');
|
||||
console.log('1. Check the last message:');
|
||||
console.log(' __opencodeDebug.getLastAssistantMessage()\n');
|
||||
console.log('2. Find all empty messages in session:');
|
||||
console.log(' __opencodeDebug.findEmptyMessages()\n');
|
||||
console.log('3. To retry, you can:');
|
||||
console.log(' - Edit your last user message and resend');
|
||||
console.log(' - Send a follow-up message like "Please provide the response"');
|
||||
console.log(' - Try a different model (OpenAI models tend to be more reliable)\n');
|
||||
console.log('[TIP] Empty responses are usually due to:');
|
||||
console.log(' - Model rate limits');
|
||||
console.log(' - Context length issues');
|
||||
console.log(' - Model refusing to respond to certain prompts');
|
||||
console.log(' - API errors from provider');
|
||||
},
|
||||
|
||||
analyzeMessageCompletionConsistency(options: {
|
||||
includeNonAssistant?: boolean;
|
||||
verbose?: boolean;
|
||||
maxTableRows?: number;
|
||||
} = {}) {
|
||||
const { includeNonAssistant = false, verbose = true, maxTableRows = 25 } = options;
|
||||
const state = useSessionStore.getState();
|
||||
const currentSessionId = state.currentSessionId;
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('[ERROR] No active session');
|
||||
return { summary: null, rows: [] };
|
||||
}
|
||||
|
||||
const messages = state.messages.get(currentSessionId) || [];
|
||||
const targetMessages = includeNonAssistant
|
||||
? messages
|
||||
: messages.filter((msg) => msg.info.role === 'assistant');
|
||||
|
||||
const summary = {
|
||||
totalMessages: messages.length,
|
||||
analyzedMessages: targetMessages.length,
|
||||
completedMissing: 0,
|
||||
completedBeforeTool: 0,
|
||||
completedBeforeReasoning: 0,
|
||||
runningToolsWhenCompleted: 0,
|
||||
reasoningOpenWhenCompleted: 0,
|
||||
withTools: 0,
|
||||
withReasoning: 0,
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const getLatestTimestamp = (timestamps: Array<number | null>): number | null => {
|
||||
const filtered = timestamps.filter((value): value is number => typeof value === 'number');
|
||||
return filtered.length > 0 ? Math.max(...filtered) : null;
|
||||
};
|
||||
|
||||
const rows = targetMessages.map((message, index) => {
|
||||
const info = message.info ?? {};
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [];
|
||||
|
||||
const timeInfo = (info.time ?? {}) as { completed?: number };
|
||||
const completedAt = toNumber(timeInfo.completed);
|
||||
const hasCompleted = completedAt !== null;
|
||||
|
||||
const toolParts = parts.filter((part: any) => part.type === 'tool');
|
||||
const reasoningParts = parts.filter((part: any) => part.type === 'reasoning');
|
||||
|
||||
const latestToolTimestamp = getLatestTimestamp(
|
||||
toolParts.map((part: any) =>
|
||||
toNumber(part.state?.time?.end ?? part.state?.time?.start)
|
||||
)
|
||||
);
|
||||
const latestReasoningTimestamp = getLatestTimestamp(
|
||||
reasoningParts.map((part: any) => toNumber(part.time?.end ?? part.time?.start))
|
||||
);
|
||||
const latestPartTimestamp = getLatestTimestamp(
|
||||
[latestToolTimestamp, latestReasoningTimestamp].filter((value) => value !== null)
|
||||
);
|
||||
|
||||
const hasRunningTool = toolParts.some((part: any) =>
|
||||
['pending', 'running', 'started'].includes(part.state?.status)
|
||||
);
|
||||
const reasoningIncomplete = reasoningParts.some(
|
||||
(part: any) => typeof part.time?.end !== 'number'
|
||||
);
|
||||
|
||||
if (toolParts.length > 0) {
|
||||
summary.withTools += 1;
|
||||
}
|
||||
if (reasoningParts.length > 0) {
|
||||
summary.withReasoning += 1;
|
||||
}
|
||||
|
||||
if (!hasCompleted) {
|
||||
summary.completedMissing += 1;
|
||||
}
|
||||
|
||||
const completedBeforeTool = Boolean(
|
||||
hasCompleted &&
|
||||
typeof latestToolTimestamp === 'number' &&
|
||||
completedAt! < latestToolTimestamp
|
||||
);
|
||||
if (completedBeforeTool) {
|
||||
summary.completedBeforeTool += 1;
|
||||
}
|
||||
|
||||
const completedBeforeReasoning = Boolean(
|
||||
hasCompleted &&
|
||||
typeof latestReasoningTimestamp === 'number' &&
|
||||
completedAt! < latestReasoningTimestamp
|
||||
);
|
||||
if (completedBeforeReasoning) {
|
||||
summary.completedBeforeReasoning += 1;
|
||||
}
|
||||
|
||||
if (hasCompleted && hasRunningTool) {
|
||||
summary.runningToolsWhenCompleted += 1;
|
||||
}
|
||||
if (hasCompleted && reasoningIncomplete) {
|
||||
summary.reasoningOpenWhenCompleted += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
messageId: info.id,
|
||||
role: info.role,
|
||||
completedAt,
|
||||
latestToolTimestamp,
|
||||
latestReasoningTimestamp,
|
||||
latestPartTimestamp,
|
||||
completed_missing: !hasCompleted,
|
||||
completed_before_tool: completedBeforeTool,
|
||||
completed_before_reasoning: completedBeforeReasoning,
|
||||
running_tools_when_completed: Boolean(hasCompleted && hasRunningTool),
|
||||
reasoning_open_when_completed: Boolean(hasCompleted && reasoningIncomplete),
|
||||
has_tools: toolParts.length > 0,
|
||||
has_reasoning: reasoningParts.length > 0,
|
||||
};
|
||||
});
|
||||
|
||||
if (verbose) {
|
||||
console.table(rows.slice(0, maxTableRows));
|
||||
if (rows.length > maxTableRows) {
|
||||
console.log(`Displayed first ${maxTableRows} rows out of ${rows.length}.`);
|
||||
}
|
||||
console.log('[SUMMARY] Message completion timing:', summary);
|
||||
}
|
||||
|
||||
return { summary, rows };
|
||||
},
|
||||
|
||||
checkCompletionStatus() {
|
||||
const state = useSessionStore.getState();
|
||||
const currentSessionId = state.currentSessionId;
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('[ERROR] No active session');
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = state.messages.get(currentSessionId) || [];
|
||||
const assistantMessages = messages.filter(m => m.info.role === 'assistant');
|
||||
|
||||
if (assistantMessages.length === 0) {
|
||||
console.log('[ERROR] No assistant messages');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastMessage = assistantMessages[assistantMessages.length - 1];
|
||||
const stepFinishParts = lastMessage.parts.filter((p: any) => p.type === 'step-finish');
|
||||
const hasStopReason = lastMessage.parts.some((p: any) => p.type === 'step-finish' && p.reason === 'stop');
|
||||
|
||||
const timeInfo = lastMessage.info.time as any;
|
||||
const completedAt = timeInfo?.completed;
|
||||
const messageStatus = (lastMessage.info as any).status;
|
||||
const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed';
|
||||
const messageIsComplete = Boolean(hasCompletedFlag && hasStopReason);
|
||||
|
||||
const messageStreamStates = state.messageStreamStates;
|
||||
const streamingMessageId = (lastMessage.info as { sessionID?: string }).sessionID
|
||||
? state.streamingMessageIds.get((lastMessage.info as { sessionID?: string }).sessionID as string) ?? null
|
||||
: null;
|
||||
const lifecycle = messageStreamStates.get(lastMessage.info.id);
|
||||
const isStreamingCandidate = lastMessage.info.id === streamingMessageId;
|
||||
|
||||
console.log('[SUMMARY] Completion Status:');
|
||||
console.log('Message ID:', lastMessage.info.id);
|
||||
console.log('time.completed:', completedAt, '(type:', typeof completedAt, ')');
|
||||
console.log('status:', messageStatus);
|
||||
console.log('hasCompletedFlag:', hasCompletedFlag);
|
||||
console.log('hasStopReason:', hasStopReason);
|
||||
console.log('messageIsComplete:', messageIsComplete);
|
||||
console.log('lifecycle phase:', lifecycle?.phase);
|
||||
console.log('isStreamingCandidate:', isStreamingCandidate);
|
||||
console.log('streamingMessageId:', streamingMessageId);
|
||||
console.log('Step-finish parts:', stepFinishParts);
|
||||
|
||||
return {
|
||||
messageId: lastMessage.info.id,
|
||||
completed: completedAt,
|
||||
status: messageStatus,
|
||||
hasCompletedFlag,
|
||||
hasStopReason,
|
||||
messageIsComplete,
|
||||
stepFinishParts,
|
||||
raw: lastMessage,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__opencodeDebug = debugUtils;
|
||||
console.log('[DEBUG] OpenCode Debug Utils loaded! Use window.__opencodeDebug in console');
|
||||
console.log('Available commands:');
|
||||
console.log(' __opencodeDebug.getLastAssistantMessage() - Get last assistant message details');
|
||||
console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)');
|
||||
console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array');
|
||||
console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic');
|
||||
console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages');
|
||||
console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses');
|
||||
console.log(' __opencodeDebug.getStreamingState() - Get streaming state info');
|
||||
console.log(' __opencodeDebug.analyzeMessageCompletionConsistency(opts?) - Compare time.completed vs part timings');
|
||||
console.log(' __opencodeDebug.checkCompletionStatus() - Check completion status of last message');
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
try {
|
||||
const message = event.message || '';
|
||||
const source = event.filename || '';
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
message.includes("this._renderer.value.dimensions") &&
|
||||
/xterm/i.test(String(source))
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
} catch { /* ignored */ }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
export type AssistantNotificationPayload = {
|
||||
title?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export type UpdateInfo = {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
currentVersion: string;
|
||||
body?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
export type UpdateProgress = {
|
||||
downloaded: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type DesktopServerInfo = {
|
||||
webPort: number | null;
|
||||
openCodePort: number | null;
|
||||
host: string | null;
|
||||
ready: boolean;
|
||||
cliAvailable: boolean;
|
||||
};
|
||||
|
||||
export type DesktopSettings = {
|
||||
themeId?: string;
|
||||
useSystemTheme?: boolean;
|
||||
themeVariant?: 'light' | 'dark';
|
||||
lightThemeId?: string;
|
||||
darkThemeId?: string;
|
||||
lastDirectory?: string;
|
||||
homeDirectory?: string;
|
||||
approvedDirectories?: string[];
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
};
|
||||
|
||||
export type DesktopSettingsApi = {
|
||||
getSettings: () => Promise<DesktopSettings>;
|
||||
updateSettings: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
|
||||
};
|
||||
|
||||
export type DesktopApi = {
|
||||
homeDirectory?: string;
|
||||
getServerInfo: () => Promise<DesktopServerInfo>;
|
||||
restartOpenCode: () => Promise<{ success: boolean }>;
|
||||
shutdown: () => Promise<{ success: boolean }>;
|
||||
markRendererReady?: () => Promise<void> | void;
|
||||
windowControl?: (action: 'close' | 'minimize' | 'maximize') => Promise<{ success: boolean }>;
|
||||
getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>;
|
||||
getSettings?: () => Promise<DesktopSettings>;
|
||||
updateSettings?: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
|
||||
requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; error?: string }>;
|
||||
startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
|
||||
stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
|
||||
notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>;
|
||||
checkForUpdates?: () => Promise<UpdateInfo>;
|
||||
downloadUpdate?: (onProgress?: (progress: UpdateProgress) => void) => Promise<void>;
|
||||
restartToUpdate?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const isDesktopRuntime = (): boolean =>
|
||||
typeof window !== "undefined" && typeof window.opencodeDesktop !== "undefined";
|
||||
|
||||
export const getDesktopApi = (): DesktopApi | null => {
|
||||
if (!isDesktopRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return window.opencodeDesktop ?? null;
|
||||
};
|
||||
|
||||
export const getDesktopSettingsApi = (): DesktopSettingsApi | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
if (window.opencodeDesktopSettings) {
|
||||
return window.opencodeDesktopSettings;
|
||||
}
|
||||
const base = window.opencodeDesktop;
|
||||
if (base?.getSettings && base?.updateSettings) {
|
||||
return {
|
||||
getSettings: base.getSettings.bind(base),
|
||||
updateSettings: base.updateSettings.bind(base)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDesktopHomeDirectory = async (): Promise<string | null> => {
|
||||
const api = getDesktopApi();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const embedded = window.__OPENCHAMBER_HOME__;
|
||||
if (embedded && embedded.length > 0) {
|
||||
return embedded;
|
||||
}
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof api.homeDirectory === 'string' && api.homeDirectory.length > 0) {
|
||||
return api.homeDirectory;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!api.getHomeDirectory) {
|
||||
return null;
|
||||
}
|
||||
const result = await api.getHomeDirectory();
|
||||
if (result?.success && typeof result.path === 'string' && result.path.length > 0) {
|
||||
return result.path;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to obtain desktop home directory:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const fetchDesktopServerInfo = async (): Promise<DesktopServerInfo | null> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await api.getServerInfo();
|
||||
} catch (error) {
|
||||
console.warn("Failed to read desktop server info", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isCliAvailable = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.__OPENCHAMBER_DESKTOP_SERVER__?.cliAvailable ?? false;
|
||||
};
|
||||
|
||||
export const getDesktopSettings = async (): Promise<DesktopSettings | null> => {
|
||||
const api = getDesktopSettingsApi();
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await api.getSettings();
|
||||
} catch (error) {
|
||||
console.warn('Failed to read desktop settings', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDesktopSettings = async (
|
||||
changes: Partial<DesktopSettings>
|
||||
): Promise<DesktopSettings | null> => {
|
||||
const api = getDesktopSettingsApi();
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await api.updateSettings(changes);
|
||||
} catch (error) {
|
||||
console.warn('[desktop] Failed to update desktop settings', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestDirectoryAccess = async (
|
||||
directoryPath: string
|
||||
): Promise<{ success: boolean; path?: string; error?: string }> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api || !api.requestDirectoryAccess) {
|
||||
return { success: true, path: directoryPath };
|
||||
}
|
||||
try {
|
||||
return await api.requestDirectoryAccess(directoryPath);
|
||||
} catch (error) {
|
||||
console.warn('Failed to request directory access', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const startAccessingDirectory = async (
|
||||
directoryPath: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api || !api.startAccessingDirectory) {
|
||||
return { success: true };
|
||||
}
|
||||
try {
|
||||
return await api.startAccessingDirectory(directoryPath);
|
||||
} catch (error) {
|
||||
console.warn('Failed to start accessing directory', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const stopAccessingDirectory = async (
|
||||
directoryPath: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api || !api.stopAccessingDirectory) {
|
||||
return { success: true };
|
||||
}
|
||||
try {
|
||||
return await api.stopAccessingDirectory(directoryPath);
|
||||
} catch (error) {
|
||||
console.warn('Failed to stop accessing directory', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const sendAssistantCompletionNotification = async (
|
||||
payload?: AssistantNotificationPayload
|
||||
): Promise<boolean> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api || !api.notifyAssistantCompletion) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const result = await api.notifyAssistantCompletion(payload ?? {});
|
||||
return Boolean(result?.success);
|
||||
} catch (error) {
|
||||
console.warn('Failed to send assistant completion notification', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api || !api.checkForUpdates) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await api.checkForUpdates();
|
||||
} catch (error) {
|
||||
console.warn('Failed to check for updates', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadDesktopUpdate = async (
|
||||
onProgress?: (progress: UpdateProgress) => void
|
||||
): Promise<boolean> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api || !api.downloadUpdate) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await api.downloadUpdate(onProgress);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to download update', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const restartToApplyUpdate = async (): Promise<boolean> => {
|
||||
const api = getDesktopApi();
|
||||
if (!api || !api.restartToUpdate) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await api.restartToUpdate();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to restart for update', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
|
||||
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
||||
|
||||
export interface DeviceInfo {
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
deviceType: DeviceType;
|
||||
screenWidth: number;
|
||||
breakpoint: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
hasTouchInput: 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 = {
|
||||
xs: 0,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1536,
|
||||
} as const;
|
||||
|
||||
const setRootDeviceAttributes = (
|
||||
isDesktopRuntime: boolean,
|
||||
deviceType: DeviceType,
|
||||
hasTouchInput: boolean,
|
||||
) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
|
||||
if (isDesktopRuntime) {
|
||||
root.classList.add('desktop-runtime');
|
||||
root.style.setProperty('--is-mobile', '0');
|
||||
root.style.setProperty('--device-type', 'desktop');
|
||||
root.style.setProperty('--font-scale', '1');
|
||||
root.style.setProperty('--has-coarse-pointer', '0');
|
||||
root.style.setProperty('--has-touch-input', '0');
|
||||
root.classList.remove('mobile-pointer');
|
||||
} else {
|
||||
root.classList.remove('desktop-runtime');
|
||||
root.style.setProperty('--is-mobile', isMobile ? '1' : '0');
|
||||
root.style.setProperty('--device-type', deviceType);
|
||||
root.style.setProperty('--font-scale', isMobile ? '0.9' : isTablet ? '0.95' : '1');
|
||||
root.style.setProperty('--has-coarse-pointer', hasTouchInput ? '1' : '0');
|
||||
root.style.setProperty('--has-touch-input', hasTouchInput ? '1' : '0');
|
||||
if (hasTouchInput) {
|
||||
root.classList.add('mobile-pointer');
|
||||
} else {
|
||||
root.classList.remove('mobile-pointer');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function getDeviceInfo(): DeviceInfo {
|
||||
const width = window.innerWidth;
|
||||
const supportsMatchMedia = typeof window.matchMedia === 'function';
|
||||
const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null;
|
||||
const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null;
|
||||
const prefersCoarsePointer = pointerQuery?.matches ?? false;
|
||||
const noHover = hoverQuery?.matches ?? false;
|
||||
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
|
||||
|
||||
const isDesktopRuntime = typeof window !== 'undefined' && typeof window.opencodeDesktop !== 'undefined';
|
||||
|
||||
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
|
||||
|
||||
const isTabletWidth = width > BREAKPOINTS.md && width <= BREAKPOINTS.lg;
|
||||
const isMobileWidth = width <= BREAKPOINTS.md;
|
||||
|
||||
let isMobile = hasTouchInput && isMobileWidth;
|
||||
let isTablet = hasTouchInput && !isMobile && isTabletWidth;
|
||||
let isDesktop = !hasTouchInput || width > BREAKPOINTS.lg;
|
||||
let deviceType: DeviceType = 'desktop';
|
||||
|
||||
if (isDesktopRuntime) {
|
||||
isMobile = false;
|
||||
isTablet = false;
|
||||
isDesktop = true;
|
||||
deviceType = 'desktop';
|
||||
} else if (isMobile) {
|
||||
deviceType = 'mobile';
|
||||
} else if (isTablet) {
|
||||
deviceType = 'tablet';
|
||||
} else {
|
||||
isDesktop = true;
|
||||
deviceType = 'desktop';
|
||||
}
|
||||
|
||||
setRootDeviceAttributes(isDesktopRuntime, deviceType, hasTouchInput);
|
||||
|
||||
let breakpoint: keyof typeof BREAKPOINTS = 'xs';
|
||||
for (const [key, value] of Object.entries(BREAKPOINTS)) {
|
||||
if (width >= value) {
|
||||
breakpoint = key as keyof typeof BREAKPOINTS;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
deviceType,
|
||||
screenWidth: width,
|
||||
breakpoint,
|
||||
hasTouchInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function isMobileDeviceViaCSS(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
if (typeof window.opencodeDesktop !== 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const isMobileValue = root.style.getPropertyValue('--is-mobile') ||
|
||||
getComputedStyle(root).getPropertyValue('--is-mobile');
|
||||
|
||||
return isMobileValue === '1' || isMobileValue === 'true';
|
||||
}
|
||||
|
||||
export function useDeviceInfo(): DeviceInfo {
|
||||
const [deviceInfo, setDeviceInfo] = React.useState<DeviceInfo>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: true,
|
||||
deviceType: 'desktop',
|
||||
screenWidth: 1024,
|
||||
breakpoint: 'lg',
|
||||
hasTouchInput: false,
|
||||
};
|
||||
}
|
||||
return getDeviceInfo();
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleResize = () => {
|
||||
setDeviceInfo(getDeviceInfo());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerQuery = window.matchMedia('(pointer: coarse)');
|
||||
const hoverQuery = window.matchMedia('(hover: none)');
|
||||
|
||||
const handlePointerChange = () => {
|
||||
setDeviceInfo(getDeviceInfo());
|
||||
};
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
const attachListener = (query: MediaQueryList | null) => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
if (typeof query.addEventListener === 'function') {
|
||||
query.addEventListener('change', handlePointerChange);
|
||||
cleanups.push(() => query.removeEventListener('change', handlePointerChange));
|
||||
} else if (typeof query.addListener === 'function') {
|
||||
query.addListener(handlePointerChange);
|
||||
cleanups.push(() => query.removeListener(handlePointerChange));
|
||||
}
|
||||
};
|
||||
|
||||
attachListener(pointerQuery);
|
||||
attachListener(hoverQuery);
|
||||
|
||||
return () => {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const isDesktopRuntime = typeof window.opencodeDesktop !== 'undefined';
|
||||
const supportsMatchMedia = typeof window.matchMedia === 'function';
|
||||
const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null;
|
||||
const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null;
|
||||
const prefersCoarsePointer = pointerQuery?.matches ?? false;
|
||||
const noHover = hoverQuery?.matches ?? false;
|
||||
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
|
||||
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
|
||||
setRootDeviceAttributes(isDesktopRuntime, deviceInfo.deviceType, hasTouchInput);
|
||||
}, [deviceInfo.deviceType, deviceInfo.hasTouchInput]);
|
||||
|
||||
return deviceInfo;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
|
||||
export const applyPersistedDirectoryPreferences = async (): Promise<void> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let savedHome: string | null = null;
|
||||
let savedDirectory: string | null = null;
|
||||
|
||||
try {
|
||||
savedHome = window.localStorage.getItem('homeDirectory');
|
||||
savedDirectory = window.localStorage.getItem('lastDirectory');
|
||||
} catch (error) {
|
||||
console.warn('Failed to read saved directory preferences:', error);
|
||||
}
|
||||
|
||||
const directoryStore = useDirectoryStore.getState();
|
||||
|
||||
if (savedHome && directoryStore.homeDirectory !== savedHome) {
|
||||
directoryStore.synchronizeHomeDirectory(savedHome);
|
||||
}
|
||||
|
||||
if (savedDirectory) {
|
||||
directoryStore.setDirectory(savedDirectory, { showOverlay: false });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
export type UiFontOption = 'ibm-plex-sans';
|
||||
|
||||
export type MonoFontOption = 'ibm-plex-mono';
|
||||
|
||||
export interface FontOptionDefinition<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
description: string;
|
||||
stack: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const UI_FONT_OPTIONS: FontOptionDefinition<UiFontOption>[] = [
|
||||
{
|
||||
id: 'ibm-plex-sans',
|
||||
label: 'IBM Plex Sans',
|
||||
description: 'Humanist sans-serif for optimal readability in the interface.',
|
||||
stack: '"IBM Plex Sans", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
}
|
||||
];
|
||||
|
||||
export const CODE_FONT_OPTIONS: FontOptionDefinition<MonoFontOption>[] = [
|
||||
{
|
||||
id: 'ibm-plex-mono',
|
||||
label: 'IBM Plex Mono',
|
||||
description: 'Balanced monospace for code blocks and technical content.',
|
||||
stack: '"IBM Plex Mono", "SFMono-Regular", "Menlo", monospace'
|
||||
}
|
||||
];
|
||||
|
||||
const buildFontMap = <T extends string>(options: FontOptionDefinition<T>[]) =>
|
||||
Object.fromEntries(options.map((option) => [option.id, option])) as Record<T, FontOptionDefinition<T>>;
|
||||
|
||||
export const UI_FONT_OPTION_MAP = buildFontMap(UI_FONT_OPTIONS);
|
||||
export const CODE_FONT_OPTION_MAP = buildFontMap(CODE_FONT_OPTIONS);
|
||||
|
||||
export const DEFAULT_UI_FONT: UiFontOption = 'ibm-plex-sans';
|
||||
export const DEFAULT_MONO_FONT: MonoFontOption = 'ibm-plex-mono';
|
||||
@@ -0,0 +1,167 @@
|
||||
import { addGitWorktree, deleteGitBranch, deleteRemoteBranch, getGitStatus, listGitWorktrees, removeGitWorktree, type GitAddWorktreePayload, type GitWorktreeInfo } from '@/lib/gitApi';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
if (replaced === '/') {
|
||||
return '/';
|
||||
}
|
||||
return replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const joinPath = (base: string, segment: string): string => {
|
||||
const normalizedBase = normalize(base);
|
||||
const sanitizedSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!normalizedBase || normalizedBase === '/') {
|
||||
return `/${sanitizedSegment}`;
|
||||
}
|
||||
return `${normalizedBase}/${sanitizedSegment}`;
|
||||
};
|
||||
|
||||
const shortBranchLabel = (branch?: string): string => {
|
||||
if (!branch) {
|
||||
return '';
|
||||
}
|
||||
if (branch.startsWith('refs/heads/')) {
|
||||
return branch.substring('refs/heads/'.length);
|
||||
}
|
||||
if (branch.startsWith('heads/')) {
|
||||
return branch.substring('heads/'.length);
|
||||
}
|
||||
if (branch.startsWith('refs/')) {
|
||||
return branch.substring('refs/'.length);
|
||||
}
|
||||
return branch;
|
||||
};
|
||||
|
||||
const ensureDirectory = async (path: string) => {
|
||||
try {
|
||||
await opencodeClient.createDirectory(path);
|
||||
} catch (error) {
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (/exist/i.test(error.message)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export interface CreateWorktreeOptions {
|
||||
projectDirectory: string;
|
||||
worktreeSlug: string;
|
||||
branch: string;
|
||||
createBranch?: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveWorktreeOptions {
|
||||
projectDirectory: string;
|
||||
path: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface ArchiveWorktreeOptions {
|
||||
projectDirectory: string;
|
||||
path: string;
|
||||
branch: string;
|
||||
force?: boolean;
|
||||
deleteRemote?: boolean;
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
export async function resolveWorktreePath(projectDirectory: string, worktreeSlug: string): Promise<string> {
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const root = joinPath(normalizedProject, WORKTREE_ROOT);
|
||||
await ensureDirectory(root);
|
||||
return joinPath(root, worktreeSlug);
|
||||
}
|
||||
|
||||
export async function createWorktree(options: CreateWorktreeOptions): Promise<WorktreeMetadata> {
|
||||
const { projectDirectory, worktreeSlug, branch, createBranch } = options;
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug);
|
||||
|
||||
const payload: GitAddWorktreePayload = {
|
||||
path: worktreePath,
|
||||
branch,
|
||||
createBranch: Boolean(createBranch),
|
||||
};
|
||||
|
||||
await addGitWorktree(normalizedProject, payload);
|
||||
|
||||
return {
|
||||
path: worktreePath,
|
||||
branch,
|
||||
label: shortBranchLabel(branch),
|
||||
projectDirectory: normalizedProject,
|
||||
relativePath: worktreePath.startsWith(`${normalizedProject}/`)
|
||||
? worktreePath.slice(normalizedProject.length + 1)
|
||||
: worktreePath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeWorktree(options: RemoveWorktreeOptions): Promise<void> {
|
||||
const { projectDirectory, path, force } = options;
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
await removeGitWorktree(normalizedProject, { path, force });
|
||||
}
|
||||
|
||||
export async function archiveWorktree(options: ArchiveWorktreeOptions): Promise<void> {
|
||||
const { projectDirectory, path, branch, force, deleteRemote, remote } = options;
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const normalizedBranch = branch.startsWith('refs/heads/')
|
||||
? branch.substring('refs/heads/'.length)
|
||||
: branch;
|
||||
|
||||
await removeGitWorktree(normalizedProject, { path, force });
|
||||
if (normalizedBranch) {
|
||||
await deleteGitBranch(normalizedProject, { branch: normalizedBranch, force: true });
|
||||
if (deleteRemote) {
|
||||
try {
|
||||
await deleteRemoteBranch(normalizedProject, {
|
||||
branch: normalizedBranch,
|
||||
remote,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete remote branch during worktree archive:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorktrees(projectDirectory: string): Promise<GitWorktreeInfo[]> {
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
return listGitWorktrees(normalizedProject);
|
||||
}
|
||||
|
||||
export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeMetadata['status']> {
|
||||
const normalizedPath = normalize(worktreePath);
|
||||
const status = await getGitStatus(normalizedPath);
|
||||
return {
|
||||
isDirty: !status.isClean,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
upstream: status.tracking,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorktreeToMetadata(projectDirectory: string, info: GitWorktreeInfo): WorktreeMetadata {
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const normalizedPath = normalize(info.worktree);
|
||||
return {
|
||||
path: normalizedPath,
|
||||
branch: info.branch ?? '',
|
||||
label: shortBranchLabel(info.branch ?? ''),
|
||||
projectDirectory: normalizedProject,
|
||||
relativePath: normalizedPath.startsWith(`${normalizedProject}/`)
|
||||
? normalizedPath.slice(normalizedProject.length + 1)
|
||||
: normalizedPath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
|
||||
|
||||
import type { RuntimeAPIs } from './api/types';
|
||||
import * as gitHttp from './gitApiHttp';
|
||||
|
||||
export type {
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
GetGitDiffOptions,
|
||||
GitBranchDetails,
|
||||
GitBranch,
|
||||
GitCommitResult,
|
||||
GitPushResult,
|
||||
GitPullResult,
|
||||
GitIdentityProfile,
|
||||
GitIdentitySummary,
|
||||
GitLogEntry,
|
||||
GitLogResponse,
|
||||
GitWorktreeInfo,
|
||||
GitAddWorktreePayload,
|
||||
GitRemoveWorktreePayload,
|
||||
GitDeleteBranchPayload,
|
||||
GitDeleteRemoteBranchPayload,
|
||||
} from './api/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
}
|
||||
}
|
||||
|
||||
const getRuntimeGit = () => {
|
||||
if (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTIME_APIS__?.git) {
|
||||
return window.__OPENCHAMBER_RUNTIME_APIS__.git;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.checkIsGitRepository(directory);
|
||||
return gitHttp.checkIsGitRepository(directory);
|
||||
}
|
||||
|
||||
export async function getGitStatus(directory: string): Promise<import('./api/types').GitStatus> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitStatus(directory);
|
||||
return gitHttp.getGitStatus(directory);
|
||||
}
|
||||
|
||||
export async function getGitDiff(directory: string, options: import('./api/types').GetGitDiffOptions): Promise<import('./api/types').GitDiffResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitDiff(directory, options);
|
||||
return gitHttp.getGitDiff(directory, options);
|
||||
}
|
||||
|
||||
export async function getGitFileDiff(
|
||||
directory: string,
|
||||
options: import('./api/types').GetGitFileDiffOptions
|
||||
): Promise<import('./api/types').GitFileDiffResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitFileDiff(directory, options);
|
||||
return gitHttp.getGitFileDiff(directory, options);
|
||||
}
|
||||
|
||||
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.revertGitFile(directory, filePath);
|
||||
return gitHttp.revertGitFile(directory, filePath);
|
||||
}
|
||||
|
||||
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.isLinkedWorktree(directory);
|
||||
return gitHttp.isLinkedWorktree(directory);
|
||||
}
|
||||
|
||||
export async function getGitBranches(directory: string): Promise<import('./api/types').GitBranch> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitBranches(directory);
|
||||
return gitHttp.getGitBranches(directory);
|
||||
}
|
||||
|
||||
export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.deleteGitBranch(directory, payload);
|
||||
return gitHttp.deleteGitBranch(directory, payload);
|
||||
}
|
||||
|
||||
export async function deleteRemoteBranch(directory: string, payload: import('./api/types').GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.deleteRemoteBranch(directory, payload);
|
||||
return gitHttp.deleteRemoteBranch(directory, payload);
|
||||
}
|
||||
|
||||
export async function generateCommitMessage(
|
||||
directory: string,
|
||||
files: string[]
|
||||
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.generateCommitMessage(directory, files);
|
||||
return gitHttp.generateCommitMessage(directory, files);
|
||||
}
|
||||
|
||||
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.listGitWorktrees(directory);
|
||||
return gitHttp.listGitWorktrees(directory);
|
||||
}
|
||||
|
||||
export async function addGitWorktree(directory: string, payload: import('./api/types').GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.addGitWorktree(directory, payload);
|
||||
return gitHttp.addGitWorktree(directory, payload);
|
||||
}
|
||||
|
||||
export async function removeGitWorktree(directory: string, payload: import('./api/types').GitRemoveWorktreePayload): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.removeGitWorktree(directory, payload);
|
||||
return gitHttp.removeGitWorktree(directory, payload);
|
||||
}
|
||||
|
||||
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.ensureOpenChamberIgnored(directory);
|
||||
return gitHttp.ensureOpenChamberIgnored(directory);
|
||||
}
|
||||
|
||||
export async function createGitCommit(
|
||||
directory: string,
|
||||
message: string,
|
||||
options: import('./api/types').CreateGitCommitOptions = {}
|
||||
): Promise<import('./api/types').GitCommitResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.createGitCommit(directory, message, options);
|
||||
return gitHttp.createGitCommit(directory, message, options);
|
||||
}
|
||||
|
||||
export async function gitPush(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
|
||||
): Promise<import('./api/types').GitPushResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.gitPush(directory, options);
|
||||
return gitHttp.gitPush(directory, options);
|
||||
}
|
||||
|
||||
export async function gitPull(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string } = {}
|
||||
): Promise<import('./api/types').GitPullResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.gitPull(directory, options);
|
||||
return gitHttp.gitPull(directory, options);
|
||||
}
|
||||
|
||||
export async function gitFetch(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string } = {}
|
||||
): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.gitFetch(directory, options);
|
||||
return gitHttp.gitFetch(directory, options);
|
||||
}
|
||||
|
||||
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.checkoutBranch(directory, branch);
|
||||
return gitHttp.checkoutBranch(directory, branch);
|
||||
}
|
||||
|
||||
export async function createBranch(
|
||||
directory: string,
|
||||
name: string,
|
||||
startPoint?: string
|
||||
): Promise<{ success: boolean; branch: string }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.createBranch(directory, name, startPoint);
|
||||
return gitHttp.createBranch(directory, name, startPoint);
|
||||
}
|
||||
|
||||
export async function getGitLog(
|
||||
directory: string,
|
||||
options: import('./api/types').GitLogOptions = {}
|
||||
): Promise<import('./api/types').GitLogResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitLog(directory, options);
|
||||
return gitHttp.getGitLog(directory, options);
|
||||
}
|
||||
|
||||
export async function getCommitFiles(
|
||||
directory: string,
|
||||
hash: string
|
||||
): Promise<import('./api/types').GitCommitFilesResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getCommitFiles(directory, hash);
|
||||
return gitHttp.getCommitFiles(directory, hash);
|
||||
}
|
||||
|
||||
export async function getGitIdentities(): Promise<import('./api/types').GitIdentityProfile[]> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitIdentities();
|
||||
return gitHttp.getGitIdentities();
|
||||
}
|
||||
|
||||
export async function createGitIdentity(profile: import('./api/types').GitIdentityProfile): Promise<import('./api/types').GitIdentityProfile> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.createGitIdentity(profile);
|
||||
return gitHttp.createGitIdentity(profile);
|
||||
}
|
||||
|
||||
export async function updateGitIdentity(id: string, updates: import('./api/types').GitIdentityProfile): Promise<import('./api/types').GitIdentityProfile> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.updateGitIdentity(id, updates);
|
||||
return gitHttp.updateGitIdentity(id, updates);
|
||||
}
|
||||
|
||||
export async function deleteGitIdentity(id: string): Promise<void> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.deleteGitIdentity(id);
|
||||
return gitHttp.deleteGitIdentity(id);
|
||||
}
|
||||
|
||||
export async function getCurrentGitIdentity(directory: string): Promise<import('./api/types').GitIdentitySummary | null> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getCurrentGitIdentity(directory);
|
||||
return gitHttp.getCurrentGitIdentity(directory);
|
||||
}
|
||||
|
||||
export async function setGitIdentity(
|
||||
directory: string,
|
||||
profileId: string
|
||||
): Promise<{ success: boolean; profile: import('./api/types').GitIdentityProfile }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.setGitIdentity(directory, profileId);
|
||||
return gitHttp.setGitIdentity(directory, profileId);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
|
||||
|
||||
import type {
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
GetGitDiffOptions,
|
||||
GitFileDiffResponse,
|
||||
GetGitFileDiffOptions,
|
||||
GitBranch,
|
||||
GitDeleteBranchPayload,
|
||||
GitDeleteRemoteBranchPayload,
|
||||
GeneratedCommitMessage,
|
||||
GitWorktreeInfo,
|
||||
GitAddWorktreePayload,
|
||||
GitRemoveWorktreePayload,
|
||||
CreateGitCommitOptions,
|
||||
GitCommitResult,
|
||||
GitPushResult,
|
||||
GitPullResult,
|
||||
GitLogOptions,
|
||||
GitLogResponse,
|
||||
GitCommitFilesResponse,
|
||||
GitIdentityProfile,
|
||||
GitIdentitySummary,
|
||||
} from './api/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_DESKTOP_SERVER__?: {
|
||||
origin: string;
|
||||
opencodePort: number | null;
|
||||
apiPrefix: string;
|
||||
cliAvailable: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resolveBaseOrigin = (): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const desktopOrigin = window.__OPENCHAMBER_DESKTOP_SERVER__?.origin;
|
||||
if (desktopOrigin) {
|
||||
return desktopOrigin;
|
||||
}
|
||||
return window.location.origin;
|
||||
};
|
||||
|
||||
const API_BASE = '/api/git';
|
||||
|
||||
function buildUrl(
|
||||
path: string,
|
||||
directory: string | null | undefined,
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
): string {
|
||||
const url = new URL(path, resolveBaseOrigin());
|
||||
if (directory) {
|
||||
url.searchParams.set('directory', directory);
|
||||
}
|
||||
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/check`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to check git repository: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.isGitRepository;
|
||||
}
|
||||
|
||||
export async function getGitStatus(directory: string): Promise<GitStatus> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/status`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git status: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
|
||||
const { path, staged, contextLines } = options;
|
||||
if (!path) {
|
||||
throw new Error('path is required to fetch git diff');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildUrl(`${API_BASE}/diff`, directory, {
|
||||
path,
|
||||
staged: staged ? 'true' : undefined,
|
||||
context: contextLines,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git diff: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
|
||||
const { path, staged } = options;
|
||||
if (!path) {
|
||||
throw new Error('path is required to fetch git file diff');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildUrl(`${API_BASE}/file-diff`, directory, {
|
||||
path,
|
||||
staged: staged ? 'true' : undefined,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git file diff: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
|
||||
if (!filePath) {
|
||||
throw new Error('path is required to revert git changes');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/revert`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response
|
||||
.json()
|
||||
.catch(() => ({ error: response.statusText }));
|
||||
throw new Error(message.error || 'Failed to revert git changes');
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktree-type`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to detect worktree type: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return Boolean(data.linked);
|
||||
}
|
||||
|
||||
export async function getGitBranches(directory: string): Promise<GitBranch> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get branches: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
||||
if (!payload?.branch) {
|
||||
throw new Error('branch is required to delete a branch');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to delete branch');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
||||
if (!payload?.branch) {
|
||||
throw new Error('branch is required to delete remote branch');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/remote-branches`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to delete remote branch');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function generateCommitMessage(
|
||||
directory: string,
|
||||
files: string[]
|
||||
): Promise<{ message: GeneratedCommitMessage }> {
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
throw new Error('No files provided to generate commit message');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ files }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to generate commit message');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data?.message || typeof data.message !== 'object') {
|
||||
throw new Error('Malformed commit generation response');
|
||||
}
|
||||
|
||||
const subject =
|
||||
typeof data.message.subject === 'string' && data.message.subject.trim().length > 0
|
||||
? data.message.subject.trim()
|
||||
: '';
|
||||
|
||||
const highlights: string[] = Array.isArray(data.message.highlights)
|
||||
? (data.message.highlights as unknown[])
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => (item as string).trim())
|
||||
: [];
|
||||
|
||||
return {
|
||||
message: {
|
||||
subject,
|
||||
highlights,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory));
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list worktrees');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
|
||||
if (!payload?.path || !payload?.branch) {
|
||||
throw new Error('path and branch are required to add a worktree');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to add worktree');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
|
||||
if (!payload?.path) {
|
||||
throw new Error('path is required to remove a worktree');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to remove worktree');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/ignore-openchamber`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to update git ignore');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGitCommit(
|
||||
directory: string,
|
||||
message: string,
|
||||
options: CreateGitCommitOptions = {}
|
||||
): Promise<GitCommitResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/commit`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
addAll: options.addAll ?? false,
|
||||
files: options.files,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create commit');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function gitPush(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
|
||||
): Promise<GitPushResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/push`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to push');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function gitPull(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string } = {}
|
||||
): Promise<GitPullResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/pull`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to pull');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function gitFetch(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string } = {}
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/fetch`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to fetch');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/checkout`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ branch }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to checkout branch');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createBranch(
|
||||
directory: string,
|
||||
name: string,
|
||||
startPoint?: string
|
||||
): Promise<{ success: boolean; branch: string }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/branches`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, startPoint }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create branch');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitLog(
|
||||
directory: string,
|
||||
options: GitLogOptions = {}
|
||||
): Promise<GitLogResponse> {
|
||||
const response = await fetch(
|
||||
buildUrl(`${API_BASE}/log`, directory, {
|
||||
maxCount: options.maxCount,
|
||||
from: options.from,
|
||||
to: options.to,
|
||||
file: options.file,
|
||||
})
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git log: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getCommitFiles(
|
||||
directory: string,
|
||||
hash: string
|
||||
): Promise<GitCommitFilesResponse> {
|
||||
const response = await fetch(
|
||||
buildUrl(`${API_BASE}/commit-files`, directory, { hash })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get commit files: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git identities: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profile),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create git identity');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to update git identity');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteGitIdentity(id: string): Promise<void> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to delete git identity');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
|
||||
if (!directory) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(buildUrl(`${API_BASE}/current-identity`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get current git identity: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userName: data.userName ?? null,
|
||||
userEmail: data.userEmail ?? null,
|
||||
sshCommand: data.sshCommand ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setGitIdentity(
|
||||
directory: string,
|
||||
profileId: string
|
||||
): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/set-identity`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to set git identity');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Part } from "@opencode-ai/sdk";
|
||||
import { isFullySyntheticMessage } from "@/lib/messages/synthetic";
|
||||
|
||||
export interface MessageInfo {
|
||||
id: string;
|
||||
role: string;
|
||||
time?: {
|
||||
created?: number;
|
||||
completed?: number;
|
||||
};
|
||||
status?: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
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 = parts.some(part =>
|
||||
part.type === 'step-finish' && (part as any).reason === '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);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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 openDatabase();
|
||||
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'));
|
||||
});
|
||||
db.close();
|
||||
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 openDatabase();
|
||||
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'));
|
||||
});
|
||||
db.close();
|
||||
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 openDatabase();
|
||||
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'));
|
||||
});
|
||||
db.close();
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
|
||||
const fallback = readFallback();
|
||||
if (sessionId in fallback) {
|
||||
delete fallback[sessionId];
|
||||
writeFallback(fallback);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Message } from '@opencode-ai/sdk';
|
||||
|
||||
export class MessageFreshnessDetector {
|
||||
private static instance: MessageFreshnessDetector;
|
||||
private sessionStartTimes: Map<string, number> = new Map();
|
||||
private seenMessageIds: Set<string> = new Set();
|
||||
private messageCreationTimes: Map<string, number> = new Map();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MessageFreshnessDetector {
|
||||
if (!MessageFreshnessDetector.instance) {
|
||||
MessageFreshnessDetector.instance = new MessageFreshnessDetector();
|
||||
}
|
||||
return MessageFreshnessDetector.instance;
|
||||
}
|
||||
|
||||
recordSessionStart(sessionId: string): void {
|
||||
this.sessionStartTimes.set(sessionId, Date.now());
|
||||
}
|
||||
|
||||
getSessionStartTime(sessionId: string): number | undefined {
|
||||
return this.sessionStartTimes.get(sessionId);
|
||||
}
|
||||
|
||||
shouldAnimateMessage(message: Message, sessionId: string): boolean {
|
||||
|
||||
if (message.role !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.seenMessageIds.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionStartTime = this.sessionStartTimes.get(sessionId);
|
||||
|
||||
if (!sessionStartTime) {
|
||||
|
||||
this.seenMessageIds.add(message.id);
|
||||
this.messageCreationTimes.set(message.id, message.time.created);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isFresh = message.time.created > (sessionStartTime - 5000);
|
||||
|
||||
if (!isFresh) {
|
||||
this.seenMessageIds.add(message.id);
|
||||
this.messageCreationTimes.set(message.id, message.time.created);
|
||||
}
|
||||
|
||||
return isFresh;
|
||||
}
|
||||
|
||||
clearSession(sessionId: string): void {
|
||||
this.sessionStartTimes.delete(sessionId);
|
||||
|
||||
}
|
||||
|
||||
hasSessionTiming(sessionId: string): boolean {
|
||||
return this.sessionStartTimes.has(sessionId);
|
||||
}
|
||||
|
||||
hasBeenAnimated(messageId: string): boolean {
|
||||
return this.seenMessageIds.has(messageId);
|
||||
}
|
||||
|
||||
markMessageAsAnimated(messageId: string, createdTime: number): void {
|
||||
this.seenMessageIds.add(messageId);
|
||||
this.messageCreationTimes.set(messageId, createdTime);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.sessionStartTimes.clear();
|
||||
this.seenMessageIds.clear();
|
||||
this.messageCreationTimes.clear();
|
||||
}
|
||||
|
||||
getDebugInfo(): {
|
||||
sessionStartTimes: Map<string, number>;
|
||||
seenMessageIds: Set<string>;
|
||||
messageCreationTimes: Map<string, number>;
|
||||
} {
|
||||
return {
|
||||
sessionStartTimes: new Map(this.sessionStartTimes),
|
||||
seenMessageIds: new Set(this.seenMessageIds),
|
||||
messageCreationTimes: new Map(this.messageCreationTimes)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Agent } from "@opencode-ai/sdk";
|
||||
|
||||
export interface AgentMentionSource {
|
||||
value: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ParsedAgentMention {
|
||||
name: string;
|
||||
source?: AgentMentionSource;
|
||||
}
|
||||
|
||||
export interface ParsedAgentResult {
|
||||
sanitizedText: string;
|
||||
mention: ParsedAgentMention | null;
|
||||
}
|
||||
|
||||
const isWordBoundaryChar = (char: string | null): boolean => {
|
||||
if (!char) {
|
||||
return true;
|
||||
}
|
||||
return /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(char);
|
||||
};
|
||||
|
||||
export const parseAgentMentions = (rawText: string, agents: Agent[]): ParsedAgentResult => {
|
||||
if (typeof rawText !== "string" || rawText.length === 0) {
|
||||
return { sanitizedText: rawText, mention: null };
|
||||
}
|
||||
|
||||
const nonPrimaryAgents = agents.filter((agent) => agent.mode && agent.mode !== "primary");
|
||||
if (nonPrimaryAgents.length === 0 || !rawText.includes("#")) {
|
||||
return { sanitizedText: rawText, mention: null };
|
||||
}
|
||||
|
||||
let firstMention: ParsedAgentMention | null = null;
|
||||
|
||||
for (const agent of nonPrimaryAgents) {
|
||||
const pattern = new RegExp(`#${agent.name}\\b`, "gi");
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(rawText)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
const charBefore = start > 0 ? rawText[start - 1] : null;
|
||||
|
||||
if (!isWordBoundaryChar(charBefore)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mention: ParsedAgentMention = {
|
||||
name: agent.name,
|
||||
source: {
|
||||
value: match[0],
|
||||
start,
|
||||
end,
|
||||
},
|
||||
};
|
||||
|
||||
if (!firstMention) {
|
||||
firstMention = mention;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstMention) {
|
||||
return { sanitizedText: rawText, mention: null };
|
||||
}
|
||||
|
||||
return {
|
||||
sanitizedText: rawText,
|
||||
mention: firstMention,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Part } from "@opencode-ai/sdk";
|
||||
|
||||
const isSyntheticPart = (part: Part | undefined): boolean => {
|
||||
if (!part || typeof part !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Boolean((part as { synthetic?: boolean }).synthetic);
|
||||
};
|
||||
|
||||
export const isFullySyntheticMessage = (parts: Part[] | undefined): boolean => {
|
||||
if (!Array.isArray(parts) || parts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parts.every((part) => isSyntheticPart(part));
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
|
||||
export interface EditModeColors {
|
||||
text: string;
|
||||
border?: string;
|
||||
background?: string;
|
||||
borderWidth?: number;
|
||||
}
|
||||
|
||||
export const getEditModeColors = (mode?: EditPermissionMode | null): EditModeColors | null => {
|
||||
if (mode === 'full') {
|
||||
return {
|
||||
text: 'var(--status-info)',
|
||||
border: 'var(--status-info-border)',
|
||||
background: 'var(--status-info-background)',
|
||||
borderWidth: 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'allow') {
|
||||
return {
|
||||
text: 'var(--status-success)',
|
||||
border: 'var(--status-success-border)',
|
||||
background: 'var(--status-success-background)',
|
||||
borderWidth: 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.themeId) {
|
||||
localStorage.setItem('selectedThemeId', settings.themeId);
|
||||
}
|
||||
if (settings.themeVariant) {
|
||||
localStorage.setItem('selectedThemeVariant', settings.themeVariant);
|
||||
}
|
||||
if (settings.lightThemeId) {
|
||||
localStorage.setItem('lightThemeId', settings.lightThemeId);
|
||||
}
|
||||
if (settings.darkThemeId) {
|
||||
localStorage.setItem('darkThemeId', settings.darkThemeId);
|
||||
}
|
||||
if (typeof settings.useSystemTheme === 'boolean') {
|
||||
localStorage.setItem('useSystemTheme', String(settings.useSystemTheme));
|
||||
}
|
||||
if (settings.lastDirectory) {
|
||||
localStorage.setItem('lastDirectory', settings.lastDirectory);
|
||||
}
|
||||
if (settings.homeDirectory) {
|
||||
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
||||
window.__OPENCHAMBER_HOME__ = settings.homeDirectory;
|
||||
}
|
||||
if (Array.isArray(settings.pinnedDirectories) && settings.pinnedDirectories.length > 0) {
|
||||
localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories));
|
||||
} else {
|
||||
localStorage.removeItem('pinnedDirectories');
|
||||
}
|
||||
};
|
||||
|
||||
type PersistApi = {
|
||||
hasHydrated?: () => boolean;
|
||||
onFinishHydration?: (callback: () => void) => (() => void) | void;
|
||||
};
|
||||
|
||||
const getPersistApi = (): PersistApi | undefined => {
|
||||
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
|
||||
if (candidate && typeof candidate === 'object') {
|
||||
return candidate;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null;
|
||||
|
||||
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
const store = useUIStore.getState();
|
||||
|
||||
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
||||
store.setShowReasoningTraces(settings.showReasoningTraces);
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = payload as Record<string, unknown>;
|
||||
const result: DesktopSettings = {};
|
||||
|
||||
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
|
||||
result.themeId = candidate.themeId;
|
||||
}
|
||||
if (candidate.useSystemTheme === true || candidate.useSystemTheme === false) {
|
||||
result.useSystemTheme = candidate.useSystemTheme;
|
||||
}
|
||||
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
|
||||
result.themeVariant = candidate.themeVariant;
|
||||
}
|
||||
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
|
||||
result.lightThemeId = candidate.lightThemeId;
|
||||
}
|
||||
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
|
||||
result.darkThemeId = candidate.darkThemeId;
|
||||
}
|
||||
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
|
||||
result.lastDirectory = candidate.lastDirectory;
|
||||
}
|
||||
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
||||
result.homeDirectory = candidate.homeDirectory;
|
||||
}
|
||||
if (Array.isArray(candidate.approvedDirectories)) {
|
||||
result.approvedDirectories = candidate.approvedDirectories.filter(
|
||||
(entry): entry is string => typeof entry === 'string' && entry.length > 0
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.securityScopedBookmarks)) {
|
||||
result.securityScopedBookmarks = candidate.securityScopedBookmarks.filter(
|
||||
(entry): entry is string => typeof entry === 'string' && entry.length > 0
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.pinnedDirectories)) {
|
||||
result.pinnedDirectories = Array.from(
|
||||
new Set(
|
||||
candidate.pinnedDirectories.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const fetchWebSettings = async (): Promise<DesktopSettings | null> => {
|
||||
const runtimeSettings = getRuntimeSettingsAPI();
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
const result = await runtimeSettings.load();
|
||||
return sanitizeWebSettings(result.settings);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load shared settings from runtime settings API:', error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
return sanitizeWebSettings(data);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load shared settings from server:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const syncDesktopSettings = async (): Promise<void> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const persistApi = getPersistApi();
|
||||
|
||||
const applySettings = (settings: DesktopSettings) => {
|
||||
persistToLocalStorage(settings);
|
||||
const apply = () => applyDesktopUiPreferences(settings);
|
||||
|
||||
if (persistApi?.hasHydrated?.()) {
|
||||
apply();
|
||||
} else {
|
||||
apply();
|
||||
if (persistApi?.onFinishHydration) {
|
||||
const unsubscribe = persistApi.onFinishHydration(() => {
|
||||
unsubscribe?.();
|
||||
apply();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const settings = isDesktopRuntime() ? await getDesktopSettings() : await fetchWebSettings();
|
||||
if (settings) {
|
||||
applySettings(settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to synchronise settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDesktopSettings = async (changes: Partial<DesktopSettings>): Promise<void> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDesktopRuntime()) {
|
||||
try {
|
||||
const updated = await updateDesktopSettingsApi(changes);
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update desktop settings:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSettings = getRuntimeSettingsAPI();
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
const updated = await runtimeSettings.save(changes);
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Failed to update settings via runtime settings API:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update shared settings via API:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeAppearancePreferences = async (): Promise<void> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const persistApi = getPersistApi();
|
||||
|
||||
try {
|
||||
const appearance = await loadAppearancePreferences();
|
||||
if (!appearance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyAppearance = () => applyAppearancePreferences(appearance);
|
||||
|
||||
if (persistApi?.hasHydrated?.()) {
|
||||
applyAppearance();
|
||||
return;
|
||||
}
|
||||
|
||||
applyAppearance();
|
||||
if (persistApi?.onFinishHydration) {
|
||||
const unsubscribe = persistApi.onFinishHydration(() => {
|
||||
unsubscribe?.();
|
||||
applyAppearance();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load appearance preferences:', error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Session } from '@opencode-ai/sdk';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
export type SessionDeleteRequest = {
|
||||
sessions: Session[];
|
||||
dateLabel?: string;
|
||||
mode?: 'session' | 'worktree';
|
||||
worktree?: WorktreeMetadata | null;
|
||||
};
|
||||
|
||||
export type SessionCreateRequest = {
|
||||
worktreeMode?: 'main' | 'create' | 'reuse';
|
||||
parentID?: string | null;
|
||||
};
|
||||
|
||||
type DeleteListener = (request: SessionDeleteRequest) => void;
|
||||
type CreateListener = (request: SessionCreateRequest) => void;
|
||||
type DirectoryListener = () => void;
|
||||
|
||||
const deleteListeners = new Set<DeleteListener>();
|
||||
const createListeners = new Set<CreateListener>();
|
||||
const directoryListeners = new Set<DirectoryListener>();
|
||||
|
||||
export const sessionEvents = {
|
||||
onDeleteRequest(listener: DeleteListener) {
|
||||
deleteListeners.add(listener);
|
||||
return () => {
|
||||
deleteListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
requestDelete(payload: SessionDeleteRequest) {
|
||||
if (!payload.sessions.length) {
|
||||
return;
|
||||
}
|
||||
deleteListeners.forEach((listener) => listener(payload));
|
||||
},
|
||||
onCreateRequest(listener: CreateListener) {
|
||||
createListeners.add(listener);
|
||||
return () => {
|
||||
createListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
requestCreate(payload?: SessionCreateRequest) {
|
||||
const request = payload ?? {};
|
||||
createListeners.forEach((listener) => listener(request));
|
||||
},
|
||||
onDirectoryRequest(listener: DirectoryListener) {
|
||||
directoryListeners.add(listener);
|
||||
return () => {
|
||||
directoryListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
requestDirectoryDialog() {
|
||||
directoryListeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TerminalStreamEvent {
|
||||
type: 'connected' | 'data' | 'exit' | 'reconnecting';
|
||||
data?: string;
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface CreateTerminalOptions {
|
||||
cwd: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface ConnectStreamOptions {
|
||||
maxRetries?: number;
|
||||
initialRetryDelay?: number;
|
||||
maxRetryDelay?: number;
|
||||
connectionTimeout?: number;
|
||||
}
|
||||
|
||||
export async function createTerminalSession(
|
||||
options: CreateTerminalOptions
|
||||
): Promise<TerminalSession> {
|
||||
const response = await fetch('/api/terminal/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
cwd: options.cwd,
|
||||
cols: options.cols || 80,
|
||||
rows: options.rows || 24,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to create terminal' }));
|
||||
throw new Error(error.error || 'Failed to create terminal session');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function connectTerminalStream(
|
||||
sessionId: string,
|
||||
onEvent: (event: TerminalStreamEvent) => void,
|
||||
onError?: (error: Error, fatal?: boolean) => void,
|
||||
options: ConnectStreamOptions = {}
|
||||
): () => void {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialRetryDelay = 1000,
|
||||
maxRetryDelay = 8000,
|
||||
connectionTimeout = 10000,
|
||||
} = options;
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let retryCount = 0;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let isClosed = false;
|
||||
let hasDispatchedOpen = false;
|
||||
let terminalExited = false;
|
||||
|
||||
const clearTimeouts = () => {
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
retryTimeout = null;
|
||||
}
|
||||
if (connectionTimeoutId) {
|
||||
clearTimeout(connectionTimeoutId);
|
||||
connectionTimeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
isClosed = true;
|
||||
clearTimeouts();
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (isClosed || terminalExited) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||
console.warn('Attempted to create duplicate EventSource, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
hasDispatchedOpen = false;
|
||||
eventSource = new EventSource(`/api/terminal/${sessionId}/stream`);
|
||||
|
||||
connectionTimeoutId = setTimeout(() => {
|
||||
if (!hasDispatchedOpen && eventSource?.readyState !== EventSource.OPEN) {
|
||||
console.error('Terminal connection timeout');
|
||||
eventSource?.close();
|
||||
handleError(new Error('Connection timeout'), false);
|
||||
}
|
||||
}, connectionTimeout);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
if (hasDispatchedOpen) {
|
||||
return;
|
||||
}
|
||||
hasDispatchedOpen = true;
|
||||
retryCount = 0;
|
||||
clearTimeouts();
|
||||
|
||||
onEvent({ type: 'connected' });
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as TerminalStreamEvent;
|
||||
|
||||
if (data.type === 'exit') {
|
||||
terminalExited = true;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
onEvent(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse terminal event:', error);
|
||||
onError?.(error as Error, false);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Terminal stream error:', error, 'readyState:', eventSource?.readyState);
|
||||
clearTimeouts();
|
||||
|
||||
const isFatalError = terminalExited || eventSource?.readyState === EventSource.CLOSED;
|
||||
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
|
||||
if (!terminalExited) {
|
||||
handleError(new Error('Terminal stream connection error'), isFatalError);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleError = (error: Error, isFatal: boolean) => {
|
||||
if (isClosed || terminalExited) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryCount < maxRetries && !isFatal) {
|
||||
retryCount++;
|
||||
const delay = Math.min(initialRetryDelay * Math.pow(2, retryCount - 1), maxRetryDelay);
|
||||
|
||||
console.log(`Reconnecting to terminal stream (attempt ${retryCount}/${maxRetries}) in ${delay}ms`);
|
||||
|
||||
onEvent({
|
||||
type: 'reconnecting',
|
||||
attempt: retryCount,
|
||||
maxAttempts: maxRetries,
|
||||
});
|
||||
|
||||
retryTimeout = setTimeout(() => {
|
||||
if (!isClosed && !terminalExited) {
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
|
||||
console.error(`Terminal connection failed after ${retryCount} attempts`);
|
||||
onError?.(error, true);
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
export async function sendTerminalInput(
|
||||
sessionId: string,
|
||||
data: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(`/api/terminal/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to send input' }));
|
||||
throw new Error(error.error || 'Failed to send terminal input');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resizeTerminal(
|
||||
sessionId: string,
|
||||
cols: number,
|
||||
rows: number
|
||||
): Promise<void> {
|
||||
const response = await fetch(`/api/terminal/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to resize terminal' }));
|
||||
throw new Error(error.error || 'Failed to resize terminal');
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTerminal(sessionId: string): Promise<void> {
|
||||
const response = await fetch(`/api/terminal/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to close terminal' }));
|
||||
throw new Error(error.error || 'Failed to close terminal');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
|
||||
export interface TerminalTheme {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent: string;
|
||||
selectionBackground: string;
|
||||
selectionForeground?: string;
|
||||
selectionInactiveBackground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
export function convertThemeToXterm(theme: Theme): TerminalTheme {
|
||||
const { colors } = theme;
|
||||
const syntax = colors.syntax.base;
|
||||
|
||||
return {
|
||||
|
||||
background: colors.surface.background,
|
||||
foreground: syntax.foreground,
|
||||
cursor: colors.interactive.cursor,
|
||||
cursorAccent: colors.surface.background,
|
||||
|
||||
selectionBackground: colors.interactive.selection,
|
||||
selectionForeground: colors.interactive.selectionForeground,
|
||||
selectionInactiveBackground: colors.interactive.selection + '50',
|
||||
|
||||
black: colors.surface.muted,
|
||||
red: colors.status.error,
|
||||
green: colors.status.success,
|
||||
yellow: colors.status.warning,
|
||||
blue: syntax.function,
|
||||
magenta: syntax.keyword,
|
||||
cyan: syntax.type,
|
||||
white: syntax.foreground,
|
||||
|
||||
brightBlack: syntax.comment,
|
||||
brightRed: colors.status.error,
|
||||
brightGreen: colors.status.success,
|
||||
brightYellow: colors.status.warning,
|
||||
brightBlue: syntax.function,
|
||||
brightMagenta: syntax.keyword,
|
||||
brightCyan: syntax.type,
|
||||
brightWhite: colors.surface.elevatedForeground,
|
||||
};
|
||||
}
|
||||
|
||||
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: true,
|
||||
cursorStyle: 'block' as const,
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
scrollback: 10000,
|
||||
minimumContrastRatio: 1,
|
||||
fastScrollModifier: 'shift' as const,
|
||||
fastScrollSensitivity: 5,
|
||||
scrollSensitivity: 3,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography';
|
||||
|
||||
const hexToRgb = (value: string | undefined | null): string | null => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
let hex = normalized.slice(1);
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('');
|
||||
}
|
||||
if (hex.length === 8) {
|
||||
hex = hex.slice(0, 6);
|
||||
}
|
||||
if (hex.length !== 6) {
|
||||
return null;
|
||||
}
|
||||
const int = Number.parseInt(hex, 16);
|
||||
if (Number.isNaN(int)) {
|
||||
return null;
|
||||
}
|
||||
const r = (int >> 16) & 255;
|
||||
const g = (int >> 8) & 255;
|
||||
const b = int & 255;
|
||||
return `${r} ${g} ${b}`;
|
||||
};
|
||||
|
||||
export class CSSVariableGenerator {
|
||||
private inheritanceMap: Map<string, string> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.initializeInheritanceMap();
|
||||
}
|
||||
|
||||
generate(theme: Theme): string {
|
||||
const cssVars: string[] = [];
|
||||
|
||||
cssVars.push(...this.generateTailwindVariables(theme));
|
||||
|
||||
cssVars.push(...this.generatePrimaryColors(theme.colors.primary));
|
||||
cssVars.push(...this.generateSurfaceColors(theme.colors.surface));
|
||||
cssVars.push(...this.generateInteractiveColors(theme.colors.interactive));
|
||||
cssVars.push(...this.generateStatusColors(theme.colors.status));
|
||||
|
||||
cssVars.push(...this.generateSyntaxColors(theme.colors.syntax));
|
||||
|
||||
cssVars.push(...this.generateComponentColors(theme.colors, theme));
|
||||
|
||||
cssVars.push(...this.generateTypographyVariables());
|
||||
|
||||
if (theme.config) {
|
||||
cssVars.push(...this.generateConfigVariables(theme.config));
|
||||
}
|
||||
|
||||
return cssVars.join('\n');
|
||||
}
|
||||
|
||||
private generateTailwindVariables(theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
vars.push(` --background: ${theme.colors.surface.background} !important;`);
|
||||
vars.push(` --foreground: ${theme.colors.surface.foreground} !important;`);
|
||||
|
||||
vars.push(` --muted: ${theme.colors.surface.muted} !important;`);
|
||||
vars.push(` --muted-foreground: ${theme.colors.surface.mutedForeground} !important;`);
|
||||
|
||||
vars.push(` --card: ${theme.colors.surface.elevated} !important;`);
|
||||
vars.push(` --card-foreground: ${theme.colors.surface.elevatedForeground} !important;`);
|
||||
|
||||
vars.push(` --popover: ${theme.colors.surface.elevated} !important;`);
|
||||
vars.push(` --popover-foreground: ${theme.colors.surface.elevatedForeground} !important;`);
|
||||
|
||||
vars.push(` --border: ${theme.colors.interactive.border} !important;`);
|
||||
vars.push(` --input: ${theme.colors.interactive.border} !important;`);
|
||||
|
||||
vars.push(` --primary: ${theme.colors.primary.base} !important;`);
|
||||
vars.push(` --primary-foreground: ${theme.colors.primary.foreground} !important;`);
|
||||
|
||||
vars.push(` --secondary: ${theme.colors.surface.muted} !important;`);
|
||||
vars.push(` --secondary-foreground: ${theme.colors.surface.mutedForeground} !important;`);
|
||||
|
||||
vars.push(` --accent: ${theme.colors.surface.subtle} !important;`);
|
||||
vars.push(` --accent-foreground: ${theme.colors.surface.foreground} !important;`);
|
||||
|
||||
vars.push(` --destructive: ${theme.colors.status.error} !important;`);
|
||||
vars.push(` --destructive-foreground: ${theme.colors.status.errorForeground} !important;`);
|
||||
|
||||
vars.push(` --ring: ${theme.colors.interactive.focusRing} !important;`);
|
||||
|
||||
if (theme.config?.radius?.md) {
|
||||
vars.push(` --radius: ${theme.config.radius.md} !important;`);
|
||||
}
|
||||
|
||||
const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted);
|
||||
const sidebarAccentRgb = hexToRgb(theme.colors.surface.subtle);
|
||||
const sidebarBorderRgb = hexToRgb(theme.colors.interactive.border);
|
||||
|
||||
vars.push(` --sidebar-base: ${theme.colors.surface.muted} !important;`);
|
||||
if (sidebarBaseRgb) {
|
||||
vars.push(` --sidebar-base-rgb: ${sidebarBaseRgb} !important;`);
|
||||
}
|
||||
vars.push(` --sidebar: var(--sidebar-base) !important;`);
|
||||
vars.push(` --sidebar-foreground: ${theme.colors.surface.mutedForeground} !important;`);
|
||||
vars.push(` --sidebar-primary: ${theme.colors.primary.base} !important;`);
|
||||
vars.push(` --sidebar-primary-foreground: ${theme.colors.primary.foreground} !important;`);
|
||||
vars.push(` --sidebar-accent-base: ${theme.colors.surface.subtle} !important;`);
|
||||
if (sidebarAccentRgb) {
|
||||
vars.push(` --sidebar-accent-base-rgb: ${sidebarAccentRgb} !important;`);
|
||||
}
|
||||
vars.push(` --sidebar-accent: var(--sidebar-accent-base) !important;`);
|
||||
vars.push(` --sidebar-accent-foreground: ${theme.colors.surface.foreground} !important;`);
|
||||
vars.push(` --sidebar-border: ${theme.colors.interactive.border} !important;`);
|
||||
if (sidebarBorderRgb) {
|
||||
vars.push(` --sidebar-border-rgb: ${sidebarBorderRgb} !important;`);
|
||||
}
|
||||
vars.push(` --sidebar-ring: ${theme.colors.interactive.focusRing} !important;`);
|
||||
|
||||
const isDark = theme.metadata.variant === 'dark';
|
||||
const strongAlpha = isDark ? 0.8 : 0.95;
|
||||
const softAlpha = isDark ? 0.7 : 0.9;
|
||||
|
||||
if (sidebarBaseRgb) {
|
||||
vars.push(
|
||||
` --sidebar-overlay-strong: rgb(${sidebarBaseRgb} / ${strongAlpha}) !important;`,
|
||||
);
|
||||
vars.push(
|
||||
` --sidebar-overlay-soft: rgb(${sidebarBaseRgb} / ${softAlpha}) !important;`,
|
||||
);
|
||||
} else {
|
||||
const base = theme.colors.surface.muted;
|
||||
vars.push(
|
||||
` --sidebar-overlay-strong: ${this.opacity(base, strongAlpha)} !important;`,
|
||||
);
|
||||
vars.push(
|
||||
` --sidebar-overlay-soft: ${this.opacity(base, softAlpha)} !important;`,
|
||||
);
|
||||
}
|
||||
|
||||
if (theme.colors.charts?.series && Array.isArray(theme.colors.charts.series)) {
|
||||
theme.colors.charts.series.forEach((color: string, i: number) => {
|
||||
vars.push(` --chart-${i + 1}: ${color};`);
|
||||
});
|
||||
}
|
||||
|
||||
if (theme.colors.loading) {
|
||||
vars.push(` --loading-spinner: ${theme.colors.loading.spinner || theme.colors.primary.base};`);
|
||||
vars.push(` --loading-spinner-track: ${theme.colors.loading.spinnerTrack || theme.colors.surface.muted};`);
|
||||
} else {
|
||||
vars.push(` --loading-spinner: ${theme.colors.primary.base};`);
|
||||
vars.push(` --loading-spinner-track: ${theme.colors.surface.muted};`);
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
apply(theme: Theme): void {
|
||||
const cssVars = this.generate(theme);
|
||||
const style = document.createElement('style');
|
||||
style.id = 'opencode-theme-variables';
|
||||
|
||||
let styleContent = '';
|
||||
if (theme.metadata.variant === 'dark') {
|
||||
|
||||
styleContent = `:root {\n${cssVars}\n}\n\n.dark {\n${cssVars}\n}`;
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.classList.remove('light');
|
||||
} else {
|
||||
|
||||
styleContent = `:root {\n${cssVars}\n}\n\n:root:not(.dark) {\n${cssVars}\n}`;
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
|
||||
style.textContent = styleContent;
|
||||
|
||||
const existing = document.getElementById('opencode-theme-variables');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
document.head.appendChild(style);
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme.metadata.variant);
|
||||
}
|
||||
|
||||
private generatePrimaryColors(primary: Theme['colors']['primary']): string[] {
|
||||
const vars: string[] = [];
|
||||
vars.push(` --primary-base: ${primary.base};`);
|
||||
vars.push(` --primary-hover: ${primary.hover || this.darken(primary.base, 10)};`);
|
||||
vars.push(` --primary-active: ${primary.active || this.darken(primary.base, 20)};`);
|
||||
vars.push(` --primary-foreground: ${primary.foreground || '#ffffff'};`);
|
||||
vars.push(` --primary-muted: ${primary.muted || this.opacity(primary.base, 0.5)};`);
|
||||
vars.push(` --primary-emphasis: ${primary.emphasis || this.lighten(primary.base, 10)};`);
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateSurfaceColors(surface: Theme['colors']['surface']): string[] {
|
||||
const vars: string[] = [];
|
||||
vars.push(` --surface-background: ${surface.background};`);
|
||||
vars.push(` --surface-foreground: ${surface.foreground};`);
|
||||
vars.push(` --surface-muted: ${surface.muted};`);
|
||||
vars.push(` --surface-muted-foreground: ${surface.mutedForeground};`);
|
||||
vars.push(` --surface-elevated: ${surface.elevated};`);
|
||||
vars.push(` --surface-elevated-foreground: ${surface.elevatedForeground};`);
|
||||
vars.push(` --surface-overlay: ${surface.overlay};`);
|
||||
vars.push(` --surface-subtle: ${surface.subtle};`);
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateInteractiveColors(interactive: Theme['colors']['interactive']): string[] {
|
||||
const vars: string[] = [];
|
||||
vars.push(` --interactive-border: ${interactive.border};`);
|
||||
vars.push(` --interactive-border-hover: ${interactive.borderHover};`);
|
||||
vars.push(` --interactive-border-focus: ${interactive.borderFocus};`);
|
||||
vars.push(` --interactive-selection: ${interactive.selection};`);
|
||||
vars.push(` --interactive-selection-foreground: ${interactive.selectionForeground};`);
|
||||
vars.push(` --interactive-focus: ${interactive.focus};`);
|
||||
vars.push(` --interactive-focus-ring: ${interactive.focusRing};`);
|
||||
vars.push(` --interactive-cursor: ${interactive.cursor};`);
|
||||
vars.push(` --interactive-hover: ${interactive.hover};`);
|
||||
vars.push(` --interactive-active: ${interactive.active};`);
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateStatusColors(status: Theme['colors']['status']): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
vars.push(` --status-error: ${status.error};`);
|
||||
vars.push(` --status-error-foreground: ${status.errorForeground};`);
|
||||
vars.push(` --status-error-background: ${status.errorBackground};`);
|
||||
vars.push(` --status-error-border: ${status.errorBorder};`);
|
||||
|
||||
vars.push(` --status-warning: ${status.warning};`);
|
||||
vars.push(` --status-warning-foreground: ${status.warningForeground};`);
|
||||
vars.push(` --status-warning-background: ${status.warningBackground};`);
|
||||
vars.push(` --status-warning-border: ${status.warningBorder};`);
|
||||
|
||||
vars.push(` --status-success: ${status.success};`);
|
||||
vars.push(` --status-success-foreground: ${status.successForeground};`);
|
||||
vars.push(` --status-success-background: ${status.successBackground};`);
|
||||
vars.push(` --status-success-border: ${status.successBorder};`);
|
||||
|
||||
vars.push(` --status-info: ${status.info};`);
|
||||
vars.push(` --status-info-foreground: ${status.infoForeground};`);
|
||||
vars.push(` --status-info-background: ${status.infoBackground};`);
|
||||
vars.push(` --status-info-border: ${status.infoBorder};`);
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateSyntaxColors(syntax: Theme['colors']['syntax']): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
vars.push(` --syntax-background: ${syntax.base.background};`);
|
||||
vars.push(` --syntax-foreground: ${syntax.base.foreground};`);
|
||||
vars.push(` --syntax-comment: ${syntax.base.comment};`);
|
||||
vars.push(` --syntax-keyword: ${syntax.base.keyword};`);
|
||||
vars.push(` --syntax-string: ${syntax.base.string};`);
|
||||
vars.push(` --syntax-number: ${syntax.base.number};`);
|
||||
vars.push(` --syntax-function: ${syntax.base.function};`);
|
||||
vars.push(` --syntax-variable: ${syntax.base.variable};`);
|
||||
vars.push(` --syntax-type: ${syntax.base.type};`);
|
||||
vars.push(` --syntax-operator: ${syntax.base.operator};`);
|
||||
|
||||
const tokens = this.generateSyntaxTokens(syntax);
|
||||
for (const [key, value] of Object.entries(tokens)) {
|
||||
vars.push(` --syntax-${this.kebabCase(key)}: ${value};`);
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateSyntaxTokens(syntax: Theme['colors']['syntax']): Record<string, string> {
|
||||
const base = syntax.base;
|
||||
const tokens = syntax.tokens || {};
|
||||
|
||||
return {
|
||||
|
||||
commentDoc: tokens.commentDoc || this.lighten(base.comment, 10),
|
||||
|
||||
stringEscape: tokens.stringEscape || this.darken(base.string, 20),
|
||||
stringInterpolation: tokens.stringInterpolation || base.variable,
|
||||
stringRegex: tokens.stringRegex || this.adjustHue(base.string, 15),
|
||||
|
||||
keywordControl: tokens.keywordControl || base.keyword,
|
||||
keywordOperator: tokens.keywordOperator || base.operator,
|
||||
keywordImport: tokens.keywordImport || this.lighten(base.keyword, 10),
|
||||
keywordReturn: tokens.keywordReturn || this.emphasize(base.keyword),
|
||||
|
||||
functionCall: tokens.functionCall || this.lighten(base.function, 5),
|
||||
functionBuiltin: tokens.functionBuiltin || this.darken(base.function, 10),
|
||||
method: tokens.method || base.function,
|
||||
methodCall: tokens.methodCall || this.lighten(base.function, 5),
|
||||
|
||||
variableBuiltin: tokens.variableBuiltin || this.emphasize(base.variable),
|
||||
variableProperty: tokens.variableProperty || this.lighten(base.variable, 10),
|
||||
variableReadonly: tokens.variableReadonly || base.number,
|
||||
parameter: tokens.parameter || base.variable,
|
||||
|
||||
typePrimitive: tokens.typePrimitive || this.darken(base.type, 10),
|
||||
typeInterface: tokens.typeInterface || base.type,
|
||||
className: tokens.className || this.emphasize(base.type),
|
||||
enum: tokens.enum || base.type,
|
||||
|
||||
boolean: tokens.boolean || base.number,
|
||||
null: tokens.null || this.opacity(base.number, 0.7),
|
||||
constant: tokens.constant || base.number,
|
||||
|
||||
punctuation: tokens.punctuation || this.opacity(base.foreground, 0.7),
|
||||
delimiter: tokens.delimiter || this.opacity(base.foreground, 0.8),
|
||||
bracket: tokens.bracket || base.foreground,
|
||||
|
||||
tag: tokens.tag || base.keyword,
|
||||
tagAttribute: tokens.tagAttribute || base.variable,
|
||||
tagAttributeValue: tokens.tagAttributeValue || base.string,
|
||||
tagBracket: tokens.tagBracket || this.opacity(base.foreground, 0.8),
|
||||
|
||||
decorator: tokens.decorator || base.function,
|
||||
annotation: tokens.annotation || base.function,
|
||||
|
||||
namespace: tokens.namespace || this.opacity(base.type, 0.8),
|
||||
module: tokens.module || this.opacity(base.type, 0.8),
|
||||
|
||||
...tokens
|
||||
};
|
||||
}
|
||||
|
||||
private generateComponentColors(colors: Theme['colors'], theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
if (colors.markdown) {
|
||||
vars.push(...this.generateMarkdownColors(colors.markdown, theme));
|
||||
} else {
|
||||
|
||||
vars.push(...this.generateDefaultMarkdownColors(theme));
|
||||
}
|
||||
|
||||
if (colors.chat) {
|
||||
vars.push(...this.generateChatColors(colors.chat, theme));
|
||||
} else {
|
||||
vars.push(...this.generateDefaultChatColors(theme));
|
||||
}
|
||||
|
||||
if (colors.tools) {
|
||||
vars.push(...this.generateToolColors(colors.tools, theme));
|
||||
} else {
|
||||
vars.push(...this.generateDefaultToolColors(theme));
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateMarkdownColors(markdown: Record<string, string>, theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
const primary = theme.colors.primary.base;
|
||||
const chatBackground = theme.colors.chat?.background || theme.colors.surface.background;
|
||||
|
||||
vars.push(` --markdown-heading1: ${markdown.heading1 || primary};`);
|
||||
vars.push(` --markdown-heading2: ${markdown.heading2 || this.opacity(primary, 0.9)};`);
|
||||
vars.push(` --markdown-heading3: ${markdown.heading3 || this.opacity(primary, 0.8)};`);
|
||||
vars.push(` --markdown-heading4: ${markdown.heading4 || theme.colors.surface.foreground};`);
|
||||
vars.push(` --markdown-link: ${markdown.link || primary};`);
|
||||
vars.push(` --markdown-link-hover: ${markdown.linkHover || theme.colors.primary.hover || this.darken(primary, 10)};`);
|
||||
vars.push(` --markdown-inline-code: ${markdown.inlineCode || theme.colors.syntax.base.string};`);
|
||||
vars.push(` --markdown-inline-code-bg: ${markdown.inlineCodeBackground || chatBackground};`);
|
||||
vars.push(` --markdown-blockquote: ${markdown.blockquote || theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --markdown-blockquote-border: ${markdown.blockquoteBorder || theme.colors.interactive.border};`);
|
||||
vars.push(` --markdown-list-marker: ${markdown.listMarker || this.opacity(primary, 0.6)};`);
|
||||
vars.push(` --markdown-bold: ${markdown.bold || theme.colors.surface.foreground};`);
|
||||
vars.push(` --markdown-italic: ${markdown.italic || this.opacity(theme.colors.surface.foreground, 0.9)};`);
|
||||
vars.push(` --markdown-strikethrough: ${markdown.strikethrough || theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --markdown-hr: ${markdown.hr || theme.colors.interactive.border};`);
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateDefaultMarkdownColors(theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
const primary = theme.colors.primary.base;
|
||||
const chatBackground = theme.colors.chat?.background || theme.colors.surface.background;
|
||||
|
||||
vars.push(` --markdown-heading1: ${primary};`);
|
||||
vars.push(` --markdown-heading2: ${this.opacity(primary, 0.9)};`);
|
||||
vars.push(` --markdown-heading3: ${this.opacity(primary, 0.8)};`);
|
||||
vars.push(` --markdown-heading4: ${theme.colors.surface.foreground};`);
|
||||
vars.push(` --markdown-link: ${primary};`);
|
||||
vars.push(` --markdown-link-hover: ${theme.colors.primary.hover || this.darken(primary, 10)};`);
|
||||
vars.push(` --markdown-inline-code: ${theme.colors.syntax.base.string};`);
|
||||
vars.push(` --markdown-inline-code-bg: ${chatBackground};`);
|
||||
vars.push(` --markdown-blockquote: ${theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --markdown-blockquote-border: ${theme.colors.interactive.border};`);
|
||||
vars.push(` --markdown-list-marker: ${this.opacity(primary, 0.6)};`);
|
||||
vars.push(` --markdown-bold: ${theme.colors.surface.foreground};`);
|
||||
vars.push(` --markdown-italic: ${this.opacity(theme.colors.surface.foreground, 0.9)};`);
|
||||
vars.push(` --markdown-strikethrough: ${theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --markdown-hr: ${theme.colors.interactive.border};`);
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateChatColors(chat: Record<string, string>, theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
const chatBackground = chat.background || theme.colors.surface.background;
|
||||
|
||||
vars.push(` --chat-background: ${chatBackground};`);
|
||||
vars.push(` --chat-user-message: ${chat.userMessage || theme.colors.surface.foreground};`);
|
||||
vars.push(` --chat-user-message-bg: ${chat.userMessageBackground || theme.colors.surface.elevated};`);
|
||||
vars.push(` --chat-assistant-message: ${chat.assistantMessage || theme.colors.surface.foreground};`);
|
||||
vars.push(` --chat-assistant-message-bg: ${chat.assistantMessageBackground || theme.colors.surface.muted};`);
|
||||
vars.push(` --chat-timestamp: ${chat.timestamp || theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --chat-divider: ${chat.divider || theme.colors.interactive.border};`);
|
||||
vars.push(` --chat-typing: ${chat.typing || theme.colors.surface.mutedForeground};`);
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateDefaultChatColors(theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
vars.push(` --chat-background: ${theme.colors.surface.background};`);
|
||||
vars.push(` --chat-user-message: ${theme.colors.surface.foreground};`);
|
||||
vars.push(` --chat-user-message-bg: ${theme.colors.surface.elevated};`);
|
||||
vars.push(` --chat-assistant-message: ${theme.colors.surface.foreground};`);
|
||||
vars.push(` --chat-assistant-message-bg: ${theme.colors.surface.muted};`);
|
||||
vars.push(` --chat-timestamp: ${theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --chat-divider: ${theme.colors.interactive.border};`);
|
||||
vars.push(` --chat-typing: ${theme.colors.surface.mutedForeground};`);
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateToolColors(tools: Theme['colors']['tools'], theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
vars.push(` --tools-background: ${tools?.background || this.opacity(theme.colors.surface.muted, 0.2)};`);
|
||||
vars.push(` --tools-border: ${tools?.border || this.opacity(theme.colors.interactive.border, 0.3)};`);
|
||||
vars.push(` --tools-header-hover: ${tools?.headerHover || this.opacity(theme.colors.surface.muted, 0.3)};`);
|
||||
vars.push(` --tools-icon: ${tools?.icon || theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --tools-title: ${tools?.title || theme.colors.surface.foreground};`);
|
||||
vars.push(` --tools-description: ${tools?.description || this.opacity(theme.colors.surface.mutedForeground, 0.6)};`);
|
||||
|
||||
if (tools?.edit) {
|
||||
vars.push(` --tools-edit-added: ${tools.edit.added || theme.colors.status.success};`);
|
||||
vars.push(` --tools-edit-added-bg: ${tools.edit.addedBackground || theme.colors.status.successBackground};`);
|
||||
vars.push(` --tools-edit-removed: ${tools.edit.removed || theme.colors.status.error};`);
|
||||
vars.push(` --tools-edit-removed-bg: ${tools.edit.removedBackground || theme.colors.status.errorBackground};`);
|
||||
vars.push(` --tools-edit-modified: ${tools.edit.modified || theme.colors.status.info};`);
|
||||
vars.push(` --tools-edit-modified-bg: ${tools.edit.modifiedBackground || theme.colors.status.infoBackground};`);
|
||||
vars.push(` --tools-edit-line-number: ${tools.edit.lineNumber || this.opacity(theme.colors.surface.mutedForeground, 0.6)};`);
|
||||
} else {
|
||||
vars.push(` --tools-edit-added: ${theme.colors.status.success};`);
|
||||
vars.push(` --tools-edit-added-bg: ${theme.colors.status.successBackground};`);
|
||||
vars.push(` --tools-edit-removed: ${theme.colors.status.error};`);
|
||||
vars.push(` --tools-edit-removed-bg: ${theme.colors.status.errorBackground};`);
|
||||
vars.push(` --tools-edit-modified: ${theme.colors.status.info};`);
|
||||
vars.push(` --tools-edit-modified-bg: ${theme.colors.status.infoBackground};`);
|
||||
vars.push(` --tools-edit-line-number: ${this.opacity(theme.colors.surface.mutedForeground, 0.6)};`);
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateDefaultToolColors(theme: Theme): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
vars.push(` --tools-background: ${this.opacity(theme.colors.surface.muted, 0.2)};`);
|
||||
vars.push(` --tools-border: ${this.opacity(theme.colors.interactive.border, 0.3)};`);
|
||||
vars.push(` --tools-header-hover: ${this.opacity(theme.colors.surface.muted, 0.3)};`);
|
||||
vars.push(` --tools-icon: ${theme.colors.surface.mutedForeground};`);
|
||||
vars.push(` --tools-title: ${theme.colors.surface.foreground};`);
|
||||
vars.push(` --tools-description: ${this.opacity(theme.colors.surface.mutedForeground, 0.6)};`);
|
||||
|
||||
vars.push(` --tools-edit-added: ${theme.colors.status.success};`);
|
||||
vars.push(` --tools-edit-added-bg: ${this.addTransparency(this.removeTransparency(theme.colors.status.successBackground), 0.15)};`);
|
||||
vars.push(` --tools-edit-removed: ${theme.colors.status.error};`);
|
||||
vars.push(` --tools-edit-removed-bg: ${this.addTransparency(this.removeTransparency(theme.colors.status.errorBackground), 0.15)};`);
|
||||
vars.push(` --tools-edit-modified: ${theme.colors.status.info};`);
|
||||
vars.push(` --tools-edit-modified-bg: ${this.addTransparency(this.removeTransparency(theme.colors.status.infoBackground), 0.15)};`);
|
||||
vars.push(` --tools-edit-line-number: ${this.opacity(theme.colors.surface.mutedForeground, 0.6)};`);
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateConfigVariables(config: Theme['config']): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
if (!config) return vars;
|
||||
|
||||
if (config.fonts) {
|
||||
if (config.fonts.sans) {
|
||||
vars.push(` --font-sans: ${config.fonts.sans};`);
|
||||
vars.push(` --font-family-sans: ${config.fonts.sans};`);
|
||||
}
|
||||
if (config.fonts.mono) {
|
||||
vars.push(` --font-mono: ${config.fonts.mono};`);
|
||||
vars.push(` --font-family-mono: ${config.fonts.mono};`);
|
||||
}
|
||||
if (config.fonts.heading) vars.push(` --font-heading: ${config.fonts.heading};`);
|
||||
}
|
||||
|
||||
if (config.radius) {
|
||||
if (config.radius.none) vars.push(` --radius-none: ${config.radius.none};`);
|
||||
if (config.radius.sm) vars.push(` --radius-sm: ${config.radius.sm};`);
|
||||
if (config.radius.md) vars.push(` --radius-md: ${config.radius.md};`);
|
||||
if (config.radius.lg) vars.push(` --radius-lg: ${config.radius.lg};`);
|
||||
if (config.radius.xl) vars.push(` --radius-xl: ${config.radius.xl};`);
|
||||
if (config.radius.full) vars.push(` --radius-full: ${config.radius.full};`);
|
||||
}
|
||||
|
||||
if (config.transitions) {
|
||||
if (config.transitions.fast) vars.push(` --transition-fast: ${config.transitions.fast};`);
|
||||
if (config.transitions.normal) vars.push(` --transition-normal: ${config.transitions.normal};`);
|
||||
if (config.transitions.slow) vars.push(` --transition-slow: ${config.transitions.slow};`);
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private generateTypographyVariables(): string[] {
|
||||
const vars: string[] = [];
|
||||
|
||||
vars.push(' /* Semantic Typography Variables */');
|
||||
vars.push(' --ui-regular-font-weight: 400;');
|
||||
|
||||
vars.push(' /* Markdown content - all markdown elements use same size */');
|
||||
vars.push(` --text-markdown: ${SEMANTIC_TYPOGRAPHY.markdown};`);
|
||||
vars.push(' /* Code content - all code elements use same size */');
|
||||
vars.push(` --text-code: ${SEMANTIC_TYPOGRAPHY.code};`);
|
||||
vars.push(' /* UI headers - dialog titles, panel headers */');
|
||||
vars.push(` --text-ui-header: ${SEMANTIC_TYPOGRAPHY.uiHeader};`);
|
||||
vars.push(' /* UI labels - buttons, menus, navigation */');
|
||||
vars.push(` --text-ui-label: ${SEMANTIC_TYPOGRAPHY.uiLabel};`);
|
||||
vars.push(' /* Metadata - timestamps, status, helper text */');
|
||||
vars.push(` --text-meta: ${SEMANTIC_TYPOGRAPHY.meta};`);
|
||||
vars.push(' /* Micro text - badges, shortcuts, indicators */');
|
||||
vars.push(` --text-micro: ${SEMANTIC_TYPOGRAPHY.micro};`);
|
||||
|
||||
vars.push(' /* Heading line height and letter spacing */');
|
||||
vars.push(' --h1-line-height: 1.25rem;');
|
||||
vars.push(' --h2-line-height: 1.25rem;');
|
||||
vars.push(' --h3-line-height: 1.5rem;');
|
||||
vars.push(' --h4-line-height: 1.5rem;');
|
||||
vars.push(' --h5-line-height: 1.5rem;');
|
||||
vars.push(' --h6-line-height: 1.5rem;');
|
||||
vars.push(' --h1-letter-spacing: -0.025em;');
|
||||
vars.push(' --h2-letter-spacing: -0.02em;');
|
||||
vars.push(' --h3-letter-spacing: -0.015em;');
|
||||
vars.push(' --h4-letter-spacing: -0.01em;');
|
||||
vars.push(' --h5-letter-spacing: 0;');
|
||||
vars.push(' --h6-letter-spacing: 0.01em;');
|
||||
|
||||
vars.push(' /* UI element line height and letter spacing */');
|
||||
vars.push(' --ui-button-line-height: 1.375rem;');
|
||||
vars.push(' --ui-button-letter-spacing: 0.02em;');
|
||||
vars.push(' --ui-button-font-weight: 500;');
|
||||
vars.push(' --ui-label-line-height: 1rem;');
|
||||
vars.push(' --ui-label-letter-spacing: 0.03em;');
|
||||
vars.push(' --ui-label-font-weight: 500;');
|
||||
vars.push(' --ui-caption-line-height: 1rem;');
|
||||
vars.push(' --ui-caption-letter-spacing: 0.025em;');
|
||||
vars.push(' --ui-caption-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
|
||||
vars.push(' /* Markdown line height and letter spacing */');
|
||||
vars.push(' --markdown-body-line-height: 1.5rem;');
|
||||
vars.push(' --markdown-body-letter-spacing: 0;');
|
||||
vars.push(' --markdown-body-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
vars.push(' --markdown-h1-line-height: 1.25rem;');
|
||||
vars.push(' --markdown-h1-letter-spacing: -0.025em;');
|
||||
vars.push(' --markdown-h2-line-height: 1.25rem;');
|
||||
vars.push(' --markdown-h2-letter-spacing: -0.02em;');
|
||||
vars.push(' --markdown-h3-line-height: 1.5rem;');
|
||||
vars.push(' --markdown-h3-letter-spacing: -0.015em;');
|
||||
vars.push(' --markdown-h4-line-height: 1.5rem;');
|
||||
vars.push(' --markdown-h4-letter-spacing: -0.01em;');
|
||||
vars.push(' --markdown-h5-line-height: 1.5rem;');
|
||||
vars.push(' --markdown-h5-letter-spacing: 0;');
|
||||
vars.push(' --markdown-h6-line-height: 1.5rem;');
|
||||
vars.push(' --markdown-h6-letter-spacing: 0.01em;');
|
||||
vars.push(' --markdown-list-line-height: 1.375rem;');
|
||||
vars.push(' --markdown-code-block-line-height: 1rem;');
|
||||
|
||||
vars.push(' --ui-button-small-line-height: 1.25rem;');
|
||||
vars.push(' --ui-button-small-letter-spacing: 0.02em;');
|
||||
vars.push(' --ui-button-small-font-weight: 500;');
|
||||
vars.push(' --markdown-body-small-line-height: 1.375rem;');
|
||||
vars.push(' --markdown-body-small-letter-spacing: 0;');
|
||||
vars.push(' --markdown-body-small-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
|
||||
vars.push(' --ui-button-large-line-height: 1.5rem;');
|
||||
vars.push(' --ui-button-large-letter-spacing: 0.02em;');
|
||||
vars.push(' --ui-button-large-font-weight: 500;');
|
||||
vars.push(' --markdown-body-large-line-height: 1.625rem;');
|
||||
vars.push(' --markdown-body-large-letter-spacing: 0;');
|
||||
vars.push(' --markdown-body-large-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
|
||||
vars.push(' /* Code line height and letter spacing */');
|
||||
vars.push(' --code-inline-line-height: 1rem;');
|
||||
vars.push(' --code-inline-letter-spacing: 0;');
|
||||
vars.push(' --code-inline-font-weight: 400;');
|
||||
vars.push(' --code-block-line-height: 1.4rem;');
|
||||
vars.push(' --code-block-letter-spacing: 0;');
|
||||
vars.push(' --code-block-font-weight: 400;');
|
||||
vars.push(' --code-line-numbers-line-height: 1.25rem;');
|
||||
vars.push(' --code-line-numbers-letter-spacing: 0;');
|
||||
vars.push(' --code-line-numbers-font-weight: 400;');
|
||||
|
||||
vars.push(' /* Additional UI element line height and letter spacing */');
|
||||
vars.push(' --ui-badge-line-height: 1rem;');
|
||||
vars.push(' --ui-badge-letter-spacing: 0.025em;');
|
||||
vars.push(' --ui-badge-font-weight: 500;');
|
||||
vars.push(' --ui-tooltip-line-height: 1rem;');
|
||||
vars.push(' --ui-tooltip-letter-spacing: 0.025em;');
|
||||
vars.push(' --ui-tooltip-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
vars.push(' --ui-input-line-height: 1.375rem;');
|
||||
vars.push(' --ui-input-letter-spacing: 0.02em;');
|
||||
vars.push(' --ui-input-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
vars.push(' --ui-helper-text-line-height: 1rem;');
|
||||
vars.push(' --ui-helper-text-letter-spacing: 0.025em;');
|
||||
vars.push(' --ui-helper-text-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
|
||||
vars.push(' /* Additional markdown line height and letter spacing */');
|
||||
vars.push(' --markdown-blockquote-line-height: 1.5rem;');
|
||||
vars.push(' --markdown-blockquote-letter-spacing: 0;');
|
||||
vars.push(' --markdown-blockquote-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
vars.push(' --markdown-list-letter-spacing: 0;');
|
||||
vars.push(' --markdown-list-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
vars.push(' --markdown-link-line-height: 1.5rem;');
|
||||
vars.push(' --markdown-link-letter-spacing: 0;');
|
||||
vars.push(' --markdown-link-font-weight: var(--ui-regular-font-weight, 400);');
|
||||
vars.push(' --markdown-code-line-height: 1.35;');
|
||||
vars.push(' --markdown-code-letter-spacing: 0;');
|
||||
vars.push(' --markdown-code-font-weight: 400;');
|
||||
vars.push(' --markdown-code-block-letter-spacing: 0;');
|
||||
vars.push(' --markdown-code-block-font-weight: 400;');
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private initializeInheritanceMap(): void {
|
||||
|
||||
this.inheritanceMap.set('header.background', 'surface.background');
|
||||
this.inheritanceMap.set('header.foreground', 'surface.foreground');
|
||||
this.inheritanceMap.set('header.logoTint', 'primary.base');
|
||||
this.inheritanceMap.set('header.divider', 'interactive.border');
|
||||
|
||||
this.inheritanceMap.set('sidebar.background', 'surface.muted');
|
||||
this.inheritanceMap.set('sidebar.foreground', 'surface.mutedForeground');
|
||||
this.inheritanceMap.set('sidebar.hover', 'interactive.hover');
|
||||
this.inheritanceMap.set('sidebar.active', 'primary.base');
|
||||
this.inheritanceMap.set('sidebar.activeForeground', 'primary.foreground');
|
||||
|
||||
}
|
||||
|
||||
private opacity(color: string, alpha: number): string {
|
||||
if (color.startsWith('#')) {
|
||||
return `${color}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
if (color.startsWith('rgb')) {
|
||||
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
private removeTransparency(color: string): string {
|
||||
if (color.startsWith('#')) {
|
||||
|
||||
if (color.length === 9) {
|
||||
return color.slice(0, 7);
|
||||
}
|
||||
|
||||
if (color.length === 5) {
|
||||
return color.slice(0, 4);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
if (color.startsWith('rgba')) {
|
||||
|
||||
return color.replace('rgba', 'rgb').replace(/,\s*[\d.]+\)$/, ')');
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
private addTransparency(color: string, opacity: number): string {
|
||||
if (color.startsWith('#')) {
|
||||
|
||||
const hex = color.slice(1);
|
||||
if (hex.length === 3) {
|
||||
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
} else if (hex.length === 6) {
|
||||
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
}
|
||||
if (color.startsWith('rgb')) {
|
||||
|
||||
return color.replace('rgb', 'rgba').replace(')', `, ${opacity})`);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
private darken(color: string, percent: number): string {
|
||||
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) - amt;
|
||||
const G = (num >> 8 & 0x00FF) - amt;
|
||||
const B = (num & 0x0000FF) - amt;
|
||||
return '#' + (0x1000000 + (R < 255 ? R < 0 ? 0 : R : 255) * 0x10000 +
|
||||
(G < 255 ? G < 0 ? 0 : G : 255) * 0x100 +
|
||||
(B < 255 ? B < 0 ? 0 : B : 255)).toString(16).slice(1);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
private lighten(color: string, percent: number): string {
|
||||
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) + amt;
|
||||
const G = (num >> 8 & 0x00FF) + amt;
|
||||
const B = (num & 0x0000FF) + amt;
|
||||
return '#' + (0x1000000 + (R < 255 ? R < 0 ? 0 : R : 255) * 0x10000 +
|
||||
(G < 255 ? G < 0 ? 0 : G : 255) * 0x100 +
|
||||
(B < 255 ? B < 0 ? 0 : B : 255)).toString(16).slice(1);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
private adjustHue(color: string, degrees: number): string {
|
||||
|
||||
return this.lighten(color, degrees / 10);
|
||||
}
|
||||
|
||||
private emphasize(color: string): string {
|
||||
|
||||
return this.lighten(color, 15);
|
||||
}
|
||||
|
||||
private kebabCase(str: string): string {
|
||||
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
|
||||
interface MonacoTokenRule {
|
||||
token: string;
|
||||
foreground?: string;
|
||||
fontStyle?: string;
|
||||
}
|
||||
|
||||
interface MonacoThemeData {
|
||||
base: 'vs' | 'vs-dark';
|
||||
inherit: boolean;
|
||||
rules: MonacoTokenRule[];
|
||||
colors: Record<string, string>;
|
||||
}
|
||||
|
||||
type Monaco = {
|
||||
editor: {
|
||||
defineTheme: (themeName: string, themeData: MonacoThemeData) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const MONACO_LIGHT_THEME_ID = 'openchamber-flexoki-light';
|
||||
const MONACO_DARK_THEME_ID = 'openchamber-flexoki-dark';
|
||||
|
||||
let lastRegisteredThemeId: string | null = null;
|
||||
|
||||
const getMonacoFromGlobal = (): Monaco | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const anyWindow = window as typeof window & { monaco?: Monaco };
|
||||
if (anyWindow.monaco?.editor?.defineTheme) return anyWindow.monaco;
|
||||
return null;
|
||||
};
|
||||
|
||||
const flexokiDarkTheme: MonacoThemeData = {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
|
||||
{ token: '', foreground: 'CECDC3' },
|
||||
{ token: 'source', foreground: 'CECDC3' },
|
||||
|
||||
{ token: 'comment', foreground: '878580' },
|
||||
{ token: 'comment.block', foreground: '878580' },
|
||||
{ token: 'comment.line', foreground: '878580' },
|
||||
{ token: 'comment.block.documentation', foreground: '575653' },
|
||||
|
||||
{ token: 'string', foreground: '3AA99F' },
|
||||
{ token: 'string.quoted', foreground: '3AA99F' },
|
||||
{ token: 'string.template', foreground: '3AA99F' },
|
||||
{ token: 'string.regexp', foreground: '3AA99F' },
|
||||
|
||||
{ token: 'string.escape', foreground: 'CECDC3' },
|
||||
{ token: 'constant.character.escape', foreground: 'CECDC3' },
|
||||
|
||||
{ token: 'number', foreground: '8B7EC8' },
|
||||
{ token: 'number.hex', foreground: '8B7EC8' },
|
||||
{ token: 'number.float', foreground: '8B7EC8' },
|
||||
{ token: 'constant.numeric', foreground: '8B7EC8' },
|
||||
|
||||
{ token: 'constant.language', foreground: 'D0A215' },
|
||||
{ token: 'constant.language.boolean', foreground: 'D0A215' },
|
||||
{ token: 'constant.language.null', foreground: 'D0A215' },
|
||||
|
||||
{ token: 'keyword', foreground: '4385BE' },
|
||||
{ token: 'keyword.control', foreground: '4385BE' },
|
||||
{ token: 'keyword.other', foreground: '4385BE' },
|
||||
|
||||
{ token: 'keyword.control.import', foreground: 'D14D41' },
|
||||
{ token: 'keyword.control.from', foreground: 'D14D41' },
|
||||
{ token: 'keyword.control.export', foreground: 'D14D41' },
|
||||
|
||||
{ token: 'keyword.control.exception', foreground: 'CE5D97' },
|
||||
{ token: 'keyword.control.trycatch', foreground: 'CE5D97' },
|
||||
|
||||
{ token: 'keyword.operator', foreground: 'D14D41' },
|
||||
{ token: 'operator', foreground: 'D14D41' },
|
||||
|
||||
{ token: 'storage', foreground: '4385BE' },
|
||||
{ token: 'storage.type', foreground: '4385BE' },
|
||||
{ token: 'storage.modifier', foreground: '4385BE' },
|
||||
|
||||
{ token: 'entity.name.function', foreground: 'DA702C', fontStyle: 'bold' },
|
||||
{ token: 'support.function', foreground: 'DA702C', fontStyle: 'bold' },
|
||||
{ token: 'meta.function-call', foreground: 'DA702C' },
|
||||
|
||||
{ token: 'entity.name.function.method', foreground: '879A39' },
|
||||
|
||||
{ token: 'entity.name.class', foreground: 'DA702C' },
|
||||
{ token: 'entity.name.type.class', foreground: 'DA702C' },
|
||||
{ token: 'support.class', foreground: 'DA702C' },
|
||||
|
||||
{ token: 'entity.name.type', foreground: 'D0A215' },
|
||||
{ token: 'entity.name.type.interface', foreground: 'D0A215' },
|
||||
{ token: 'support.type', foreground: 'D0A215' },
|
||||
|
||||
{ token: 'entity.name.type.struct', foreground: 'DA702C' },
|
||||
{ token: 'entity.name.type.enum', foreground: 'DA702C' },
|
||||
|
||||
{ token: 'entity.name.type.parameter', foreground: 'DA702C' },
|
||||
|
||||
{ token: 'variable', foreground: 'CECDC3' },
|
||||
{ token: 'variable.other', foreground: 'CECDC3' },
|
||||
{ token: 'variable.parameter', foreground: 'CECDC3' },
|
||||
|
||||
{ token: 'variable.other.object', foreground: '879A39' },
|
||||
{ token: 'variable.other.readwrite.alias', foreground: '879A39' },
|
||||
|
||||
{ token: 'variable.language', foreground: 'CE5D97' },
|
||||
{ token: 'variable.language.this', foreground: 'CE5D97' },
|
||||
{ token: 'variable.language.super', foreground: 'CE5D97' },
|
||||
|
||||
{ token: 'variable.other.property', foreground: '4385BE' },
|
||||
{ token: 'support.variable.property', foreground: '4385BE' },
|
||||
|
||||
{ token: 'variable.other.constant', foreground: 'CECDC3' },
|
||||
|
||||
{ token: 'meta.object-literal.key', foreground: 'DA702C' },
|
||||
{ token: 'support.type.property-name', foreground: 'DA702C' },
|
||||
|
||||
{ token: 'entity.name.tag', foreground: '4385BE' },
|
||||
{ token: 'tag', foreground: '4385BE' },
|
||||
|
||||
{ token: 'support.class.component', foreground: 'CE5D97' },
|
||||
|
||||
{ token: 'entity.other.attribute-name', foreground: 'D0A215' },
|
||||
|
||||
{ token: 'entity.name.namespace', foreground: 'D0A215' },
|
||||
|
||||
{ token: 'entity.name.module', foreground: 'D14D41' },
|
||||
|
||||
{ token: 'meta.decorator', foreground: 'D0A215' },
|
||||
{ token: 'entity.name.function.decorator', foreground: 'D0A215' },
|
||||
|
||||
{ token: 'entity.name.label', foreground: 'CE5D97' },
|
||||
|
||||
{ token: 'meta.preprocessor', foreground: 'CE5D97' },
|
||||
{ token: 'entity.name.function.preprocessor', foreground: '4385BE' },
|
||||
|
||||
{ token: 'punctuation', foreground: '878580' },
|
||||
{ token: 'delimiter', foreground: '878580' },
|
||||
{ token: 'delimiter.bracket', foreground: '878580' },
|
||||
|
||||
{ token: 'markup.heading', foreground: 'D0A215' },
|
||||
{ token: 'markup.bold', foreground: 'D0A215', fontStyle: 'bold' },
|
||||
{ token: 'markup.italic', foreground: '3AA99F', fontStyle: 'italic' },
|
||||
{ token: 'markup.underline.link', foreground: '4385BE' },
|
||||
{ token: 'markup.inline.raw', foreground: '3AA99F' },
|
||||
|
||||
{ token: 'invalid', foreground: 'D14D41' },
|
||||
{ token: 'invalid.illegal', foreground: 'D14D41' },
|
||||
|
||||
{ token: 'string.key.json', foreground: 'DA702C' },
|
||||
{ token: 'string.value.json', foreground: '3AA99F' },
|
||||
|
||||
{ token: 'support.type.property-name.css', foreground: 'CECDC3' },
|
||||
{ token: 'support.constant.property-value.css', foreground: '3AA99F' },
|
||||
|
||||
{ token: 'type', foreground: 'D0A215' },
|
||||
{ token: 'type.identifier', foreground: 'D0A215' },
|
||||
{ token: 'identifier', foreground: 'CECDC3' },
|
||||
],
|
||||
colors: {
|
||||
|
||||
'editor.background': '#100F0F',
|
||||
'editor.foreground': '#CECDC3',
|
||||
'editor.lineHighlightBackground': '#1C1B1A',
|
||||
'editor.selectionBackground': '#CECDC333',
|
||||
'editor.selectionHighlightBackground': '#CECDC333',
|
||||
'editor.inactiveSelectionBackground': '#282726',
|
||||
'editor.findMatchBackground': '#AD8301',
|
||||
'editor.findMatchHighlightBackground': '#AD8301cc',
|
||||
'editor.hoverHighlightBackground': '#343331',
|
||||
'editor.rangeHighlightBackground': '#403E3C',
|
||||
'editorCursor.foreground': '#CECDC3',
|
||||
|
||||
'editorLineNumber.foreground': '#403E3C',
|
||||
'editorLineNumber.activeForeground': '#CECDC3',
|
||||
|
||||
'editorGutter.background': '#100F0F',
|
||||
'editorGutter.modifiedBackground': '#3AA99F',
|
||||
'editorGutter.addedBackground': '#879A39',
|
||||
'editorGutter.deletedBackground': '#D14D41',
|
||||
|
||||
'diffEditor.insertedTextBackground': '#66800B25',
|
||||
'diffEditor.removedTextBackground': '#AF302925',
|
||||
'diffEditor.insertedLineBackground': '#66800B15',
|
||||
'diffEditor.removedLineBackground': '#AF302915',
|
||||
'diffEditor.insertedTextBorder': '#00000000',
|
||||
'diffEditor.removedTextBorder': '#00000000',
|
||||
|
||||
'editorBracketMatch.background': '#282726',
|
||||
'editorBracketMatch.border': '#343331',
|
||||
|
||||
'editorWhitespace.foreground': '#403E3C',
|
||||
'editorIndentGuide.background1': '#343331',
|
||||
'editorIndentGuide.activeBackground1': '#575653',
|
||||
|
||||
'editorWidget.background': '#1C1B1A',
|
||||
'editorWidget.border': '#343331',
|
||||
'editorSuggestWidget.background': '#100F0F',
|
||||
'editorSuggestWidget.border': '#343331',
|
||||
'editorSuggestWidget.foreground': '#CECDC3',
|
||||
'editorSuggestWidget.selectedBackground': '#343331',
|
||||
'editorHoverWidget.background': '#282726',
|
||||
'editorHoverWidget.border': '#343331',
|
||||
|
||||
'editorInlayHint.foreground': '#878580',
|
||||
'editorInlayHint.background': '#343331',
|
||||
|
||||
'editorError.foreground': '#D14D41',
|
||||
'editorWarning.foreground': '#DA702C',
|
||||
'editorInfo.foreground': '#4385BE',
|
||||
|
||||
'input.background': '#1C1B1A',
|
||||
'input.foreground': '#CECDC3',
|
||||
'input.border': '#343331',
|
||||
'input.placeholderForeground': '#878580',
|
||||
|
||||
'dropdown.background': '#1C1B1A',
|
||||
'dropdown.foreground': '#CECDC3',
|
||||
'dropdown.border': '#343331',
|
||||
'dropdown.listBackground': '#100F0F',
|
||||
|
||||
'focusBorder': '#343331',
|
||||
|
||||
'scrollbarSlider.background': '#34333180',
|
||||
'scrollbarSlider.hoverBackground': '#403E3C',
|
||||
'scrollbarSlider.activeBackground': '#575653',
|
||||
},
|
||||
};
|
||||
|
||||
const flexokiLightTheme: MonacoThemeData = {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [
|
||||
|
||||
{ token: '', foreground: '100F0F' },
|
||||
{ token: 'source', foreground: '100F0F' },
|
||||
|
||||
{ token: 'comment', foreground: '6F6E69' },
|
||||
{ token: 'comment.block', foreground: '6F6E69' },
|
||||
{ token: 'comment.line', foreground: '6F6E69' },
|
||||
{ token: 'comment.block.documentation', foreground: 'B7B5AC' },
|
||||
|
||||
{ token: 'string', foreground: '24837B' },
|
||||
{ token: 'string.quoted', foreground: '24837B' },
|
||||
{ token: 'string.template', foreground: '24837B' },
|
||||
{ token: 'string.regexp', foreground: '24837B' },
|
||||
|
||||
{ token: 'string.escape', foreground: '100F0F' },
|
||||
{ token: 'constant.character.escape', foreground: '100F0F' },
|
||||
|
||||
{ token: 'number', foreground: '5E409D' },
|
||||
{ token: 'number.hex', foreground: '5E409D' },
|
||||
{ token: 'number.float', foreground: '5E409D' },
|
||||
{ token: 'constant.numeric', foreground: '5E409D' },
|
||||
|
||||
{ token: 'constant.language', foreground: 'AD8301' },
|
||||
{ token: 'constant.language.boolean', foreground: 'AD8301' },
|
||||
{ token: 'constant.language.null', foreground: 'AD8301' },
|
||||
|
||||
{ token: 'keyword', foreground: '205EA6' },
|
||||
{ token: 'keyword.control', foreground: '205EA6' },
|
||||
{ token: 'keyword.other', foreground: '205EA6' },
|
||||
|
||||
{ token: 'keyword.control.import', foreground: 'AF3029' },
|
||||
{ token: 'keyword.control.from', foreground: 'AF3029' },
|
||||
{ token: 'keyword.control.export', foreground: 'AF3029' },
|
||||
|
||||
{ token: 'keyword.control.exception', foreground: 'A02F6F' },
|
||||
{ token: 'keyword.control.trycatch', foreground: 'A02F6F' },
|
||||
|
||||
{ token: 'keyword.operator', foreground: 'AF3029' },
|
||||
{ token: 'operator', foreground: 'AF3029' },
|
||||
|
||||
{ token: 'storage', foreground: '205EA6' },
|
||||
{ token: 'storage.type', foreground: '205EA6' },
|
||||
{ token: 'storage.modifier', foreground: '205EA6' },
|
||||
|
||||
{ token: 'entity.name.function', foreground: 'BC5215', fontStyle: 'bold' },
|
||||
{ token: 'support.function', foreground: 'BC5215', fontStyle: 'bold' },
|
||||
{ token: 'meta.function-call', foreground: 'BC5215' },
|
||||
|
||||
{ token: 'entity.name.function.method', foreground: '66800B' },
|
||||
|
||||
{ token: 'entity.name.class', foreground: 'BC5215' },
|
||||
{ token: 'entity.name.type.class', foreground: 'BC5215' },
|
||||
{ token: 'support.class', foreground: 'BC5215' },
|
||||
|
||||
{ token: 'entity.name.type', foreground: 'AD8301' },
|
||||
{ token: 'entity.name.type.interface', foreground: 'AD8301' },
|
||||
{ token: 'support.type', foreground: 'AD8301' },
|
||||
|
||||
{ token: 'entity.name.type.struct', foreground: 'BC5215' },
|
||||
{ token: 'entity.name.type.enum', foreground: 'BC5215' },
|
||||
|
||||
{ token: 'entity.name.type.parameter', foreground: 'BC5215' },
|
||||
|
||||
{ token: 'variable', foreground: '100F0F' },
|
||||
{ token: 'variable.other', foreground: '100F0F' },
|
||||
{ token: 'variable.parameter', foreground: '100F0F' },
|
||||
|
||||
{ token: 'variable.other.object', foreground: '66800B' },
|
||||
{ token: 'variable.other.readwrite.alias', foreground: '66800B' },
|
||||
|
||||
{ token: 'variable.language', foreground: 'A02F6F' },
|
||||
{ token: 'variable.language.this', foreground: 'A02F6F' },
|
||||
{ token: 'variable.language.super', foreground: 'A02F6F' },
|
||||
|
||||
{ token: 'variable.other.property', foreground: '205EA6' },
|
||||
{ token: 'support.variable.property', foreground: '205EA6' },
|
||||
|
||||
{ token: 'variable.other.constant', foreground: '100F0F' },
|
||||
|
||||
{ token: 'meta.object-literal.key', foreground: 'BC5215' },
|
||||
{ token: 'support.type.property-name', foreground: 'BC5215' },
|
||||
|
||||
{ token: 'entity.name.tag', foreground: '205EA6' },
|
||||
{ token: 'tag', foreground: '205EA6' },
|
||||
|
||||
{ token: 'support.class.component', foreground: 'A02F6F' },
|
||||
|
||||
{ token: 'entity.other.attribute-name', foreground: 'AD8301' },
|
||||
|
||||
{ token: 'entity.name.namespace', foreground: 'AD8301' },
|
||||
|
||||
{ token: 'entity.name.module', foreground: 'AF3029' },
|
||||
|
||||
{ token: 'meta.decorator', foreground: 'AD8301' },
|
||||
{ token: 'entity.name.function.decorator', foreground: 'AD8301' },
|
||||
|
||||
{ token: 'entity.name.label', foreground: 'A02F6F' },
|
||||
|
||||
{ token: 'meta.preprocessor', foreground: 'A02F6F' },
|
||||
{ token: 'entity.name.function.preprocessor', foreground: '205EA6' },
|
||||
|
||||
{ token: 'punctuation', foreground: '6F6E69' },
|
||||
{ token: 'delimiter', foreground: '6F6E69' },
|
||||
{ token: 'delimiter.bracket', foreground: '6F6E69' },
|
||||
|
||||
{ token: 'markup.heading', foreground: 'AD8301' },
|
||||
{ token: 'markup.bold', foreground: 'AD8301', fontStyle: 'bold' },
|
||||
{ token: 'markup.italic', foreground: '24837B', fontStyle: 'italic' },
|
||||
{ token: 'markup.underline.link', foreground: '205EA6' },
|
||||
{ token: 'markup.inline.raw', foreground: '24837B' },
|
||||
|
||||
{ token: 'invalid', foreground: 'AF3029' },
|
||||
{ token: 'invalid.illegal', foreground: 'AF3029' },
|
||||
|
||||
{ token: 'string.key.json', foreground: 'BC5215' },
|
||||
{ token: 'string.value.json', foreground: '24837B' },
|
||||
|
||||
{ token: 'support.type.property-name.css', foreground: '100F0F' },
|
||||
{ token: 'support.constant.property-value.css', foreground: '24837B' },
|
||||
|
||||
{ token: 'type', foreground: 'AD8301' },
|
||||
{ token: 'type.identifier', foreground: 'AD8301' },
|
||||
{ token: 'identifier', foreground: '100F0F' },
|
||||
],
|
||||
colors: {
|
||||
|
||||
'editor.background': '#FFFCF0',
|
||||
'editor.foreground': '#100F0F',
|
||||
'editor.lineHighlightBackground': '#F2F0E5',
|
||||
'editor.selectionBackground': '#100F0F44',
|
||||
'editor.selectionHighlightBackground': '#100F0F44',
|
||||
'editor.inactiveSelectionBackground': '#E6E4D9',
|
||||
'editor.findMatchBackground': '#D0A215',
|
||||
'editor.findMatchHighlightBackground': '#D0A215cc',
|
||||
'editor.hoverHighlightBackground': '#DAD8CE',
|
||||
'editor.rangeHighlightBackground': '#CECDC3',
|
||||
'editorCursor.foreground': '#100F0F',
|
||||
|
||||
'editorLineNumber.foreground': '#CECDC3',
|
||||
'editorLineNumber.activeForeground': '#100F0F',
|
||||
|
||||
'editorGutter.background': '#FFFCF0',
|
||||
'editorGutter.modifiedBackground': '#24837B',
|
||||
'editorGutter.addedBackground': '#66800B',
|
||||
'editorGutter.deletedBackground': '#AF3029',
|
||||
|
||||
'diffEditor.insertedTextBackground': '#66800B25',
|
||||
'diffEditor.removedTextBackground': '#AF302925',
|
||||
'diffEditor.insertedLineBackground': '#66800B15',
|
||||
'diffEditor.removedLineBackground': '#AF302915',
|
||||
'diffEditor.insertedTextBorder': '#00000000',
|
||||
'diffEditor.removedTextBorder': '#00000000',
|
||||
|
||||
'editorBracketMatch.background': '#E6E4D9',
|
||||
'editorBracketMatch.border': '#DAD8CE',
|
||||
|
||||
'editorWhitespace.foreground': '#CECDC3',
|
||||
'editorIndentGuide.background1': '#DAD8CE',
|
||||
'editorIndentGuide.activeBackground1': '#B7B5AC',
|
||||
|
||||
'editorWidget.background': '#F2F0E5',
|
||||
'editorWidget.border': '#DAD8CE',
|
||||
'editorSuggestWidget.background': '#FFFCF0',
|
||||
'editorSuggestWidget.border': '#DAD8CE',
|
||||
'editorSuggestWidget.foreground': '#100F0F',
|
||||
'editorSuggestWidget.selectedBackground': '#DAD8CE',
|
||||
'editorHoverWidget.background': '#E6E4D9',
|
||||
'editorHoverWidget.border': '#DAD8CE',
|
||||
|
||||
'editorInlayHint.foreground': '#6F6E69',
|
||||
'editorInlayHint.background': '#DAD8CE',
|
||||
|
||||
'editorError.foreground': '#AF3029',
|
||||
'editorWarning.foreground': '#BC5215',
|
||||
'editorInfo.foreground': '#205EA6',
|
||||
|
||||
'input.background': '#F2F0E5',
|
||||
'input.foreground': '#100F0F',
|
||||
'input.border': '#DAD8CE',
|
||||
'input.placeholderForeground': '#6F6E69',
|
||||
|
||||
'dropdown.background': '#F2F0E5',
|
||||
'dropdown.foreground': '#100F0F',
|
||||
'dropdown.border': '#DAD8CE',
|
||||
'dropdown.listBackground': '#FFFCF0',
|
||||
|
||||
'focusBorder': '#DAD8CE',
|
||||
|
||||
'scrollbarSlider.background': '#DAD8CE80',
|
||||
'scrollbarSlider.hoverBackground': '#CECDC3',
|
||||
'scrollbarSlider.activeBackground': '#B7B5AC',
|
||||
},
|
||||
};
|
||||
|
||||
export const getMonacoThemeIdForTheme = (theme: Theme): string => {
|
||||
return theme.metadata.variant === 'dark' ? MONACO_DARK_THEME_ID : MONACO_LIGHT_THEME_ID;
|
||||
};
|
||||
|
||||
export const ensureMonacoThemeRegistered = (theme: Theme, monacoOverride?: Monaco): void => {
|
||||
const monaco = monacoOverride ?? getMonacoFromGlobal();
|
||||
if (!monaco) return;
|
||||
|
||||
const themeId = getMonacoThemeIdForTheme(theme);
|
||||
if (lastRegisteredThemeId === themeId) return;
|
||||
|
||||
const themeData = theme.metadata.variant === 'dark' ? flexokiDarkTheme : flexokiLightTheme;
|
||||
monaco.editor.defineTheme(themeId, themeData);
|
||||
|
||||
lastRegisteredThemeId = themeId;
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
|
||||
export function generateSyntaxTheme(theme: Theme) {
|
||||
const syntax = theme.colors.syntax;
|
||||
const surface = theme.colors.surface;
|
||||
|
||||
return {
|
||||
'code[class*="language-"]': {
|
||||
color: syntax.base.foreground,
|
||||
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: syntax.base.foreground,
|
||||
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: '0',
|
||||
margin: '0',
|
||||
overflow: 'auto',
|
||||
},
|
||||
|
||||
comment: {
|
||||
color: syntax.base.comment,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
prolog: {
|
||||
color: syntax.base.comment,
|
||||
},
|
||||
doctype: {
|
||||
color: syntax.base.comment,
|
||||
},
|
||||
cdata: {
|
||||
color: syntax.base.comment,
|
||||
},
|
||||
|
||||
punctuation: {
|
||||
color: syntax.tokens?.punctuation || surface.mutedForeground,
|
||||
},
|
||||
|
||||
property: {
|
||||
color: syntax.tokens?.variableProperty || syntax.base.variable,
|
||||
},
|
||||
tag: {
|
||||
color: syntax.tokens?.tag || syntax.base.keyword,
|
||||
},
|
||||
'attr-name': {
|
||||
color: syntax.tokens?.tagAttribute || syntax.base.variable,
|
||||
},
|
||||
'attr-value': {
|
||||
color: syntax.tokens?.tagAttributeValue || syntax.base.string,
|
||||
},
|
||||
|
||||
boolean: {
|
||||
color: syntax.tokens?.boolean || syntax.base.number,
|
||||
},
|
||||
number: {
|
||||
color: syntax.base.number,
|
||||
},
|
||||
constant: {
|
||||
color: syntax.tokens?.constant || syntax.base.number,
|
||||
},
|
||||
symbol: {
|
||||
color: syntax.tokens?.constant || syntax.base.number,
|
||||
},
|
||||
|
||||
string: {
|
||||
color: syntax.base.string,
|
||||
},
|
||||
char: {
|
||||
color: syntax.base.string,
|
||||
},
|
||||
|
||||
function: {
|
||||
color: syntax.base.function,
|
||||
},
|
||||
builtin: {
|
||||
color: syntax.tokens?.functionBuiltin || syntax.base.function,
|
||||
},
|
||||
|
||||
'class-name': {
|
||||
color: syntax.tokens?.className || syntax.base.type,
|
||||
},
|
||||
namespace: {
|
||||
color: syntax.tokens?.namespace || syntax.base.type,
|
||||
opacity: 0.8,
|
||||
},
|
||||
|
||||
keyword: {
|
||||
color: syntax.base.keyword,
|
||||
},
|
||||
atrule: {
|
||||
color: syntax.base.keyword,
|
||||
},
|
||||
selector: {
|
||||
color: syntax.base.function,
|
||||
},
|
||||
|
||||
operator: {
|
||||
color: syntax.base.operator,
|
||||
},
|
||||
|
||||
variable: {
|
||||
color: syntax.base.variable,
|
||||
},
|
||||
|
||||
regex: {
|
||||
color: syntax.tokens?.regex || syntax.base.string,
|
||||
},
|
||||
|
||||
url: {
|
||||
color: syntax.base.function,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
entity: {
|
||||
color: syntax.base.function,
|
||||
cursor: 'help',
|
||||
},
|
||||
|
||||
'.language-css .token.string': {
|
||||
color: syntax.base.string,
|
||||
},
|
||||
'.style .token.string': {
|
||||
color: syntax.base.string,
|
||||
},
|
||||
|
||||
deleted: {
|
||||
color: theme.colors.status.error,
|
||||
backgroundColor: theme.colors.status.errorBackground,
|
||||
},
|
||||
inserted: {
|
||||
color: theme.colors.status.success,
|
||||
backgroundColor: theme.colors.status.successBackground,
|
||||
},
|
||||
|
||||
title: {
|
||||
color: theme.colors.primary.base,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'code-block': {
|
||||
color: syntax.base.string,
|
||||
},
|
||||
'code-snippet': {
|
||||
color: syntax.base.string,
|
||||
},
|
||||
list: {
|
||||
color: syntax.base.variable,
|
||||
},
|
||||
hr: {
|
||||
color: surface.mutedForeground,
|
||||
},
|
||||
table: {
|
||||
color: syntax.base.function,
|
||||
},
|
||||
blockquote: {
|
||||
color: surface.mutedForeground,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
important: {
|
||||
color: syntax.base.keyword,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bold: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
strike: {
|
||||
textDecoration: 'line-through',
|
||||
},
|
||||
|
||||
decorator: {
|
||||
color: syntax.tokens?.decorator || syntax.base.function,
|
||||
},
|
||||
annotation: {
|
||||
color: syntax.tokens?.decorator || syntax.base.function,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
|
||||
export const flexokiDarkTheme: Theme = {
|
||||
metadata: {
|
||||
id: 'flexoki-dark',
|
||||
name: 'Flexoki Dark',
|
||||
description: 'An inky color scheme for prose and code - dark variant',
|
||||
author: 'Steph Ango',
|
||||
version: '1.0.0',
|
||||
variant: 'dark',
|
||||
tags: ['dark', 'warm', 'natural', 'ink']
|
||||
},
|
||||
|
||||
colors: {
|
||||
|
||||
primary: {
|
||||
base: '#EC8B49',
|
||||
hover: '#DA702C',
|
||||
active: '#F9AE77',
|
||||
foreground: '#100F0F',
|
||||
muted: '#EC8B4980',
|
||||
emphasis: '#F9AE77'
|
||||
},
|
||||
|
||||
surface: {
|
||||
background: '#100F0F',
|
||||
foreground: '#CECDC3',
|
||||
muted: '#1C1B1A',
|
||||
mutedForeground: '#878580',
|
||||
elevated: '#282726',
|
||||
elevatedForeground: '#CECDC3',
|
||||
overlay: '#00000080',
|
||||
subtle: '#343331'
|
||||
},
|
||||
|
||||
interactive: {
|
||||
border: '#343331',
|
||||
borderHover: '#403E3C',
|
||||
borderFocus: '#EC8B49',
|
||||
selection: '#CECDC330',
|
||||
selectionForeground: '#CECDC3',
|
||||
focus: '#EC8B49',
|
||||
focusRing: '#EC8B4950',
|
||||
cursor: '#CECDC3',
|
||||
hover: '#343331',
|
||||
active: '#403E3C'
|
||||
},
|
||||
|
||||
status: {
|
||||
error: '#D14D41',
|
||||
errorForeground: '#100F0F',
|
||||
errorBackground: '#AF302920',
|
||||
errorBorder: '#AF302950',
|
||||
|
||||
warning: '#DA702C',
|
||||
warningForeground: '#100F0F',
|
||||
warningBackground: '#BC521520',
|
||||
warningBorder: '#BC521550',
|
||||
|
||||
success: '#A0AF54',
|
||||
successForeground: '#100F0F',
|
||||
successBackground: '#66800B20',
|
||||
successBorder: '#66800B50',
|
||||
|
||||
info: '#4385BE',
|
||||
infoForeground: '#100F0F',
|
||||
infoBackground: '#205EA620',
|
||||
infoBorder: '#205EA650'
|
||||
},
|
||||
|
||||
syntax: {
|
||||
base: {
|
||||
background: '#1C1B1A',
|
||||
foreground: '#CECDC3',
|
||||
comment: '#878580',
|
||||
keyword: '#4385BE',
|
||||
string: '#3AA99F',
|
||||
number: '#8B7EC8',
|
||||
function: '#DA702C',
|
||||
variable: '#CECDC3',
|
||||
type: '#D0A215',
|
||||
operator: '#D14D41'
|
||||
},
|
||||
|
||||
tokens: {
|
||||
commentDoc: '#575653',
|
||||
stringEscape: '#CECDC3',
|
||||
keywordImport: '#D14D41',
|
||||
storageModifier: '#4385BE',
|
||||
functionCall: '#DA702C',
|
||||
method: '#879A39',
|
||||
variableProperty: '#4385BE',
|
||||
variableOther: '#879A39',
|
||||
variableGlobal: '#CE5D97',
|
||||
variableLocal: '#282726',
|
||||
parameter: '#CECDC3',
|
||||
constant: '#CECDC3',
|
||||
class: '#DA702C',
|
||||
className: '#DA702C',
|
||||
interface: '#D0A215',
|
||||
struct: '#DA702C',
|
||||
enum: '#DA702C',
|
||||
typeParameter: '#DA702C',
|
||||
namespace: '#D0A215',
|
||||
module: '#D14D41',
|
||||
tag: '#4385BE',
|
||||
jsxTag: '#CE5D97',
|
||||
tagAttribute: '#D0A215',
|
||||
tagAttributeValue: '#3AA99F',
|
||||
boolean: '#D0A215',
|
||||
decorator: '#D0A215',
|
||||
label: '#CE5D97',
|
||||
punctuation: '#878580',
|
||||
macro: '#4385BE',
|
||||
preprocessor: '#CE5D97',
|
||||
regex: '#3AA99F',
|
||||
url: '#4385BE',
|
||||
key: '#DA702C',
|
||||
exception: '#CE5D97'
|
||||
},
|
||||
|
||||
highlights: {
|
||||
diffAdded: '#879A39',
|
||||
diffAddedBackground: '#66800B20',
|
||||
diffRemoved: '#D14D41',
|
||||
diffRemovedBackground: '#AF302920',
|
||||
diffModified: '#4385BE',
|
||||
diffModifiedBackground: '#205EA620',
|
||||
lineNumber: '#403E3C',
|
||||
lineNumberActive: '#CECDC3'
|
||||
}
|
||||
},
|
||||
|
||||
markdown: {
|
||||
heading1: '#D0A215',
|
||||
heading2: '#DA702C',
|
||||
heading3: '#4385BE',
|
||||
heading4: '#CECDC3',
|
||||
link: '#4385BE',
|
||||
linkHover: '#205EA6',
|
||||
inlineCode: '#A0AF54',
|
||||
inlineCodeBackground: '#1C1B1A',
|
||||
blockquote: '#878580',
|
||||
blockquoteBorder: '#343331',
|
||||
listMarker: '#D0A21599'
|
||||
},
|
||||
|
||||
chat: {
|
||||
userMessage: '#CECDC3',
|
||||
userMessageBackground: '#282726',
|
||||
assistantMessage: '#CECDC3',
|
||||
assistantMessageBackground: '#100F0F',
|
||||
timestamp: '#878580',
|
||||
divider: '#343331'
|
||||
},
|
||||
|
||||
tools: {
|
||||
background: '#1C1B1A50',
|
||||
border: '#34333180',
|
||||
headerHover: '#34333150',
|
||||
icon: '#878580',
|
||||
title: '#CECDC3',
|
||||
description: '#878580',
|
||||
|
||||
edit: {
|
||||
added: '#879A39',
|
||||
addedBackground: '#66800B25',
|
||||
removed: '#D14D41',
|
||||
removedBackground: '#AF302925',
|
||||
lineNumber: '#403E3C'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
config: {
|
||||
fonts: {
|
||||
sans: '"IBM Plex Mono", monospace',
|
||||
mono: '"IBM Plex Mono", monospace',
|
||||
heading: '"IBM Plex Mono", monospace'
|
||||
},
|
||||
|
||||
radius: {
|
||||
none: '0',
|
||||
sm: '0.125rem',
|
||||
md: '0.375rem',
|
||||
lg: '0.5rem',
|
||||
xl: '0.75rem',
|
||||
full: '9999px'
|
||||
},
|
||||
|
||||
transitions: {
|
||||
fast: '150ms ease',
|
||||
normal: '250ms ease',
|
||||
slow: '350ms ease'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
|
||||
export const flexokiLightTheme: Theme = {
|
||||
metadata: {
|
||||
id: 'flexoki-light',
|
||||
name: 'Flexoki Light',
|
||||
description: 'An inky color scheme for prose and code - light variant',
|
||||
author: 'Steph Ango',
|
||||
version: '1.0.0',
|
||||
variant: 'light',
|
||||
tags: ['light', 'warm', 'natural', 'paper']
|
||||
},
|
||||
|
||||
colors: {
|
||||
|
||||
primary: {
|
||||
base: '#EC8B49',
|
||||
hover: '#F9AE77',
|
||||
active: '#DA702C',
|
||||
foreground: '#FFFCF0',
|
||||
muted: '#EC8B4980',
|
||||
emphasis: '#F9AE77'
|
||||
},
|
||||
|
||||
surface: {
|
||||
background: '#FFFCF0',
|
||||
foreground: '#100F0F',
|
||||
muted: '#F2F0E5',
|
||||
mutedForeground: '#6F6E69',
|
||||
elevated: '#E6E4D9',
|
||||
elevatedForeground: '#100F0F',
|
||||
overlay: '#100F0F20',
|
||||
subtle: '#DAD8CE'
|
||||
},
|
||||
|
||||
interactive: {
|
||||
border: '#DAD8CE',
|
||||
borderHover: '#CECDC3',
|
||||
borderFocus: '#EC8B49',
|
||||
selection: '#100F0F44',
|
||||
selectionForeground: '#100F0F',
|
||||
focus: '#EC8B49',
|
||||
focusRing: '#EC8B4940',
|
||||
cursor: '#100F0F',
|
||||
hover: '#DAD8CE',
|
||||
active: '#CECDC3'
|
||||
},
|
||||
|
||||
status: {
|
||||
error: '#AF3029',
|
||||
errorForeground: '#FFFCF0',
|
||||
errorBackground: '#D14D4120',
|
||||
errorBorder: '#D14D4150',
|
||||
|
||||
warning: '#BC5215',
|
||||
warningForeground: '#FFFCF0',
|
||||
warningBackground: '#DA702C20',
|
||||
warningBorder: '#DA702C50',
|
||||
|
||||
success: '#66800B',
|
||||
successForeground: '#FFFCF0',
|
||||
successBackground: '#879A3920',
|
||||
successBorder: '#879A3950',
|
||||
|
||||
info: '#205EA6',
|
||||
infoForeground: '#FFFCF0',
|
||||
infoBackground: '#4385BE20',
|
||||
infoBorder: '#4385BE50'
|
||||
},
|
||||
|
||||
syntax: {
|
||||
base: {
|
||||
background: '#F2F0E5',
|
||||
foreground: '#100F0F',
|
||||
comment: '#6F6E69',
|
||||
keyword: '#205EA6',
|
||||
string: '#24837B',
|
||||
number: '#5E409D',
|
||||
function: '#BC5215',
|
||||
variable: '#100F0F',
|
||||
type: '#AD8301',
|
||||
operator: '#AF3029'
|
||||
},
|
||||
|
||||
tokens: {
|
||||
commentDoc: '#B7B5AC',
|
||||
stringEscape: '#100F0F',
|
||||
keywordImport: '#AF3029',
|
||||
storageModifier: '#205EA6',
|
||||
functionCall: '#BC5215',
|
||||
method: '#66800B',
|
||||
variableProperty: '#205EA6',
|
||||
variableOther: '#66800B',
|
||||
variableGlobal: '#A02F6F',
|
||||
variableLocal: '#E6E4D9',
|
||||
parameter: '#100F0F',
|
||||
constant: '#100F0F',
|
||||
class: '#BC5215',
|
||||
className: '#BC5215',
|
||||
interface: '#AD8301',
|
||||
struct: '#BC5215',
|
||||
enum: '#BC5215',
|
||||
typeParameter: '#BC5215',
|
||||
namespace: '#AD8301',
|
||||
module: '#AF3029',
|
||||
tag: '#205EA6',
|
||||
jsxTag: '#A02F6F',
|
||||
tagAttribute: '#AD8301',
|
||||
tagAttributeValue: '#24837B',
|
||||
boolean: '#AD8301',
|
||||
decorator: '#AD8301',
|
||||
label: '#A02F6F',
|
||||
punctuation: '#6F6E69',
|
||||
macro: '#205EA6',
|
||||
preprocessor: '#A02F6F',
|
||||
regex: '#24837B',
|
||||
url: '#205EA6',
|
||||
key: '#BC5215',
|
||||
exception: '#A02F6F'
|
||||
},
|
||||
|
||||
highlights: {
|
||||
diffAdded: '#66800B',
|
||||
diffAddedBackground: '#879A3920',
|
||||
diffRemoved: '#AF3029',
|
||||
diffRemovedBackground: '#D14D4120',
|
||||
diffModified: '#205EA6',
|
||||
diffModifiedBackground: '#4385BE20',
|
||||
lineNumber: '#CECDC3',
|
||||
lineNumberActive: '#100F0F'
|
||||
}
|
||||
},
|
||||
|
||||
markdown: {
|
||||
heading1: '#AD8301',
|
||||
heading2: '#BC5215',
|
||||
heading3: '#205EA6',
|
||||
heading4: '#100F0F',
|
||||
link: '#205EA6',
|
||||
linkHover: '#4385BE',
|
||||
inlineCode: '#24837B',
|
||||
inlineCodeBackground: '#F2F0E5',
|
||||
blockquote: '#6F6E69',
|
||||
blockquoteBorder: '#DAD8CE',
|
||||
listMarker: '#AD830199'
|
||||
},
|
||||
|
||||
chat: {
|
||||
userMessage: '#100F0F',
|
||||
userMessageBackground: '#F2F0E5',
|
||||
assistantMessage: '#100F0F',
|
||||
assistantMessageBackground: '#FFFCF0',
|
||||
timestamp: '#6F6E69',
|
||||
divider: '#DAD8CE'
|
||||
},
|
||||
|
||||
tools: {
|
||||
background: '#F2F0E550',
|
||||
border: '#DAD8CE80',
|
||||
headerHover: '#DAD8CE',
|
||||
icon: '#6F6E69',
|
||||
title: '#100F0F',
|
||||
description: '#6F6E69',
|
||||
|
||||
edit: {
|
||||
added: '#66800B',
|
||||
addedBackground: '#66800B25',
|
||||
removed: '#AF3029',
|
||||
removedBackground: '#AF302925',
|
||||
lineNumber: '#CECDC3'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
config: {
|
||||
fonts: {
|
||||
sans: '"IBM Plex Mono", monospace',
|
||||
mono: '"IBM Plex Mono", monospace',
|
||||
heading: '"IBM Plex Mono", monospace'
|
||||
},
|
||||
|
||||
radius: {
|
||||
none: '0',
|
||||
sm: '0.125rem',
|
||||
md: '0.375rem',
|
||||
lg: '0.5rem',
|
||||
xl: '0.75rem',
|
||||
full: '9999px'
|
||||
},
|
||||
|
||||
transitions: {
|
||||
fast: '150ms ease',
|
||||
normal: '250ms ease',
|
||||
slow: '350ms ease'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { flexokiLightTheme } from './flexoki-light';
|
||||
import { flexokiDarkTheme } from './flexoki-dark';
|
||||
|
||||
export const themes: Theme[] = [
|
||||
flexokiLightTheme,
|
||||
flexokiDarkTheme,
|
||||
];
|
||||
|
||||
export {
|
||||
flexokiLightTheme,
|
||||
flexokiDarkTheme,
|
||||
};
|
||||
|
||||
export function getThemeById(id: string): Theme | undefined {
|
||||
return themes.find(theme => theme.metadata.id === id);
|
||||
}
|
||||
|
||||
export function getDefaultTheme(prefersDark: boolean): Theme {
|
||||
return prefersDark ? flexokiDarkTheme : flexokiLightTheme;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
export interface ToolMetadata {
|
||||
displayName: string;
|
||||
icon?: string;
|
||||
outputLanguage?: string;
|
||||
inputFields?: {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'command' | 'file' | 'pattern' | 'text' | 'code';
|
||||
language?: string;
|
||||
}[];
|
||||
category: 'file' | 'search' | 'code' | 'system' | 'ai' | 'web';
|
||||
}
|
||||
|
||||
export const TOOL_METADATA: Record<string, ToolMetadata> = {
|
||||
|
||||
read: {
|
||||
displayName: 'Read File',
|
||||
category: 'file',
|
||||
outputLanguage: 'auto',
|
||||
inputFields: [
|
||||
{ key: 'filePath', label: 'File Path', type: 'file' },
|
||||
{ key: 'offset', label: 'Start Line', type: 'text' },
|
||||
{ key: 'limit', label: 'Lines to Read', type: 'text' }
|
||||
]
|
||||
},
|
||||
write: {
|
||||
displayName: 'Write File',
|
||||
category: 'file',
|
||||
outputLanguage: 'auto',
|
||||
inputFields: [
|
||||
{ key: 'filePath', label: 'File Path', type: 'file' },
|
||||
{ key: 'content', label: 'Content', type: 'code' }
|
||||
]
|
||||
},
|
||||
edit: {
|
||||
displayName: 'Edit File',
|
||||
category: 'file',
|
||||
outputLanguage: 'diff',
|
||||
inputFields: [
|
||||
{ key: 'filePath', label: 'File Path', type: 'file' },
|
||||
{ key: 'oldString', label: 'Find', type: 'code' },
|
||||
{ key: 'newString', label: 'Replace', type: 'code' },
|
||||
{ key: 'replaceAll', label: 'Replace All', type: 'text' }
|
||||
]
|
||||
},
|
||||
multiedit: {
|
||||
displayName: 'Multi-Edit',
|
||||
category: 'file',
|
||||
outputLanguage: 'diff',
|
||||
inputFields: [
|
||||
{ key: 'filePath', label: 'File Path', type: 'file' },
|
||||
{ key: 'edits', label: 'Edits', type: 'code', language: 'json' }
|
||||
]
|
||||
},
|
||||
|
||||
bash: {
|
||||
displayName: 'Shell Command',
|
||||
category: 'system',
|
||||
outputLanguage: 'text',
|
||||
inputFields: [
|
||||
{ key: 'command', label: 'Command', type: 'command', language: 'bash' },
|
||||
{ key: 'description', label: 'Description', type: 'text' },
|
||||
{ key: 'timeout', label: 'Timeout (ms)', type: 'text' }
|
||||
]
|
||||
},
|
||||
|
||||
grep: {
|
||||
displayName: 'Search Files',
|
||||
category: 'search',
|
||||
outputLanguage: 'text',
|
||||
inputFields: [
|
||||
{ key: 'pattern', label: 'Pattern', type: 'pattern' },
|
||||
{ key: 'path', label: 'Directory', type: 'file' },
|
||||
{ key: 'include', label: 'Include Pattern', type: 'pattern' }
|
||||
]
|
||||
},
|
||||
glob: {
|
||||
displayName: 'Find Files',
|
||||
category: 'search',
|
||||
outputLanguage: 'text',
|
||||
inputFields: [
|
||||
{ key: 'pattern', label: 'Pattern', type: 'pattern' },
|
||||
{ key: 'path', label: 'Directory', type: 'file' }
|
||||
]
|
||||
},
|
||||
list: {
|
||||
displayName: 'List Directory',
|
||||
category: 'file',
|
||||
outputLanguage: 'text',
|
||||
inputFields: [
|
||||
{ key: 'path', label: 'Directory', type: 'file' },
|
||||
{ key: 'ignore', label: 'Ignore Patterns', type: 'pattern' }
|
||||
]
|
||||
},
|
||||
|
||||
task: {
|
||||
displayName: 'Agent Task',
|
||||
category: 'ai',
|
||||
outputLanguage: 'markdown',
|
||||
inputFields: [
|
||||
{ key: 'description', label: 'Task', type: 'text' },
|
||||
{ key: 'prompt', label: 'Instructions', type: 'text' },
|
||||
{ key: 'subagent_type', label: 'Agent Type', type: 'text' }
|
||||
]
|
||||
},
|
||||
|
||||
webfetch: {
|
||||
displayName: 'Fetch URL',
|
||||
category: 'web',
|
||||
outputLanguage: 'auto',
|
||||
inputFields: [
|
||||
{ key: 'url', label: 'URL', type: 'text' },
|
||||
{ key: 'format', label: 'Format', type: 'text' },
|
||||
{ key: 'timeout', label: 'Timeout', type: 'text' }
|
||||
]
|
||||
},
|
||||
|
||||
websearch: {
|
||||
displayName: 'Web Search',
|
||||
category: 'web',
|
||||
outputLanguage: 'markdown',
|
||||
inputFields: [
|
||||
{ key: 'query', label: 'Search Query', type: 'text' },
|
||||
{ key: 'numResults', label: 'Results Count', type: 'text' },
|
||||
{ key: 'type', label: 'Search Type', type: 'text' }
|
||||
]
|
||||
},
|
||||
codesearch: {
|
||||
displayName: 'Code Search',
|
||||
category: 'web',
|
||||
outputLanguage: 'markdown',
|
||||
inputFields: [
|
||||
{ key: 'query', label: 'Search Query', type: 'text' },
|
||||
{ key: 'tokensNum', label: 'Tokens', type: 'text' }
|
||||
]
|
||||
},
|
||||
|
||||
todowrite: {
|
||||
displayName: 'Update Todo List',
|
||||
category: 'system',
|
||||
outputLanguage: 'json',
|
||||
inputFields: [
|
||||
{ key: 'todos', label: 'Todo Items', type: 'code', language: 'json' }
|
||||
]
|
||||
},
|
||||
todoread: {
|
||||
displayName: 'Read Todo List',
|
||||
category: 'system',
|
||||
outputLanguage: 'json',
|
||||
inputFields: []
|
||||
}
|
||||
};
|
||||
|
||||
export function getToolMetadata(toolName: string): ToolMetadata {
|
||||
return TOOL_METADATA[toolName] || {
|
||||
displayName: toolName.charAt(0).toUpperCase() + toolName.slice(1).replace(/-/g, ' '),
|
||||
category: 'system',
|
||||
outputLanguage: 'text',
|
||||
inputFields: []
|
||||
};
|
||||
}
|
||||
|
||||
export function detectToolOutputLanguage(
|
||||
toolName: string,
|
||||
output: string,
|
||||
input?: Record<string, unknown>
|
||||
): string {
|
||||
const metadata = getToolMetadata(toolName);
|
||||
|
||||
if (metadata.outputLanguage === 'auto') {
|
||||
|
||||
if (input?.filePath || input?.file_path || input?.sourcePath) {
|
||||
const filePath = (input.filePath || input.file_path || input.sourcePath) as string;
|
||||
const language = getLanguageFromExtension(filePath);
|
||||
if (language) return language;
|
||||
}
|
||||
|
||||
if (toolName === 'webfetch') {
|
||||
if (output.trim().startsWith('{') || output.trim().startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(output);
|
||||
return 'json';
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
if (output.trim().startsWith('<')) {
|
||||
return 'html';
|
||||
}
|
||||
if (output.includes('```')) {
|
||||
return 'markdown';
|
||||
}
|
||||
}
|
||||
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return metadata.outputLanguage || 'text';
|
||||
}
|
||||
|
||||
export function getLanguageFromExtension(filePath: string): string | null {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
|
||||
const languageMap: Record<string, string> = {
|
||||
|
||||
'js': 'javascript',
|
||||
'jsx': 'javascript',
|
||||
'ts': 'typescript',
|
||||
'tsx': 'typescript',
|
||||
'mjs': 'javascript',
|
||||
'cjs': 'javascript',
|
||||
|
||||
'html': 'html',
|
||||
'htm': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'toml': 'toml',
|
||||
'xml': 'xml',
|
||||
|
||||
'py': 'python',
|
||||
'rb': 'ruby',
|
||||
'go': 'go',
|
||||
'rs': 'rust',
|
||||
'java': 'java',
|
||||
'kt': 'kotlin',
|
||||
'swift': 'swift',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'cc': 'cpp',
|
||||
'h': 'c',
|
||||
'hpp': 'cpp',
|
||||
'cs': 'csharp',
|
||||
'php': 'php',
|
||||
'dart': 'dart',
|
||||
'r': 'r',
|
||||
'lua': 'lua',
|
||||
'vim': 'vim',
|
||||
|
||||
'sh': 'bash',
|
||||
'bash': 'bash',
|
||||
'zsh': 'bash',
|
||||
'fish': 'bash',
|
||||
'ps1': 'powershell',
|
||||
|
||||
'md': 'markdown',
|
||||
'mdx': 'markdown',
|
||||
'rst': 'text',
|
||||
'txt': 'text',
|
||||
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'gitignore': 'text',
|
||||
'env': 'text',
|
||||
'conf': 'text',
|
||||
'cfg': 'text',
|
||||
'ini': 'ini',
|
||||
|
||||
'sql': 'sql',
|
||||
|
||||
'diff': 'diff',
|
||||
'patch': 'diff'
|
||||
};
|
||||
|
||||
return languageMap[ext || ''] || null;
|
||||
}
|
||||
|
||||
export function formatToolInput(input: Record<string, unknown>, toolName: string): string {
|
||||
if (!input) return '';
|
||||
|
||||
const getString = (key: string): string | null => {
|
||||
const val = input[key];
|
||||
return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : null);
|
||||
};
|
||||
|
||||
if (toolName === 'bash') {
|
||||
const cmd = getString('command');
|
||||
if (cmd) return cmd;
|
||||
}
|
||||
|
||||
if (toolName === 'task') {
|
||||
const prompt = getString('prompt');
|
||||
if (prompt) return prompt;
|
||||
const desc = getString('description');
|
||||
if (desc) return desc;
|
||||
}
|
||||
|
||||
if ((toolName === 'edit' || toolName === 'multiedit') && typeof input === 'object') {
|
||||
const filePath = getString('filePath') || getString('file_path') || getString('path');
|
||||
if (filePath) {
|
||||
return `File path: ${filePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'write' && typeof input === 'object') {
|
||||
|
||||
const content = getString('content');
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof input === 'object') {
|
||||
const entries = Object.entries(input)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => {
|
||||
|
||||
const formattedKey = key.replace(/([A-Z])/g, ' $1').replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/^./, str => str.toUpperCase());
|
||||
|
||||
let formattedValue = value;
|
||||
if (typeof value === 'object') {
|
||||
formattedValue = JSON.stringify(value, null, 2);
|
||||
} else if (typeof value === 'boolean') {
|
||||
formattedValue = value ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
return `${formattedKey}: ${formattedValue}`;
|
||||
});
|
||||
|
||||
return entries.join('\n');
|
||||
}
|
||||
|
||||
return String(input);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
export const SEMANTIC_TYPOGRAPHY = {
|
||||
markdown: '0.9375rem',
|
||||
code: '0.9063rem',
|
||||
uiHeader: '0.9375rem',
|
||||
uiLabel: '0.8750rem',
|
||||
meta: '0.875rem',
|
||||
micro: '0.875rem',
|
||||
} 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: {
|
||||
fontSize: 'var(--text-markdown)',
|
||||
},
|
||||
|
||||
semanticCode: {
|
||||
fontSize: 'var(--text-code)',
|
||||
},
|
||||
|
||||
uiHeader: {
|
||||
fontSize: 'var(--text-ui-header)',
|
||||
},
|
||||
|
||||
uiLabel: {
|
||||
fontSize: 'var(--text-ui-label)',
|
||||
},
|
||||
|
||||
meta: {
|
||||
fontSize: 'var(--text-meta)',
|
||||
},
|
||||
|
||||
micro: {
|
||||
fontSize: 'var(--text-micro)',
|
||||
},
|
||||
|
||||
ui: {
|
||||
button: {
|
||||
fontSize: 'var(--text-ui-label)',
|
||||
lineHeight: 'var(--ui-button-line-height)',
|
||||
letterSpacing: 'var(--ui-button-letter-spacing)',
|
||||
fontWeight: 'var(--ui-button-font-weight)',
|
||||
},
|
||||
buttonSmall: {
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: 'var(--ui-button-small-line-height)',
|
||||
letterSpacing: 'var(--ui-button-small-letter-spacing)',
|
||||
fontWeight: 'var(--ui-button-small-font-weight)',
|
||||
},
|
||||
buttonLarge: {
|
||||
fontSize: 'var(--text-ui-label)',
|
||||
lineHeight: 'var(--ui-button-large-line-height)',
|
||||
letterSpacing: 'var(--ui-button-large-letter-spacing)',
|
||||
fontWeight: 'var(--ui-button-large-font-weight)',
|
||||
},
|
||||
label: {
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: 'var(--ui-label-line-height)',
|
||||
letterSpacing: 'var(--ui-label-letter-spacing)',
|
||||
fontWeight: 'var(--ui-label-font-weight)',
|
||||
},
|
||||
caption: {
|
||||
fontSize: 'var(--text-micro)',
|
||||
lineHeight: 'var(--ui-caption-line-height)',
|
||||
letterSpacing: 'var(--ui-caption-letter-spacing)',
|
||||
fontWeight: 'var(--ui-caption-font-weight)',
|
||||
},
|
||||
badge: {
|
||||
fontSize: 'var(--text-micro)',
|
||||
lineHeight: 'var(--ui-badge-line-height)',
|
||||
letterSpacing: 'var(--ui-badge-letter-spacing)',
|
||||
fontWeight: 'var(--ui-badge-font-weight)',
|
||||
},
|
||||
tooltip: {
|
||||
fontSize: 'var(--text-micro)',
|
||||
lineHeight: 'var(--ui-tooltip-line-height)',
|
||||
letterSpacing: 'var(--ui-tooltip-letter-spacing)',
|
||||
fontWeight: 'var(--ui-tooltip-font-weight)',
|
||||
},
|
||||
input: {
|
||||
fontSize: 'var(--text-ui-label)',
|
||||
lineHeight: 'var(--ui-input-line-height)',
|
||||
letterSpacing: 'var(--ui-input-letter-spacing)',
|
||||
fontWeight: 'var(--ui-input-font-weight)',
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: 'var(--ui-helper-text-line-height)',
|
||||
letterSpacing: 'var(--ui-helper-text-letter-spacing)',
|
||||
fontWeight: 'var(--ui-helper-text-font-weight)',
|
||||
},
|
||||
},
|
||||
|
||||
code: {
|
||||
inline: {
|
||||
fontSize: 'var(--text-code)',
|
||||
lineHeight: 'var(--code-inline-line-height)',
|
||||
letterSpacing: 'var(--code-inline-letter-spacing)',
|
||||
fontWeight: 'var(--code-inline-font-weight)',
|
||||
},
|
||||
block: {
|
||||
fontSize: 'var(--text-code)',
|
||||
lineHeight: 'var(--code-block-line-height)',
|
||||
letterSpacing: 'var(--code-block-letter-spacing)',
|
||||
fontWeight: 'var(--code-block-font-weight)',
|
||||
},
|
||||
lineNumbers: {
|
||||
fontSize: 'var(--text-micro)',
|
||||
lineHeight: 'var(--code-line-numbers-line-height)',
|
||||
letterSpacing: 'var(--code-line-numbers-letter-spacing)',
|
||||
fontWeight: 'var(--code-line-numbers-font-weight)',
|
||||
},
|
||||
},
|
||||
|
||||
markdown: {
|
||||
body: {
|
||||
fontSize: 'var(--text-markdown)',
|
||||
lineHeight: 'var(--markdown-body-line-height)',
|
||||
letterSpacing: 'var(--markdown-body-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-body-font-weight)',
|
||||
},
|
||||
bodySmall: {
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: 'var(--markdown-body-small-line-height)',
|
||||
letterSpacing: 'var(--markdown-body-small-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-body-small-font-weight)',
|
||||
},
|
||||
bodyLarge: {
|
||||
fontSize: 'var(--text-markdown)',
|
||||
lineHeight: 'var(--markdown-body-large-line-height)',
|
||||
letterSpacing: 'var(--markdown-body-large-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-body-large-font-weight)',
|
||||
},
|
||||
blockquote: {
|
||||
fontSize: 'var(--text-markdown)',
|
||||
lineHeight: 'var(--markdown-blockquote-line-height)',
|
||||
letterSpacing: 'var(--markdown-blockquote-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-blockquote-font-weight)',
|
||||
},
|
||||
list: {
|
||||
fontSize: 'var(--text-markdown)',
|
||||
lineHeight: 'var(--markdown-list-line-height)',
|
||||
letterSpacing: 'var(--markdown-list-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-list-font-weight)',
|
||||
},
|
||||
link: {
|
||||
fontSize: 'var(--text-markdown)',
|
||||
lineHeight: 'var(--markdown-link-line-height)',
|
||||
letterSpacing: 'var(--markdown-link-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-link-font-weight)',
|
||||
},
|
||||
code: {
|
||||
fontSize: 'var(--text-code)',
|
||||
lineHeight: 'var(--markdown-code-line-height)',
|
||||
letterSpacing: 'var(--markdown-code-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-code-font-weight)',
|
||||
},
|
||||
codeBlock: {
|
||||
fontSize: 'var(--text-code)',
|
||||
lineHeight: 'var(--markdown-code-block-line-height)',
|
||||
letterSpacing: 'var(--markdown-code-block-letter-spacing)',
|
||||
fontWeight: 'var(--markdown-code-block-font-weight)',
|
||||
},
|
||||
},
|
||||
|
||||
tool: {
|
||||
|
||||
collapsed: {
|
||||
fontSize: 'var(--text-code)',
|
||||
lineHeight: 'var(--code-block-line-height)',
|
||||
letterSpacing: 'var(--code-block-letter-spacing)',
|
||||
fontWeight: 'var(--code-block-font-weight)',
|
||||
},
|
||||
|
||||
popup: {
|
||||
fontSize: 'var(--text-code)',
|
||||
lineHeight: 'var(--code-block-line-height)',
|
||||
letterSpacing: 'var(--code-block-letter-spacing)',
|
||||
fontWeight: 'var(--code-block-font-weight)',
|
||||
},
|
||||
|
||||
inline: {
|
||||
fontSize: 'var(--text-code)',
|
||||
lineHeight: 'var(--code-inline-line-height)',
|
||||
letterSpacing: 'var(--code-inline-letter-spacing)',
|
||||
fontWeight: 'var(--code-inline-font-weight)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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: {
|
||||
collapsed: '0.375rem',
|
||||
popup: '0.75rem',
|
||||
popupContainer: '1rem',
|
||||
},
|
||||
|
||||
backgroundOpacity: {
|
||||
muted: '30',
|
||||
mutedAlt: '50',
|
||||
},
|
||||
|
||||
getCollapsedStyles: () => ({
|
||||
...typography.tool.collapsed,
|
||||
background: 'transparent !important',
|
||||
margin: 0,
|
||||
padding: toolDisplayStyles.padding.collapsed,
|
||||
borderRadius: 0,
|
||||
}),
|
||||
|
||||
getPopupStyles: () => ({
|
||||
...typography.tool.popup,
|
||||
background: 'transparent !important',
|
||||
margin: 0,
|
||||
padding: toolDisplayStyles.padding.popup,
|
||||
borderRadius: '0.75rem',
|
||||
}),
|
||||
|
||||
getPopupContainerStyles: () => ({
|
||||
...typography.tool.popup,
|
||||
background: 'transparent !important',
|
||||
margin: 0,
|
||||
padding: toolDisplayStyles.padding.popupContainer,
|
||||
borderRadius: '0.5rem',
|
||||
overflowX: 'auto' as const,
|
||||
}),
|
||||
|
||||
getInlineStyles: () => ({
|
||||
...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',
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography';
|
||||
|
||||
let started = false;
|
||||
|
||||
const applySemanticTypography = (): void => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
Object.entries(SEMANTIC_TYPOGRAPHY).forEach(([key, value]) => {
|
||||
const cssVarName = `--text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.setProperty(cssVarName, value);
|
||||
});
|
||||
};
|
||||
|
||||
export const startTypographyWatcher = (): void => {
|
||||
if (started || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
|
||||
applySemanticTypography();
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const truncatePathMiddle = (
|
||||
value: string,
|
||||
options?: { maxLength?: number }
|
||||
): string => {
|
||||
const source = value ?? "";
|
||||
const maxLength = Math.max(16, options?.maxLength ?? 45);
|
||||
if (source.length <= maxLength) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const segments = source.split('/');
|
||||
if (segments.length <= 1) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const fileName = segments.pop() ?? '';
|
||||
if (!fileName) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const prefixBudget = Math.max(0, maxLength - (fileName.length + 2));
|
||||
if (prefixBudget <= 0) {
|
||||
return `…/${fileName}`;
|
||||
}
|
||||
|
||||
let prefix = '';
|
||||
for (const segment of segments) {
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
const candidate = prefix ? `${prefix}/${segment}` : segment;
|
||||
if (candidate.length > prefixBudget) {
|
||||
break;
|
||||
}
|
||||
prefix = candidate;
|
||||
}
|
||||
|
||||
if (!prefix) {
|
||||
const first = segments[0] ?? '';
|
||||
prefix = first ? first.slice(0, prefixBudget) : '';
|
||||
}
|
||||
|
||||
return prefix ? `${prefix}…/${fileName}` : `…/${fileName}`;
|
||||
};
|
||||
|
||||
const normalizePath = (value: string) => {
|
||||
if (!value) return "";
|
||||
if (value === "/") return "/";
|
||||
return value.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
export function formatPathForDisplay(path: string | null | undefined, homeDirectory?: string | null): string {
|
||||
if (!path) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (normalizedPath === "/") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const normalizedHome = homeDirectory ? normalizePath(homeDirectory) : undefined;
|
||||
|
||||
if (normalizedHome && normalizedHome !== "/") {
|
||||
if (normalizedPath === normalizedHome) {
|
||||
return "~";
|
||||
}
|
||||
if (normalizedPath.startsWith(`${normalizedHome}/`)) {
|
||||
const relative = normalizedPath.slice(normalizedHome.length + 1);
|
||||
return relative ? `~/${relative}` : "~";
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
export function formatDirectoryName(path: string | null | undefined, homeDirectory?: string | null): string {
|
||||
if (!path) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (!normalizedPath || normalizedPath === "/") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const normalizedHome = homeDirectory ? normalizePath(homeDirectory) : undefined;
|
||||
if (normalizedHome && normalizedHome !== "/" && normalizedPath === normalizedHome) {
|
||||
return "~";
|
||||
}
|
||||
|
||||
const segments = normalizedPath.split("/");
|
||||
const name = segments.pop() || normalizedPath;
|
||||
return name || "/";
|
||||
}
|
||||
Reference in New Issue
Block a user