diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6529d72..82a4bf25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,9 @@ jobs: artifact: canvastty-macos runs-on: ${{ matrix.runner }} steps: + - name: Note plugin showcase sign-in availability + if: ${{ vars.CANVASTTY_GITHUB_CLIENT_ID == '' }} + run: echo "::warning::CANVASTTY_GITHUB_CLIENT_ID is not set. The plugin showcase still works anonymously; GitHub sign-in is unavailable in this build." - name: Check out repository uses: actions/checkout@v7 - name: Set up Node.js diff --git a/.gitignore b/.gitignore index 410ebb1c..e339edb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Dependencies and generated application output -node_modules/ +node_modules bun.lock out/ dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d987df..078880c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ - Limited WebGL to the focused terminal card: one context at a time, disposed when focus leaves, with a fallback to the DOM renderer when the context is lost. Palette and transparency rendering are unchanged. - Added a self-update section in Settings → Updates with the honest states: idle, checking, update available with the version, downloading with the percent when known, ready to install, and unavailable for dev, offline, or error. Downloading and installing are explicit user actions, install-and-restart is offered only once the update is downloaded, and in development the row reports unavailable instead of throwing. - Hardened the repository secret audit to ignore key prefixes embedded inside identifiers, so names such as `disk-…` or `task-…` no longer produce false positives while real keys still match. +- Added Cursor, MiniMax Code, Devin and Antigravity agents (PR #63). +- Added provider API keys, API profiles and agent-to-agent delegation through the orchestration MCP (PR #64). +- The plugin showcase works without a GitHub account; signing in is optional and only raises GitHub's limits (issue #22). The HOME clock shows the date (issue #53). +- The default session denies web permissions, a crashed renderer reloads, long agent answers keep their turn, and the secret audit also checks the built bundle (from PRs #35 and #51). ## 1.5.2 - Fixed terminal history jumping to the beginning when Codex clears and redraws its history after a card resize. Readers retain their relative scroll position, while terminals at the bottom continue following new output. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index c87b04fb..2ed4b884 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -23,6 +23,10 @@ - WebGL ограничен сфокусированной карточкой терминала: один контекст одновременно, освобождается при уходе фокуса, при потере контекста используется DOM-renderer. Отрисовка палитры и прозрачности не изменилась. - В «Настройках → Обновления» появилась строка самообновления с честными состояниями: простой, проверка, доступно обновление (с версией), загрузка (с процентом, когда он известен), готово к установке и недоступно (dev, offline или ошибка). Загрузка и установка — явные действия пользователя, установка с перезапуском предлагается только после загрузки обновления, а в режиме разработки строка сообщает о недоступности и не выбрасывает исключение. - Аудит секретов в репозитории больше не срабатывает на префиксы ключей внутри идентификаторов, поэтому имена вида `disk-…` и `task-…` не дают ложных срабатываний, а настоящие ключи по-прежнему находятся. +- Добавлены агенты Cursor, MiniMax Code, Devin и Antigravity (PR #63). +- Добавлены API-ключи провайдеров, API-профили и делегирование между агентами через MCP оркестрации (PR #64). +- Витрина плагинов работает без аккаунта GitHub; вход необязателен и только повышает лимиты GitHub (issue #22). Часы на HOME показывают дату (issue #53). +- Основная сессия отклоняет веб-разрешения, упавший интерфейс перезагружается, длинные ответы агентов сохраняют свой ход, а проверка секретов проверяет и собранный бандл (из PR #35 и #51). ## 1.5.2 - Исправлен прыжок терминала в начало истории, когда Codex очищает и заново рисует её после изменения размера карточки. При чтении сохраняется относительная позиция прокрутки, а терминал в конце истории продолжает следить за новым выводом. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 08f0724e..b92a94a2 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -23,6 +23,10 @@ - WebGL 仅用于聚焦的终端卡片:同一时间只有一个 context,焦点离开时释放;context 丢失时回退到 DOM renderer。调色板与透明度渲染保持不变。 - Settings → Updates 新增一行自更新,状态如实呈现:idle、checking、update available(含版本号)、downloading(已知时显示百分比)、ready to install 以及 unavailable(dev、offline 或 error)。下载与安装都是显式操作,只有在更新下载完成后才提供 install-and-restart;开发模式下该行报告 unavailable 而不会抛错。 - 仓库密钥审计不再把标识符内部的密钥前缀当作命中,因此 `disk-…`、`task-…` 这类名称不再产生误报,而真实密钥仍会被检出。 +- 新增 Cursor、MiniMax Code、Devin 和 Antigravity 智能体(PR #63)。 +- 新增提供商 API 密钥、API 配置文件,以及通过编排 MCP 在智能体之间委派任务(PR #64)。 +- 插件展示页无需 GitHub 账号即可使用;登录是可选的,只会提高 GitHub 限额(issue #22)。HOME 时钟显示日期(issue #53)。 +- 默认会话拒绝网页权限,渲染进程崩溃后会重新加载,较长的智能体回答会保留其轮次,密钥审计也会检查构建产物(来自 PR #35 和 #51)。 ## 1.5.2 - 修复调整卡片大小后,Codex 清空并重新绘制历史时终端跳到历史开头的问题。阅读时保留相对滚动位置,位于底部的终端继续跟随新输出。 diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index e31d62ea..48266ccd 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -25,7 +25,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - Click focus has three explicit modes: Off, Single click, and Double click. It is off by default. Selection and its visible outline still work when camera focus is off; a double-click mode never jumps the camera on the first click. Window stacking is independent: an ordinary primary click always raises the pressed terminal, plugin, Browser, or note card. - Selection is currently exclusive across terminals and the built-in Browser: pressing on empty canvas clears it. Input focus is tracked independently so future multi-selection does not have to redefine wheel ownership. - Hover focus is an off-by-default input-focus mode. After a configurable delay (slow `500ms`, normal `250ms`, fast `80ms`), an input-bearing widget under the pointer receives logical focus without changing selection; terminal and native Browser keyboard input follows that focus. Pointer leave only cancels a pending transfer; assigned focus remains until another focusable widget is clicked/hovered or the user clicks outside every widget. Focusable surfaces are terminals, the native Browser, plugin iframe/canvas surfaces, and actually scrollable HOME lists; decorative and action-only tiles do not take focus. -- Go to HOME and Rename window are separate user-remappable Controls rows with their own short descriptions; there is no duplicate standalone shortcut list. Defaults are `Home` for focusing the Home zone and `F2` for renaming the selected terminal window. Either action also accepts Mouse3/Mouse4/Mouse5 with optional modifiers. Rename is an inline header edit and does not recreate the PTY. A compact passive canvas hint reflects the persisted bindings immediately, can be hidden, and can be placed in any corner from Appearance. +- Go to HOME, Toggle terminal fullscreen, and Rename window are separate user-remappable Controls rows with their own short descriptions. Defaults are `Home`, `Super+F` (`Command+F` on macOS), and `F2`. These actions also accept Mouse3/Mouse4/Mouse5 with optional modifiers. Rename is an inline header edit and does not recreate the PTY. Escape remains terminal input rather than closing fullscreen. A compact canvas hint reflects the persisted bindings immediately, can be hidden, and can be placed in any corner from Appearance. Controls and an expandable section in the hint share a reference of fixed shortcuts, including their terminal/menu context. - Snapping is enabled by default and can be disabled without changing existing window bounds. Edge panning remains off by default and exposes slow/normal/fast speed. Wheel direction is configurable separately for terminal scrolling and canvas navigation; canvas inversion applies to both pan axes and historical wheel zoom. Fresh profiles pan on ordinary scroll, while migrated profiles retain ordinary wheel zoom, direction, and sensitivity. ## Visual system diff --git a/docs/fullscreen-mode.md b/docs/fullscreen-mode.md new file mode 100644 index 00000000..c91aef97 --- /dev/null +++ b/docs/fullscreen-mode.md @@ -0,0 +1,44 @@ +# Fullscreen Mode for Terminal Sessions + +CanvasTTY supports fullscreen mode for terminal sessions, allowing you to expand any session to fill the entire viewport for focused work. + +## How to Use + +### Enter Fullscreen + +- **Header button:** Click the maximize icon (⛶) in the terminal card header. +- **Keyboard:** Select a terminal and press `Super+F` (`Command+F` on macOS). Change the binding in Settings → Controls → Terminal fullscreen. + +### Exit Fullscreen + +- **Close button:** Click the X button in the top-right corner of the fullscreen session. +- **Header button:** Click the restore icon (❐) in the terminal card header. +- **Keyboard:** Press the same fullscreen shortcut again. `Escape` remains available to the terminal application and never exits fullscreen. + +## Behavior + +- **Camera preserved:** When you exit fullscreen, the canvas camera (pan/zoom) stays where you left it. If you navigated during fullscreen, the camera won't snap back. +- **Single session only:** Only one session can be fullscreen at a time. Opening fullscreen for another session automatically closes the previous one. +- **No bounds persistence:** Fullscreen mode is CSS-only — it does not modify the session's saved position or size. If the app crashes during fullscreen, the session restores at its original layout on restart. +- **Header hidden:** The terminal card header and resize handles are hidden in fullscreen, maximizing the terminal surface area. + +## Architecture + +Fullscreen is implemented as a **CSS overlay layer** separate from the canvas camera transform: + +1. `App.tsx` tracks `fullscreenSessionId` state and passes it down to `WorkspaceCanvas`. +2. `WorkspaceCanvas` splits rendering into two groups: + - **Normal layer** (`workspace__windows`): all sessions *except* the fullscreen one. + - **Fullscreen layer** (`workspace__fullscreen-layer`): only the fullscreen session, rendered outside the camera transform with `position: absolute; inset: 0`. +3. `TerminalCard` receives a `fullscreen` prop and applies the `terminal-card--fullscreen` CSS class, which overrides dimensions, removes borders/shadows, and hides the header. + +This design avoids coordinate-system conflicts between the canvas pan/zoom transform and the fullscreen overlay. + +## Internationalization + +Fullscreen UI strings are localized in `src/renderer/src/lib/i18n.ts`: + +| Key | English | Russian | +|------------------|----------------------|-------------------------------| +| `enterFullscreen`| Enter fullscreen | Развернуть на весь экран | +| `exitFullscreen` | Exit fullscreen | Свернуть из полноэкранного | diff --git a/docs/plugins.md b/docs/plugins.md index bc38739a..6be85806 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -228,7 +228,7 @@ Context updates include the active CanvasTTY locale and palette. Plugins own the The current installer intentionally rejects private repositories, GitHub `/tree/branch/subdirectory` links, and repositories that require a build step. Publish a ready-to-run static package at the repository root. -The optional showcase sign-in uses GitHub's OAuth device flow. Build maintainers can [register an OAuth App and enable Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app), then store its public client ID in the `CANVASTTY_GITHUB_CLIENT_ID` GitHub Actions repository variable. Official builds bake in that value when configured; local builds can use `GITHUB_OAUTH_CLIENT_ID` or `CANVASTTY_GITHUB_CLIENT_ID`, and either variable can also override the bundled value at runtime. No client secret is shipped or required. Sign-in opens GitHub in CanvasTTY's built-in Browser by default and offers the system browser as an explicit fallback. Without a client ID the UI reports that OAuth is unavailable, while direct repository inspection and installation continue to work. Signing out removes the encrypted local session; revoke the OAuth grant separately under [GitHub application settings](https://github.com/settings/applications) when needed. +Browsing and searching the showcase work without an account through GitHub's public search API. Signing in is optional and only raises GitHub's search limits; when the anonymous limit is reached, CanvasTTY shows when to try again. The optional showcase sign-in uses GitHub's OAuth device flow. Build maintainers can [register an OAuth App and enable Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app), then store its public client ID in the `CANVASTTY_GITHUB_CLIENT_ID` GitHub Actions repository variable. Official builds bake in that value when configured; local builds can use `GITHUB_OAUTH_CLIENT_ID` or `CANVASTTY_GITHUB_CLIENT_ID`, and either variable can also override the bundled value at runtime. No client secret is shipped or required. Sign-in opens GitHub in CanvasTTY's built-in Browser by default and offers the system browser as an explicit fallback. Without a client ID the UI reports that OAuth is unavailable, while direct repository inspection and installation continue to work. Signing out removes the encrypted local session; revoke the OAuth grant separately under [GitHub application settings](https://github.com/settings/applications) when needed. ## Author checklist diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 4fafa25b..cee62344 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -209,7 +209,7 @@ Context сообщает текущие locale и palette CanvasTTY. Локал Установщик намеренно не принимает приватные репозитории, ссылки GitHub вида `/tree/branch/subdirectory` и репозитории, которым нужен build. Публикуйте готовый пакет в корне. -Опциональный вход в витрину использует GitHub OAuth Device Flow. Сопровождающий сборку может [зарегистрировать OAuth App и включить Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app), затем сохранить его публичный client ID в repository variable GitHub Actions `CANVASTTY_GITHUB_CLIENT_ID`. Официальная сборка встраивает это значение, когда оно настроено; для локальной сборки подходят `GITHUB_OAUTH_CLIENT_ID` и `CANVASTTY_GITHUB_CLIENT_ID`, а при запуске любая из них может переопределить встроенный ID. Client secret в приложение не встраивается и не требуется. По умолчанию вход открывает GitHub во встроенном Browser CanvasTTY, а системный браузер остаётся явным fallback. Без client ID интерфейс прямо сообщает, что OAuth недоступен, но проверка и установка по прямой ссылке продолжают работать. Отключение удаляет локальную зашифрованную сессию; при необходимости отдельно отзовите доступ в [настройках приложений GitHub](https://github.com/settings/applications). +Просмотр и поиск в витрине работают без входа, через публичный поиск GitHub. Вход необязателен и только повышает лимиты поиска GitHub; когда анонимный лимит исчерпан, CanvasTTY показывает, когда можно повторить. Опциональный вход в витрину использует GitHub OAuth Device Flow. Сопровождающий сборку может [зарегистрировать OAuth App и включить Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app), затем сохранить его публичный client ID в repository variable GitHub Actions `CANVASTTY_GITHUB_CLIENT_ID`. Официальная сборка встраивает это значение, когда оно настроено; для локальной сборки подходят `GITHUB_OAUTH_CLIENT_ID` и `CANVASTTY_GITHUB_CLIENT_ID`, а при запуске любая из них может переопределить встроенный ID. Client secret в приложение не встраивается и не требуется. По умолчанию вход открывает GitHub во встроенном Browser CanvasTTY, а системный браузер остаётся явным fallback. Без client ID интерфейс прямо сообщает, что OAuth недоступен, но проверка и установка по прямой ссылке продолжают работать. Отключение удаляет локальную зашифрованную сессию; при необходимости отдельно отзовите доступ в [настройках приложений GitHub](https://github.com/settings/applications). ## Чек-лист автора diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index a09436f9..748b268f 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -207,7 +207,7 @@ if (library) { 当前安装器会刻意拒绝私有仓库、GitHub `/tree/branch/subdirectory` 链接以及需要构建步骤的仓库。请把可直接运行的静态包发布到仓库根目录。 -可选的展示页登录使用 GitHub OAuth Device Flow。构建维护者可以[注册 OAuth App 并启用 Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app),再将公开的 client ID 保存到 GitHub Actions 仓库变量 `CANVASTTY_GITHUB_CLIENT_ID`。正式构建会在该变量已配置时嵌入其值;本地构建可使用 `GITHUB_OAUTH_CLIENT_ID` 或 `CANVASTTY_GITHUB_CLIENT_ID`,运行时也可以用任一变量覆盖内置值。应用不包含也不需要 client secret。默认情况下,登录会在 CanvasTTY 内置浏览器中打开 GitHub,同时明确提供系统浏览器作为备用选项。未配置 client ID 时,界面会明确显示 OAuth 不可用,但仍可通过仓库链接检查和安装插件。退出登录只删除本机的加密会话;需要时请另行在 [GitHub 应用设置](https://github.com/settings/applications)中撤销授权。 +无需账号即可通过 GitHub 公共搜索 API 浏览和搜索展示页。登录是可选的,只会提高 GitHub 搜索限额;达到匿名限额时,CanvasTTY 会显示何时可以重试。可选的展示页登录使用 GitHub OAuth Device Flow。构建维护者可以[注册 OAuth App 并启用 Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app),再将公开的 client ID 保存到 GitHub Actions 仓库变量 `CANVASTTY_GITHUB_CLIENT_ID`。正式构建会在该变量已配置时嵌入其值;本地构建可使用 `GITHUB_OAUTH_CLIENT_ID` 或 `CANVASTTY_GITHUB_CLIENT_ID`,运行时也可以用任一变量覆盖内置值。应用不包含也不需要 client secret。默认情况下,登录会在 CanvasTTY 内置浏览器中打开 GitHub,同时明确提供系统浏览器作为备用选项。未配置 client ID 时,界面会明确显示 OAuth 不可用,但仍可通过仓库链接检查和安装插件。退出登录只删除本机的加密会话;需要时请另行在 [GitHub 应用设置](https://github.com/settings/applications)中撤销授权。 ## 作者检查清单 diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index 93d654bd..a60a655e 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -62,7 +62,7 @@ await reportLifecycle({ /** Cuts at the limit without leaving a dangling high surrogate. */ function boundedText(value, limit) { const text = value.slice(0, limit); - return /[\uD800-\uDBFF]$/.test(text) ? text.slice(0, -1) : text; + return /[\uD800-\uDBFF]$/u.test(text) ? text.slice(0, -1) : text; } function firstString(...values) { return values.find((value) => typeof value === "string" && value.length > 0) ?? null; diff --git a/src/main/index.ts b/src/main/index.ts index 64427e89..ee3eb3a3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -248,11 +248,9 @@ function shellWindowGone(window: BrowserWindow): boolean { } async function initializeServices(): Promise { - // Deny-by-default browser permissions on the default session: neither the - // shell window nor plugin windows ever request camera, microphone, location, - // notifications or device access, so nothing is granted silently. The - // browser partition keeps its own, deliberately more permissive policy in - // BrowserService; exceptions belong there, not here. + // Deny-by-default web permissions on the default session: the app window and plugin windows + // never need camera, microphone, location, notifications or device access. The Browser card uses + // its own partition with its own policy in BrowserService. session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); session.defaultSession.setPermissionCheckHandler(() => false); session.defaultSession.setDevicePermissionHandler(() => false); diff --git a/src/main/services/LimitsService.ts b/src/main/services/LimitsService.ts index aa929f43..f26ab4bf 100644 --- a/src/main/services/LimitsService.ts +++ b/src/main/services/LimitsService.ts @@ -50,6 +50,13 @@ interface PendingRequest { timer: NodeJS.Timeout; } +interface GrokCredential { + token: string; + authMode: number; + expiresAt: number; + sessionExpired: boolean; +} + export class LimitsService { private codex: CodexAppServerClient; private kimi: KimiWebUsageClient; @@ -402,10 +409,11 @@ async function readOpenCodeGoUsage(clientVersion: string): Promise { async function readGrokUsage(clientVersion: string): Promise { const configRoot = process.env.GROK_HOME || join(homedir(), ".grok"); const credentials = await readCredentialFile(join(configRoot, "auth.json"), "not-authenticated"); - const accessToken = selectGrokAccessToken(credentials); - if (!accessToken) throw new LimitsAdapterError("not-authenticated"); + const credential = selectGrokCredential(credentials); + if (!credential) throw new LimitsAdapterError("not-authenticated"); + if (credential.sessionExpired) throw new LimitsAdapterError("session-expired"); - return fetchUsageJson(GROK_BILLING_URL, accessToken, { + return fetchUsageJson(GROK_BILLING_URL, credential.token, { "x-xai-token-auth": "xai-grok-cli", "user-agent": `canvastty/${clientVersion}` }); @@ -445,17 +453,18 @@ async function readFirstCredentialFile( throw new LimitsAdapterError(missingReason); } -function selectGrokAccessToken(credentials: Record): string | null { +function selectGrokCredential(credentials: Record): GrokCredential | null { const candidates = Object.values(credentials) .filter(isRecord) .map((credential) => ({ token: cleanSecret(credential.key), authMode: credential.auth_mode === "oidc" ? 1 : 0, - expiresAt: numericValue(credential.expires_at) ?? 0 + expiresAt: numericValue(credential.expires_at) ?? 0, + sessionExpired: cleanSecret(credential.refresh_token) !== null && hasExpired(credential.expires_at) })) - .filter((candidate): candidate is { token: string; authMode: number; expiresAt: number } => candidate.token !== null) + .filter((candidate): candidate is GrokCredential => candidate.token !== null) .sort((left, right) => right.authMode - left.authMode || right.expiresAt - left.expiresAt); - return candidates[0]?.token ?? null; + return candidates[0] ?? null; } async function readCredentialFile(path: string, missingReason: LimitUnavailableReason): Promise> { @@ -1009,7 +1018,13 @@ export function normalizeKimiLimits(raw: unknown): LimitWindow[] { const payload = isRecord(raw.data) ? raw.data : raw; if (payload.kind === "error") { const message = typeof payload.message === "string" ? payload.message.toLowerCase() : ""; - throw new LimitsAdapterError(message.includes("auth") || message.includes("login") ? "not-authenticated" : "protocol-error"); + throw new LimitsAdapterError( + message.includes("auth") || message.includes("login") + ? "not-authenticated" + : message.includes("timed out") + ? "timeout" + : "protocol-error" + ); } const windows: LimitWindow[] = []; const managedSummary = payload.summary; @@ -1086,6 +1101,40 @@ export function normalizeKimiLimits(raw: unknown): LimitWindow[] { } } + const managedUsages = isRecord(payload.quota) && isRecord(payload.quota.usages) ? payload.quota.usages : null; + if (managedUsages) { + const definitions: Array<{ + key: "limit5h" | "limit7d" | "monthTotal"; + id: string; + slot: "primary" | "secondary"; + label: string; + windowMinutes: number | null; + }> = [ + { key: "limit5h", id: "kimi:managed:300", slot: "primary", label: "5h", windowMinutes: 300 }, + { key: "limit7d", id: "kimi:weekly", slot: "secondary", label: "7d", windowMinutes: 10_080 }, + { key: "monthTotal", id: "kimi:monthly", slot: "secondary", label: "monthly", windowMinutes: null } + ]; + for (const { key, id, slot, label, windowMinutes } of definitions) { + const candidate = managedUsages[key]; + if (!isRecord(candidate)) continue; + const usedRatio = numericValue(candidate.usedRatio); + const resetsAt = epochMilliseconds(candidate.resetAt); + if (usedRatio === null && resetsAt === null) continue; + windows.push({ + id, + bucketId: "kimi", + slot, + isDefaultBucket: true, + label, + usedPercent: usedRatio === null ? null : clampPercent(usedRatio * 100), + used: null, + limit: null, + windowMinutes, + resetsAt + }); + } + } + return windows.slice(0, MAX_WINDOWS); } @@ -1199,6 +1248,11 @@ function epochMilliseconds(value: unknown): number | null { return Number.isSafeInteger(Math.trunc(milliseconds)) ? Math.trunc(milliseconds) : null; } +function hasExpired(value: unknown): boolean { + const expiresAt = epochMilliseconds(value); + return expiresAt !== null && expiresAt <= Date.now(); +} + function clampPercent(value: number): number { return Math.min(100, Math.max(0, value)); } diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 763f1d46..17e63527 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -1651,9 +1651,7 @@ async function githubGraphqlSearchPage( body: JSON.stringify({ query: gql, variables }), signal }); - if (response.status === 403 || response.status === 429) { - throw new Error("GitHub search rate limit reached; try again in a minute."); - } + if (response.status === 403 || response.status === 429) throw new Error(githubRateLimitMessage(response)); if (!response.ok) { throw new Error(`GitHub plugin search failed with HTTP ${response.status}.`); } @@ -1702,9 +1700,7 @@ async function fetchGithubSearchPage(url: URL, signal: AbortSignal): Promise): string { + const reset = Number(response.headers.get("x-ratelimit-reset")); + const when = Number.isFinite(reset) && reset > 0 + ? `after ${new Date(reset * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + : "in a minute"; + return `GitHub search rate limit reached; try again ${when}. Signing in to GitHub raises the limit.`; +} + function mapSearchResults(results: GithubSearchItem[]): GithubPluginSearchResult[] { const entries: GithubPluginSearchResult[] = []; for (const item of results) { diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 366f7e6d..a75a6e3a 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -41,6 +41,7 @@ import { DEFAULT_HOME_GRID_SIZE, DEFAULT_HOME_LAYOUT, DEFAULT_RADIAL_LAUNCHER_ITEMS, + DEFAULT_SHORTCUTS, DEFAULT_UI_SCALE, HOME_GRID_MAX_COLUMNS, HOME_GRID_MAX_ROWS, @@ -96,7 +97,6 @@ const CANVAS_OVERLAY_PLACEMENTS = new Set([ ]); const MINIMAP_INTERACTION_MODES = new Set(["click", "drag"]); const SHORTCUT_MODIFIERS = new Set(["Ctrl", "Alt", "Shift", "Meta"]); -const DEFAULT_SHORTCUTS: ShortcutBindings = { home: "Home", renameWindow: "F2" }; export class SettingsStore { private readonly filePath: string; @@ -906,10 +906,12 @@ function normalizeShortcuts(candidate: unknown, fallback: ShortcutBindings): Sho : {}; const shortcuts = { home: isValidShortcut(source.home) ? source.home : fallback.home, - renameWindow: isValidShortcut(source.renameWindow) ? source.renameWindow : fallback.renameWindow + renameWindow: isValidShortcut(source.renameWindow) ? source.renameWindow : fallback.renameWindow, + toggleFullscreen: isValidShortcut(source.toggleFullscreen) ? source.toggleFullscreen : fallback.toggleFullscreen }; - if (shortcuts.home.toLowerCase() === shortcuts.renameWindow.toLowerCase()) { + const bindings = Object.values(shortcuts).map((binding) => binding.toLowerCase()); + if (new Set(bindings).size !== bindings.length) { return { ...fallback }; } return shortcuts; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 37589a88..754bc1ad 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,6 +24,7 @@ import type { ProviderId, SessionBounds, SessionSnapshot, + ShortcutAction, StickyNote, WindowState } from "../../shared/contracts"; @@ -62,6 +63,7 @@ import { } from "./lib/shortcuts"; import { homeGridPixelSize, homeLayoutFitsGrid, placeHomeWidget } from "./features/home/homeLayout"; import { boundsInsideRegion, translateBounds } from "./features/workspace/canvasRegions"; +import { DEFAULT_SESSION_SIZE, findNearHomeSessionPosition } from "./features/workspace/sessionPlacement"; interface HomeEditDraft { homeGridSize: HomeGridSize; @@ -185,6 +187,9 @@ function contrastRatio(left: number, right: number): number { export function App(): React.JSX.Element { const [settings, setSettings] = useState(FALLBACK_SETTINGS); const [sessions, setSessions] = useState([]); + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; + const pendingSessionPlacements = useRef([]); const [limits, setLimits] = useState(null); const [limitsLoadState, setLimitsLoadState] = useState("loading"); const [limitsRevision, setLimitsRevision] = useState(0); @@ -210,6 +215,7 @@ export function App(): React.JSX.Element { const [activeSessionId, setActiveSessionId] = useState(null); const [browserSelected, setBrowserSelected] = useState(false); const [renamingSessionId, setRenamingSessionId] = useState(null); + const [fullscreenSessionId, setFullscreenSessionId] = useState(null); const [pendingTerminalUrl, setPendingTerminalUrl] = useState(null); const [toast, setToast] = useState(null); const [ready, setReady] = useState(false); @@ -364,17 +370,41 @@ export function App(): React.JSX.Element { requestedCenter?: Point, role: LaunchRole = "agent" ): Promise => { + const currentSettings = settingsRef.current; const position = requestedCenter - ? centeredWindowPosition(requestedCenter, { width: 700, height: 430 }) - : nextSessionPosition(sessions.length, settings.homeGridSize); - const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role }); - setSessions((current) => upsertSnapshot(current, session)); - setActiveSessionId(session.id); - await saveSettings({ lastDirectory: cwd }); - isHomeCamera.current = false; - setCamera(focusCamera(position, session.size)); - return session; - }, [sessions.length, saveSettings, settings.homeGridSize]); + ? centeredWindowPosition(requestedCenter, DEFAULT_SESSION_SIZE) + : findNearHomeSessionPosition( + { position: { x: 0, y: 0 }, size: homeGridPixelSize(currentSettings.homeGridSize) }, + [ + ...sessionsRef.current, + ...currentSettings.pluginCanvas, + ...currentSettings.stickyNotes, + ...(currentSettings.browserCanvas ? [currentSettings.browserCanvas] : []), + ...pendingSessionPlacements.current + ], + DEFAULT_SESSION_SIZE + ); + // Reserve the slot until the async create finishes, so fast parallel launches + // cannot both choose the same free position before React renders either card. + const reservation: SessionBounds | null = requestedCenter + ? null + : { position, size: DEFAULT_SESSION_SIZE }; + if (reservation) pendingSessionPlacements.current.push(reservation); + try { + const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role }); + sessionsRef.current = upsertSnapshot(sessionsRef.current, session); + setSessions((current) => upsertSnapshot(current, session)); + setActiveSessionId(session.id); + await saveSettings({ lastDirectory: cwd }); + isHomeCamera.current = false; + setCamera(focusCamera(position, session.size)); + return session; + } finally { + if (reservation) { + pendingSessionPlacements.current = pendingSessionPlacements.current.filter((item) => item !== reservation); + } + } + }, [saveSettings]); const openTerminal = useCallback(async (position?: Point): Promise => { try { @@ -457,6 +487,21 @@ export function App(): React.JSX.Element { window.canvasTTY.terminal.setBounds(id, bounds); }, []); + const toggleSessionFullscreen = useCallback((id: string): void => { + if (fullscreenSessionId === id) { + // Exit fullscreen: only update state, no bounds persistence + // Note: Camera position is preserved intentionally (BUG 3 mitigation). + // If the user panned/zoomed during fullscreen, the camera stays where they left it. + setFullscreenSessionId(null); + } else { + // Enter fullscreen: exit current fullscreen first if switching sessions (BUG 2) + if (fullscreenSessionId !== null) { + setFullscreenSessionId(null); + } + setFullscreenSessionId(id); + } + }, [fullscreenSessionId]); + const changePluginCanvasBounds = useCallback((id: string, bounds: SessionBounds): void => { const pluginCanvas = settingsRef.current.pluginCanvas.map((instance) => instance.id === id ? { ...instance, position: bounds.position, size: bounds.size } @@ -951,7 +996,13 @@ export function App(): React.JSX.Element { }, [homeEditDraft, settings.locale, showToast]); useEffect(() => { - const performShortcut = (shortcut: "home" | "renameWindow"): void => { + const performShortcut = (shortcut: ShortcutAction): void => { + if (shortcut === "toggleFullscreen") { + if (settingsOpen || launchProvider !== null || pendingTerminalUrl !== null || homeEditDraft) return; + const id = fullscreenSessionId ?? activeSessionId; + if (id) toggleSessionFullscreen(id); + return; + } if (shortcut === "home") { goHome(); return; @@ -964,6 +1015,12 @@ export function App(): React.JSX.Element { }; const handleShortcut = (event: KeyboardEvent): void => { if (event.repeat || isShortcutCaptureTarget(event.target) || isRenameInputTarget(event.target)) return; + if (matchesShortcut(event, settings.shortcuts.toggleFullscreen)) { + event.preventDefault(); + event.stopPropagation(); + performShortcut("toggleFullscreen"); + return; + } if (matchesShortcut(event, settings.shortcuts.home)) { event.preventDefault(); event.stopPropagation(); @@ -983,7 +1040,9 @@ export function App(): React.JSX.Element { ? "home" : matchesPointerShortcut(event, settings.shortcuts.renameWindow) ? "renameWindow" - : null; + : matchesPointerShortcut(event, settings.shortcuts.toggleFullscreen) + ? "toggleFullscreen" + : null; if (!action) return; event.preventDefault(); event.stopPropagation(); @@ -996,7 +1055,7 @@ export function App(): React.JSX.Element { window.removeEventListener("keydown", handleShortcut, true); window.removeEventListener("pointerdown", handlePointerShortcut, true); }; - }, [activeSessionId, goHome, settings.locale, settings.shortcuts, showToast]); + }, [activeSessionId, fullscreenSessionId, goHome, homeEditDraft, launchProvider, pendingTerminalUrl, settings.locale, settings.shortcuts, settingsOpen, showToast, toggleSessionFullscreen]); const appearance = resolveAppearanceSettings(settings); const rootClasses = useMemo( @@ -1074,6 +1133,8 @@ export function App(): React.JSX.Element { activeSessionId={activeSessionId} browserSelected={browserSelected} renamingSessionId={renamingSessionId} + fullscreenSessionId={fullscreenSessionId} + onToggleFullscreen={toggleSessionFullscreen} onSelectSession={(id) => { setBrowserSelected(false); setActiveSessionId(id); @@ -1164,14 +1225,6 @@ export function App(): React.JSX.Element { ); } -function nextSessionPosition(index: number, homeGridSize: HomeGridSize): Point { - const homeSize = homeGridPixelSize(homeGridSize); - return { - x: homeSize.width + 160 + (index % 2) * 760, - y: Math.floor(index / 2) * 500 + 20 - }; -} - function centeredWindowPosition(point: Point, size: { width: number; height: number }): Point { return { x: point.x - size.width / 2, diff --git a/src/renderer/src/components/ShortcutReference.tsx b/src/renderer/src/components/ShortcutReference.tsx new file mode 100644 index 00000000..6ff3b257 --- /dev/null +++ b/src/renderer/src/components/ShortcutReference.tsx @@ -0,0 +1,32 @@ +import type { LocaleId } from "../../../shared/contracts"; +import { t, type TranslationKey } from "../lib/i18n"; + +export function ShortcutReference({ locale }: { locale: LocaleId }): React.JSX.Element { + const mac = window.canvasTTY.window.isMacOS; + const command = mac ? "Command" : "Ctrl"; + const shortcuts: [string, TranslationKey][] = [ + [`${command}+K`, "commandPalette"], + [`${command}+,`, "settings"], + [`${mac ? "Option" : "Alt"}+↑↓←→`, "focusWindowHint"], + ["Shift + drag", "marqueeSelectionHint"], + ["Ctrl+Shift+F", "terminalSearch"], + [mac ? "Command+C / Ctrl+C / Ctrl+Shift+C" : "Ctrl+C / Ctrl+Shift+C", "shortcutCopySelection"], + [mac ? "Command+V / Ctrl+Shift+V / Shift+Insert" : "Ctrl+Shift+V / Shift+Insert", "shortcutPaste"], + ["Shift+Enter", "shortcutLineBreak"], + ["PageUp / PageDown", "shortcutScrollPages"], + ["Ctrl+D", "shortcutRestartExited"], + ["Enter / Shift+Enter", "shortcutSearchMatches"], + ["↑↓ / Enter", "shortcutPaletteNavigation"], + ["↑↓←→ / Enter / Space", "shortcutRadialNavigation"], + ["↑↓←→", "shortcutMinimapNavigation"], + ["Enter / Escape", "shortcutRenameConfirm"], + ["Escape", "shortcutDismiss"] + ]; + return ( +
+ {shortcuts.map(([keys, label]) => ( +
{keys}
{t(locale, label)}
+ ))} +
+ ); +} diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index abfb11e2..95e86f88 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -4,7 +4,7 @@ import { ProviderIcon } from "./ProviderIcon"; import { UiIcon } from "./UiIcon"; import { t } from "../lib/i18n"; -const BUILD_CHANNEL = import.meta.env.DEV ? "DEV" : "RELEASE"; +const BUILD_CHANNEL = import.meta.env.DEV || import.meta.env.VITE_BUILD_CHANNEL === "DEV" ? "DEV" : "RELEASE"; const BUILD_LABEL = `${BUILD_CHANNEL} v${appManifest.version}`; interface TitleBarProps { diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index 36d9a202..6cb950b0 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -499,10 +499,12 @@ function isFocusableHomeWidget(widgetId: string, plugins: readonly InstalledPlug function ClockWidget({ locale, now }: { locale: LocaleId; now: Date }): React.JSX.Element { const formatLocale = locale === "ru" ? "ru-RU" : "en-GB"; const time = now.toLocaleTimeString(formatLocale, { hour: "2-digit", minute: "2-digit" }); + const date = now.toLocaleDateString(formatLocale, { weekday: "short", day: "numeric", month: "long", year: "numeric" }); return (
- + + {date}
); } @@ -583,6 +585,8 @@ function limitReasonLabel(reason: HomeLimitReason | null, locale: LocaleId): str ? "limitCliNotFound" : reason === "not-authenticated" ? "limitNotAuthenticated" + : reason === "session-expired" + ? "limitSessionExpired" : reason === "subscription-required" ? "limitSubscriptionRequired" : reason === "unsupported-protocol" diff --git a/src/renderer/src/features/notes/StickyNoteCard.tsx b/src/renderer/src/features/notes/StickyNoteCard.tsx index c84f38e1..278b067a 100644 --- a/src/renderer/src/features/notes/StickyNoteCard.tsx +++ b/src/renderer/src/features/notes/StickyNoteCard.tsx @@ -8,6 +8,7 @@ import { MAX_STICKY_NOTE_SIZE, MIN_STICKY_NOTE_SIZE } from "./stickyNoteBounds"; +import { stickyNoteWheelAttributes } from "./stickyNoteWheelAttributes"; interface StickyNoteCardProps { note: StickyNote; @@ -213,12 +214,15 @@ export function StickyNoteCard({ onBoundsChange(note.id, liveBounds.current); }; + const wheelAttributes = stickyNoteWheelAttributes(note.id); + return (