Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
+37
View File
@@ -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;
}
+396
View File
@@ -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;
+59
View File
@@ -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;
}
};
+352
View File
@@ -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',
},
};
+62
View File
@@ -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);
}
+69
View File
@@ -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,
};
}
+488
View File
@@ -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 */ }
});
}
+276
View File
@@ -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;
}
};
+211
View File
@@ -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 });
}
};
+38
View File
@@ -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';
+167
View File
@@ -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,
};
}
+237
View File
@@ -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);
}
+515
View File
@@ -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();
}
+110
View File
@@ -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);
}
};
+90
View File
@@ -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,
};
};
+16
View File
@@ -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,
};
};
+270
View File
@@ -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);
}
};
+56
View File
@@ -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());
},
};
+233
View File
@@ -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');
}
}
+91
View File
@@ -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,
};
}
+758
View File
@@ -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'
}
}
};
+21
View File
@@ -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;
}
+330
View File
@@ -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);
}
+322
View File
@@ -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',
};
+23
View File
@@ -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();
};
+102
View File
@@ -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 || "/";
}