Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@start/desktop",
"private": true,
"productName": "Start",
"version": "0.1.0-alpha.34",
"version": "0.1.0-alpha.35",
"license": "MIT",
"description": "your coding assistant",
"type": "module",
Expand Down
221 changes: 151 additions & 70 deletions packages/desktop/src/main/chat.ts

Large diffs are not rendered by default.

35 changes: 29 additions & 6 deletions packages/desktop/src/main/git.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile, spawn } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { resolveInside } from '@main/utils/workspace';
Expand Down Expand Up @@ -47,6 +47,7 @@ type UntrackedFileData = {
const maxPatchFiles = 2000;
const maxUntrackedFiles = 64;
const gitShowTimeoutMs = 4000;
const gitWorktreeAddTimeoutMs = 60_000;
const gitWorktreeTimeoutMs = 10_000;
const maxPatchLines = 200_000;
const gitMaxBuffer = 32 * 1024 * 1024;
Expand Down Expand Up @@ -483,22 +484,35 @@ export const listWorktrees = async (cwd: string): Promise<GitWorktree[]> => {
}
};

export const gitMainWorktree = async (cwd: string): Promise<string> => {
const main = (await listWorktrees(cwd)).find((tree) => tree.isMain);
return main?.path ?? gitTopLevel(cwd);
};

export const addWorktree = async (
cwd: string,
worktreePath: string,
options: { branch?: string; base?: string } = {}
): Promise<GitWorktree | undefined> => {
): Promise<GitWorktree> => {
const branchExisted = options.branch
? Boolean(
(await git(cwd, ['for-each-ref', '--count=1', '--format=%(refname)', `refs/heads/${options.branch}`])).trim()
)
: false;
try {
const args = ['worktree', 'add', '--quiet'];
if (options.branch) args.push('-b', options.branch);
args.push(worktreePath);
if (options.base) args.push(options.base);

await git(cwd, args, gitWorktreeTimeoutMs);
await git(cwd, args, gitWorktreeAddTimeoutMs);
const target = path.resolve(worktreePath);
return (await listWorktrees(cwd)).find((tree) => path.resolve(tree.path) === target);
} catch {
return;
const worktree = (await listWorktrees(cwd)).find((tree) => path.resolve(tree.path) === target);
if (!worktree) throw new Error(`Git created ${worktreePath}, but it could not be verified.`);
return worktree;
} catch (error) {
await discardWorktree(cwd, worktreePath, branchExisted ? '' : (options.branch ?? ''));
throw error;
}
};

Expand All @@ -519,3 +533,12 @@ export const removeWorktree = async (
return false;
}
};

export const discardWorktree = async (cwd: string, worktreePath: string, branch: string): Promise<void> => {
const removed = await removeWorktree(cwd, worktreePath, { force: true });
if (!removed) {
await rm(worktreePath, { recursive: true, force: true }).catch(() => {});
await git(cwd, ['worktree', 'prune'], gitWorktreeTimeoutMs).catch(() => {});
}
if (branch) await git(cwd, ['branch', '-D', branch], gitWorktreeTimeoutMs).catch(() => {});
Comment thread
sasicodes marked this conversation as resolved.
};
2 changes: 2 additions & 0 deletions packages/desktop/src/main/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const providerAuthSlots = (provider: string): string[] => {
return [provider];
};

export const subscriptionProviderId = (provider: string) => (provider === 'openai' ? 'openai-codex' : provider);

export const isProviderModel = (model: { provider: string; id: string; name?: string }, provider: ProviderKey) => {
const haystack = `${model.provider} ${model.id} ${model.name ?? ''}`.toLowerCase();
if (provider === 'openai') {
Expand Down
19 changes: 18 additions & 1 deletion packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
} from '@main/settings';
import type { ChatStatus } from '@main/types';
import { checkForUpdatesNow, registerUpdateIpc, startAutoUpdateChecks, stopAutoUpdateChecks } from '@main/updates';
import { ProviderUsageService } from '@main/usage/index';
import { logger } from '@main/utils/logger';
import { setStayAwake, shouldStayAwake } from '@main/utils/power';
import { resolveInside } from '@main/utils/workspace';
Expand Down Expand Up @@ -102,6 +103,7 @@ let quitCleanupStarted = false;
let appQuitConfirmationOpen = false;
let appSettings: AppSettings | null = null;
let stopResourceRefresh: (() => void) | null = null;
let providerUsage: ProviderUsageService | null = null;
let stopMainWindowChanged: (() => void) | null = null;

const mobileResult = (
Expand Down Expand Up @@ -328,9 +330,11 @@ const registerComposerShortcut = (accelerator: string) => {
const menuActions = () => ({
onShowSettings: showSettings,
onShowShortcuts: showShortcuts,
onQuickAccess: () => toggleQuickAccess('menu'),
onNewSession: () => startNewSession('menu'),
onCheckForUpdates: () => checkForUpdatesNow(),
onQuickAccess: () => toggleQuickAccess('menu'),
onShowProviders: () => showSettings('providers'),
providerUsage: providerUsage?.getUsage() ?? null,
recentSessions: chat.getStatusItemRecentSessions(),
onOpenRecentSession: (sessionId: string) => openRecentSession(sessionId),
composerShortcut: appSettings?.composerShortcut ?? defaultAppSettings.composerShortcut
Expand Down Expand Up @@ -430,6 +434,15 @@ if (!singleInstanceLock) {
registerComposerShortcut(appSettings.composerShortcut);
activateWorkspaceAccess(chat.getWorkspaceCwd());

if (isMac) {
providerUsage = new ProviderUsageService({
onChange: () => installStatusItem(menuActions()),
prepareCredentials: () => chat.prepareProviderUsageCredentials(),
getCredential: (provider) => chat.getProviderUsageCredential(provider)
});
providerUsage.start();
}

installApplicationMenu(menuActions());
installStatusItem(menuActions());
stopWorkspaceChanged = onWorkspaceChanged((workspace) => {
Expand All @@ -439,6 +452,7 @@ if (!singleInstanceLock) {
if (!focused) return;

gitChanges.flushPendingRefreshes();
providerUsage?.refreshIfStale().catch(() => {});
chat
.refreshActiveSessionResources()
.then((refreshed) => {
Expand All @@ -460,6 +474,7 @@ if (!singleInstanceLock) {
getWorkspace(workspacePath ?? chat.getWorkspaceCwd())
);
ipcMain.handle('app:settings', () => appSettings);
ipcMain.handle('app:provider-usage', () => providerUsage?.getUsage() ?? []);
ipcMain.handle('app:mobile-relay-code', () => desktopRelay.currentCode);
ipcMain.handle('app:probe-mobile-relay', (_event, settings: { relayUrl: string; relayToken: string }) =>
probeRelay(settings.relayUrl, settings.relayToken)
Expand Down Expand Up @@ -614,6 +629,8 @@ const runQuitCleanup = async () => {
stopMainWindowChanged?.();
stopMainWindowChanged = null;
stopAutoUpdateChecks();
providerUsage?.stop();
providerUsage = null;
desktopRelay.stop();
setStayAwake(false);
await destroyBrowserSilently();
Expand Down
29 changes: 29 additions & 0 deletions packages/desktop/src/main/menu.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { appIconPath, appMenuName, isMac, isProd, trayIconPath } from '@main/application';
import type { StatusItemRecentSession } from '@main/types';
import type { ProviderUsage } from '@main/usage/types';
import type { Tray as ElectronTray, MenuItemConstructorOptions } from 'electron';
import electron from 'electron';

Expand All @@ -11,8 +12,10 @@ type MenuActions = {
onNewSession: () => void;
onQuickAccess: () => void;
onShowSettings: () => void;
onShowProviders: () => void;
onShowShortcuts: () => void;
onCheckForUpdates: () => void;
providerUsage: ProviderUsage[] | null;
recentSessions: StatusItemRecentSession[];
onOpenRecentSession: (id: string) => void;
};
Expand Down Expand Up @@ -60,11 +63,36 @@ const recentSessionItems = (
];
};

const usageProviders = [
{ id: 'openai', name: 'OpenAI' },
{ id: 'anthropic', name: 'Anthropic' }
] as const;

const usageSublabel = (usage: ProviderUsage | undefined) => {
if (!usage) return 'Unavailable';
return `${usage.remainingPercent}% remaining`;
};

const providerUsageItems = (usage: ProviderUsage[] | null, onShowProviders: () => void): MenuItemOptions[] => {
if (!usage) return [];
return [
{ type: 'separator' },
{ label: 'Usage', enabled: false },
...usageProviders.map((provider) => ({
label: provider.name,
click: onShowProviders,
sublabel: usageSublabel(usage.find((item) => item.id === provider.id))
}))
];
};

export const installStatusItem = ({
onNewSession,
onQuickAccess,
providerUsage,
onShowSettings,
recentSessions,
onShowProviders,
composerShortcut,
onOpenRecentSession
}: MenuActions) => {
Expand All @@ -86,6 +114,7 @@ export const installStatusItem = ({
click: onQuickAccess
},
...recentSessionItems(recentSessions, onOpenRecentSession),
...providerUsageItems(providerUsage, onShowProviders),
{ type: 'separator' },
{
label: 'Settings',
Expand Down
16 changes: 10 additions & 6 deletions packages/desktop/src/main/providers/tools/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,16 @@ interface CreateArgs {
export const runCreateSession = async (controller: SessionController, { prompt, environment }: CreateArgs) => {
if (!prompt.trim()) return toolResult('Provide a non-empty prompt.', null);
const env: SessionEnvironment = environment ?? { type: 'local' };
const session = await controller.create({ prompt, environment: env });
const message =
env.type === 'worktree' && !session.isolated
? `Could not isolate a worktree (not a git repository or git failed). Started a local session ${session.id} at ${session.workspacePath} instead.`
: `Created ${session.isolated ? 'worktree' : 'local'} session ${session.id} at ${session.workspacePath}`;
return toolResult(message, null);
try {
const session = await controller.create({ prompt, environment: env });
return toolResult(
`Created ${session.isolated ? 'worktree' : 'local'} session ${session.id} at ${session.workspacePath}`,
null
);
} catch (error) {
const reason = error instanceof Error ? error.message : 'The session could not be created.';
return toolResult(`Could not create ${env.type} session: ${reason}`, null);
}
};

export const runListSessions = (
Expand Down
3 changes: 2 additions & 1 deletion packages/desktop/src/main/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,10 @@ export type ScopedChatEvent<T> = {
export type WorkspaceFolder = {
name: string;
path: string;
active: boolean;
modified: number;
status?: AgentTabStatus;
sessionCount: number;
status?: AgentTabStatus;
noticeKind?: SessionNoticeKind;
};

Expand Down
84 changes: 84 additions & 0 deletions packages/desktop/src/main/usage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { ProviderKey } from '@main/types';
import { fetchProviderUsage } from '@main/usage/providers';
import type { ProviderUsage, ProviderUsageCredential } from '@main/usage/types';
import { logger } from '@main/utils/logger';

const refreshIntervalMs = 5 * 60 * 1000;
const providers: ProviderKey[] = ['openai', 'anthropic'];

interface ProviderUsageServiceOptions {
onChange: () => void;
prepareCredentials: () => void;
getCredential: (provider: ProviderKey) => Promise<ProviderUsageCredential | null>;
}

export class ProviderUsageService {
private lastUsage = '[]';
private lastRefreshAt = 0;
private refreshPromise: Promise<void> | null = null;
private usage = new Map<ProviderKey, ProviderUsage>();
private timer: ReturnType<typeof setInterval> | null = null;

constructor(private readonly options: ProviderUsageServiceOptions) {}

start(): void {
if (this.timer) return;
this.refresh().catch(() => {});
this.timer = setInterval(() => this.refresh().catch(() => {}), refreshIntervalMs);
}

stop(): void {
if (!this.timer) return;
clearInterval(this.timer);
this.timer = null;
}

refresh(): Promise<void> {
this.refreshPromise ??= this.refreshProviders().finally(() => {
this.refreshPromise = null;
});
return this.refreshPromise;
}

async refreshIfStale(): Promise<void> {
if (Date.now() - this.lastRefreshAt < refreshIntervalMs) return;
await this.refresh();
}

getUsage(): ProviderUsage[] {
return providers.flatMap((provider) => {
const usage = this.usage.get(provider);
return usage ? [usage] : [];
});
}

private async refreshProviders(): Promise<void> {
this.lastRefreshAt = Date.now();
this.options.prepareCredentials();
await Promise.all(
providers.map(async (provider) => {
try {
const credential = await this.options.getCredential(provider);
if (!credential) {
this.usage.delete(provider);
return;
}

this.usage.set(provider, await fetchProviderUsage(provider, credential));
} catch (error) {
this.usage.delete(provider);
logger.error(`${provider} usage`, error);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
})
);

this.emitChange();
}

private emitChange(): void {
const serialized = JSON.stringify(this.getUsage());
if (serialized === this.lastUsage) return;
this.lastUsage = serialized;
this.options.onChange();
}
}
Loading