Compare commits

...
Author SHA1 Message Date
bincxz 8cbcb73b61 fix(ssh): retain vnode directory cwd fallback 2026-09-18 11:49:37 +08:00
bincxz d3eb3007bb fix(ssh): reject lsof diagnostics as terminal directories 2026-09-18 11:45:25 +08:00
陈大猫 c16f0c43d4 Merge pull request #3401 from OOKAMISAMA/main
feat(tab): Add configurable terminal tab double-click behavior
2026-09-15 22:52:12 +08:00
OOKAMI 4df94736d0 Update TopTabs.test.ts 2026-09-15 17:55:59 +08:00
OOKAMI 91c3defe66 Add configurable terminal tab double-click behavior
Introduce a new TerminalTabDoubleClickBehavior setting (duplicate | copy | disabled) with default 'duplicate'. Add model field, normalization, default, and sync key (tabDoubleClickBehavior). UI: TerminalBehaviorSettings adds a Select; AppView and TopTabs pass the setting through; TopTabItems implements createTopTabSessionDoubleClickHandler to perform duplicate/copy with a safe fallback. Add i18n strings, settings search entry, and update tests to cover behavior and serialization. Improves user control over double-click actions on session tabs.
2026-09-15 16:56:55 +08:00
陈大猫 faf74ae9be Merge pull request #3395 from binaricat/ai/issue-3394-34924104389
fix(ai): replay signed Anthropic thinking blocks in Catty follow-up turns
2026-09-15 13:47:20 +08:00
陈大猫 cbace2f2ac Merge pull request #3389 from lengyuqu/codebuddy
feat: upgrade CodeBuddy SDK integration
2026-09-15 13:46:41 +08:00
netcatty-bot a52726aef4 fix(#3394): automated AI fix 2026-09-15 03:36:18 +00:00
lengyuqu 605dc94b13 fix: clarify CodeBuddy session persistence scope 2026-09-14 17:02:06 +08:00
lengyuqu 9e74618f2e feat: upgrade CodeBuddy SDK integration 2026-09-14 16:38:33 +08:00
github-actions[bot]andgithub-actions[bot] b46910d46e chore: update star history charts [skip ci] (#3383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-14 00:11:12 +00:00
陈大猫 d8ea77c28b Merge pull request #3373 from binaricat/codex/3135-custom-provider-headers
feat(ai): configure custom provider HTTP headers securely
2026-09-13 16:37:09 +08:00
bincxz 210879b848 fix: prepare sync credentials before protected apply begins 2026-09-13 16:22:11 +08:00
bincxz db0238ce6e fix: prepare provider headers before applying sync data 2026-09-13 16:07:26 +08:00
bincxz 6f30bbefb5 Merge main into custom provider headers PR 2026-09-13 15:46:12 +08:00
陈大猫 843c81ca44 Merge pull request #3376 from binaricat/ai/issue-3375-34697848012
fix(sftp): expand tree folders in place on double click
2026-09-13 15:44:39 +08:00
陈大猫 f1e59913c2 Merge pull request #3368 from binaricat/codex/issue-3153-right-long-press
feat(terminal): add optional right-click hold menu
2026-09-13 15:44:05 +08:00
bincxz a95eb62cb1 fix(sftp): isolate tree arrow double clicks and test real events 2026-09-13 15:32:32 +08:00
netcatty-bot f24b5f4202 fix(#3375): automated AI fix 2026-09-12 14:14:36 +00:00
bincxz 26278a8694 fix(ai): reject transport-owned custom framing headers 2026-09-12 18:08:55 +08:00
bincxz e10e5ccbc2 Revert "fix: address Codex review on PR #3373"
This reverts commit dd5749460f.
2026-09-12 17:59:28 +08:00
netcatty-bot dd5749460f fix: address Codex review on PR #3373 2026-09-12 09:43:28 +00:00
bincxz 688f544034 test(ai): complete custom provider fixture 2026-09-12 17:18:37 +08:00
bincxz 214bc8e7f1 test(ai): verify custom header removal persists 2026-09-12 17:13:42 +08:00
bincxz 758029bc29 feat(ai): configure and securely store custom provider headers 2026-09-12 17:12:34 +08:00
68 changed files with 1438 additions and 181 deletions

No files matched your search

+9 -6
View File
@@ -57,7 +57,7 @@ import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
import type { SyncPayload } from '../../domain/sync';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import { prepareSyncPayloadApply, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -502,8 +502,8 @@ export function AppSideEffects() {
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: () =>
applySyncPayload(payload, {
prepareApply: () =>
prepareSyncPayloadApply(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
@@ -525,14 +525,17 @@ export function AppSideEffects() {
(payload: SyncPayload, commitReplica: () => Promise<void>) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: async () => {
prepareApply: async () => {
const portable = stripSyncPayloadEncryptedCredentials(payload);
await applySyncPayload(portable, {
const applyPreparedPayload = await prepareSyncPayloadApply(portable, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
}, { currentHosts: hosts });
await commitReplica();
return async () => {
await applyPreparedPayload();
await commitReplica();
};
},
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
+1
View File
@@ -504,6 +504,7 @@ function AppViewInner({ domains }: AppViewProps) {
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onDuplicateSession={duplicateSessionWithCurrentShell}
tabDoubleClickBehavior={terminalSettings.tabDoubleClickBehavior}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
+58 -5
View File
@@ -136,7 +136,7 @@ test('initialization applies the protected preview before persisting and enablin
return liveLocalPayload;
},
translateProtectiveBackupFailure: (message) => message,
applyPayload: async (incoming) => {
preparePayloadApply: async (incoming) => async () => {
calls.push('apply');
assert.equal(incoming.convergentSync?.schemaVersion, 2);
},
@@ -212,7 +212,7 @@ test('initialization applies a concurrent provider merge before releasing the mi
buildCurrentPayload: () => currentPayload,
buildPreApplyPayload: () => currentPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: async (incoming) => {
preparePayloadApply: async (incoming) => async () => {
assert.equal(lockHeld, true);
applied.push(incoming);
currentPayload = stripConvergentSyncEnvelope(incoming);
@@ -265,7 +265,7 @@ test('initialization rejects a stale preview before backup or apply', async () =
buildCurrentPayload: () => changedPayload,
buildPreApplyPayload: () => changedPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {
preparePayloadApply: async () => async () => {
applied = true;
},
runProtectedApply: async (options) => {
@@ -300,7 +300,7 @@ test('blocked previews cannot enter the protected initialization transaction', a
buildCurrentPayload: () => localPayload,
buildPreApplyPayload: () => localPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {},
preparePayloadApply: async () => async () => {},
runProtectedApply: async () => {
entered = true;
},
@@ -337,7 +337,7 @@ test('a locked manager cannot enter the protected initialization transaction', a
return localPayload;
},
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {
preparePayloadApply: async () => async () => {
applied = true;
},
runProtectedApply: async () => {
@@ -352,3 +352,56 @@ test('a locked manager cannot enter the protected initialization transaction', a
assert.equal(applied, false);
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
});
for (const failDuringPublish of [false, true]) {
test(`migration header failure precedes protected writes (${failDuringPublish ? 'publish' : 'initialize'})`, async () => {
const { prepareSyncPayloadApply } = await import('./syncPayload.ts');
const { readInterruptedVaultApply } = await import('./localVaultBackups.ts');
const headers = { ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] } };
const localPayload: SyncPayload = { ...payload(), ...(failDuringPublish ? {} : { settings: headers }) };
let currentPayload = localPayload;
let imports = 0;
let replicaCommits = 0;
let snapshots = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => {
assert.equal(readInterruptedVaultApply(), null);
throw new Error('keychain locked');
} },
dispatchEvent: () => true,
} });
const plan = planConvergentSyncMigration({
localPayload, localTrustedBaseline: null, providers: [], deviceId: 'device-a', now: NOW,
});
const manager = {
isUnlocked: () => true,
withConvergentSyncLock: async (task: () => Promise<void>) => task(),
saveConvergentReplica: async () => { replicaCommits++; },
saveConvergentProviderBaseline: async () => {},
syncConvergentProvidersUnderLock: async (
_incoming: SyncPayload,
apply: (incoming: SyncPayload, commit: () => Promise<void>) => Promise<void>,
) => {
await apply({ ...currentPayload, settings: headers }, async () => { replicaCommits++; });
return new Map();
},
} as unknown as CloudSyncManager;
await assert.rejects(initializePreparedConvergentMigration({
prepared: { plan, providerBaselines: [], localSnapshot: localPayload }, manager, now: NOW,
buildCurrentPayload: () => currentPayload,
buildPreApplyPayload: () => { snapshots++; return payload(); },
translateProtectiveBackupFailure: (message) => message,
preparePayloadApply: async (incoming) => {
const apply = await prepareSyncPayloadApply(incoming, { importVaultData: () => { imports++; } });
return async () => {
await apply();
currentPayload = stripConvergentSyncEnvelope(incoming);
};
},
}));
assert.equal(readInterruptedVaultApply(), null);
assert.equal(imports, failDuringPublish ? 1 : 0);
assert.equal(replicaCommits, failDuringPublish ? 1 : 0);
assert.equal(snapshots, failDuringPublish ? 1 : 0);
});
}
+5 -3
View File
@@ -137,7 +137,7 @@ export async function initializePreparedConvergentMigration(options: {
prepared: PreparedConvergentMigration;
buildCurrentPayload: () => SyncPayload | Promise<SyncPayload>;
buildPreApplyPayload: () => SyncPayload;
applyPayload: (payload: SyncPayload) => void | Promise<void>;
preparePayloadApply: (payload: SyncPayload) => Promise<() => Promise<void>>;
translateProtectiveBackupFailure: (message: string) => string;
manager?: CloudSyncManager;
now?: number;
@@ -164,8 +164,9 @@ export async function initializePreparedConvergentMigration(options: {
'Local sync data changed after the migration preview. Review the updated migration before enabling convergent sync.',
);
}
const applyPreparedPayload = await options.preparePayloadApply(prepared.plan.payload as SyncPayload);
return async () => {
await options.applyPayload(prepared.plan.payload as SyncPayload);
await applyPreparedPayload();
for (const baseline of prepared.providerBaselines) {
await manager.saveConvergentProviderBaseline(baseline);
}
@@ -195,8 +196,9 @@ export async function initializePreparedConvergentMigration(options: {
'Local sync data changed while publishing the convergent migration. Retry sync to preserve the newer local edits.',
);
}
const applyPreparedPayload = await options.preparePayloadApply(mergedPayload);
return async () => {
await options.applyPayload(mergedPayload);
await applyPreparedPayload();
await commitReplica();
};
},
+16 -2
View File
@@ -8,6 +8,7 @@ export const enAiMessages: Messages = {
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
'ai.chat.contextCompacted': 'Context compacted',
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
'ai.chat.activity.title': 'Agent activity',
'ai.chat.activity.plan': 'Plan',
@@ -53,6 +54,15 @@ export const enAiMessages: Messages = {
'ai.providers.icon.close': 'Close',
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
'ai.providers.icon.errorType': 'Please choose an image file.',
'ai.providers.headers': 'Custom HTTP Headers',
'ai.providers.headers.help': 'Optional headers for this provider. Values are hidden and encrypted when saved.',
'ai.providers.headers.name': 'Header name',
'ai.providers.headers.value': 'Header value',
'ai.providers.headers.add': 'Add header',
'ai.providers.headers.remove': 'Remove header',
'ai.providers.headers.invalid': 'Use valid, unique HTTP headers. Content-Length and Transfer-Encoding are managed automatically.',
'ai.providers.headers.loadError': 'Unable to unlock saved headers. Cancel and try again after unlocking your system keychain.',
'ai.providers.headers.saveError': 'Unable to save securely. Check that your system keychain is available and try again.',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
@@ -225,20 +235,24 @@ export const enAiMessages: Messages = {
'ai.codebuddy.envVars': 'Environment variables',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.230)',
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.258)',
'ai.codebuddy.effort': 'Reasoning Effort',
'ai.codebuddy.effort.default': 'Default',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Low for simple commands to save tokens, High/XHigh for complex diagnostics.',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Minimal/Low for simple commands to save tokens, Max/High for complex diagnostics.',
'ai.codebuddy.maxTurns': 'Max Turns',
'ai.codebuddy.maxTurns.hint': 'Limits the maximum conversation turns per request to prevent runaway loops. Leave empty for default.',
'ai.codebuddy.maxBudget': 'Max Budget (USD)',
'ai.codebuddy.maxBudget.hint': 'Maximum spend (USD) per request. Stops automatically when exceeded. Leave empty for no limit.',
'ai.codebuddy.sandbox': 'Sandbox Mode',
'ai.codebuddy.sandbox.hint': 'Execute tool calls in a sandbox, restricting filesystem and network access.',
'ai.codebuddy.persistSession': 'Session Persistence',
'ai.codebuddy.persistSession.hint': "Keep CodeBuddy's own session transcript on disk so CodeBuddy can resume it later. Turn off to disable CodeBuddy session storage; Netcatty may still save chat history and external session metadata. File checkpointing is skipped. Requires CodeBuddy CLI 2.125.1 or newer.",
'ai.codebuddy.fileCheckpointing': 'File Checkpointing',
'ai.codebuddy.fileCheckpointing.hint': 'Enable file operation checkpoints so AI file modifications can be rolled back.',
'ai.codebuddy.elicitation.title': 'CodeBuddy needs your input',
+5
View File
@@ -712,6 +712,11 @@ Highlight the focused split pane:
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
'settings.terminal.behavior.tabDoubleClick': 'Tab double-click behavior',
'settings.terminal.behavior.tabDoubleClick.desc': 'Choose the action when double-clicking a terminal tab.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicate session (new connection)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Copy tab (reuse connection)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'Do nothing',
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
'settings.terminal.behavior.oscNotifications.desc':
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
+7 -2
View File
@@ -8,6 +8,7 @@ export const esAiMessages: Messages = {
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
'ai.chat.contextCompacted': 'Contexto compactado',
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
'ai.chat.activity.title': 'Actividad del agente',
'ai.chat.activity.plan': 'Plan',
@@ -225,20 +226,24 @@ export const esAiMessages: Messages = {
'ai.codebuddy.envVars': 'Variables de entorno',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.258)',
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
'ai.codebuddy.effort.default': 'Predeterminado',
'ai.codebuddy.effort.minimal': 'Mínimo',
'ai.codebuddy.effort.low': 'Bajo',
'ai.codebuddy.effort.medium': 'Medio',
'ai.codebuddy.effort.high': 'Alto',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
'ai.codebuddy.effort.max': 'Máximo',
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Mínimo/Bajo para comandos simples y ahorrar tokens, y Máximo/Alto para diagnósticos complejos.',
'ai.codebuddy.maxTurns': 'Máximo de turnos',
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
'ai.codebuddy.sandbox': 'Modo sandbox',
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
'ai.codebuddy.persistSession': 'Persistencia de sesión',
'ai.codebuddy.persistSession.hint': 'Guarda en disco la transcripción de la sesión propia de CodeBuddy para que CodeBuddy pueda reanudarla más tarde. Desactívalo para deshabilitar el almacenamiento de sesiones de CodeBuddy; Netcatty aún puede guardar el historial del chat y los metadatos de la sesión externa. Se omiten los puntos de control de archivos. Requiere CodeBuddy CLI 2.125.1 o superior.',
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
+5
View File
@@ -627,6 +627,11 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.dynamicTabTitle.off': 'Desactivado',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Solo agentes',
'settings.terminal.behavior.dynamicTabTitle.all': 'Todas las sesiones',
'settings.terminal.behavior.tabDoubleClick': 'Comportamiento de doble clic en pestaña',
'settings.terminal.behavior.tabDoubleClick.desc': 'Elige la acción al hacer doble clic en una pestaña de terminal.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicar sesión (nueva conexión)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Copiar pestaña (reutilizar conexión)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'No hacer nada',
'settings.terminal.behavior.oscNotifications': 'Notificaciones de escritorio OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Muestra una notificación del sistema cuando un programa emite OSC 9, OSC 777 notify o kitty OSC 99. Lo usan Codex y otros agentes para avisos de turno completado y de aprobación.',
+21
View File
@@ -5,6 +5,7 @@ export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.chat.contextCompacted': 'Контекст сжат',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
@@ -206,6 +207,26 @@ export const ruAiMessages: Messages = {
'ai.codebuddy.envVars': 'Переменные окружения',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
'ai.codebuddy.advancedSection': 'Расширенные параметры (SDK 0.3.258)',
'ai.codebuddy.effort': 'Усилие рассуждения',
'ai.codebuddy.effort.default': 'По умолчанию',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': 'Управляет глубиной рассуждения модели. Для простых команд используйте Minimal/Low, чтобы экономить токены, а для сложной диагностики — Max/High.',
'ai.codebuddy.maxTurns': 'Максимум шагов',
'ai.codebuddy.maxTurns.hint': 'Ограничивает максимальное число шагов диалога за запрос, предотвращая зацикливание. Оставьте пустым для значения по умолчанию.',
'ai.codebuddy.maxBudget': 'Максимальный бюджет (USD)',
'ai.codebuddy.maxBudget.hint': 'Максимальные расходы (USD) за запрос. При превышении выполнение останавливается автоматически. Оставьте пустым, чтобы не ограничивать.',
'ai.codebuddy.sandbox': 'Режим песочницы',
'ai.codebuddy.sandbox.hint': 'Выполняет вызовы инструментов в песочнице, ограничивая доступ к файловой системе и сети.',
'ai.codebuddy.persistSession': 'Сохранение сессии',
'ai.codebuddy.persistSession.hint': 'Сохраняет на диск собственную историю сессии CodeBuddy, чтобы CodeBuddy мог возобновить её позже. Отключите, чтобы запретить хранение сессии CodeBuddy; Netcatty всё ещё может сохранять историю чата и метаданные внешней сессии. Контрольные точки файлов пропускаются. Требуется CodeBuddy CLI 2.125.1 или новее.',
'ai.codebuddy.fileCheckpointing': 'Контрольные точки файлов',
'ai.codebuddy.fileCheckpointing.hint': 'Включает контрольные точки файловых операций, чтобы изменения файлов ИИ можно было откатить.',
'ai.codebuddy.elicitation.title': 'CodeBuddy требуется ваш ответ',
'ai.codebuddy.elicitation.description': 'Проверьте запрос, чтобы продолжить текущий ход.',
'ai.codebuddy.elicitation.select': 'Выберите вариант',
+5
View File
@@ -669,6 +669,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
'settings.terminal.behavior.tabDoubleClick': 'Действие по двойному щелчку вкладки',
'settings.terminal.behavior.tabDoubleClick.desc': 'Выберите действие при двойном щелчке вкладки терминала.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Дублировать сеанс (новое подключение)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Копировать вкладку (использовать подключение)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
+16 -2
View File
@@ -8,6 +8,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
'ai.chat.contextCompacted': '上下文已压缩',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活动',
'ai.chat.activity.plan': '计划',
@@ -53,6 +54,15 @@ export const zhCNAiMessages: Messages = {
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
'ai.providers.icon.errorType': '请选择图片文件。',
'ai.providers.headers': '自定义 HTTP 请求头',
'ai.providers.headers.help': '为此服务添加可选请求头。内容会隐藏显示并在保存时加密。',
'ai.providers.headers.name': '请求头名称',
'ai.providers.headers.value': '请求头内容',
'ai.providers.headers.add': '添加请求头',
'ai.providers.headers.remove': '删除请求头',
'ai.providers.headers.invalid': '请填写有效且不重复的请求头。Content-Length 和 Transfer-Encoding 由应用自动设置。',
'ai.providers.headers.loadError': '无法解锁已保存的请求头。请取消编辑,解锁系统钥匙串后重试。',
'ai.providers.headers.saveError': '无法安全保存。请确认系统钥匙串可用后重试。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
@@ -225,20 +235,24 @@ export const zhCNAiMessages: Messages = {
'ai.codebuddy.envVars': '环境变量',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.230)',
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.258)',
'ai.codebuddy.effort': '推理力度',
'ai.codebuddy.effort.default': '默认',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Low 节省 token,复杂诊断用 High/XHigh。',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Minimal/Low 节省 token,复杂诊断用 Max/High。',
'ai.codebuddy.maxTurns': '最大轮次',
'ai.codebuddy.maxTurns.hint': '限制每次请求的最大对话轮次,防止 AI 失控循环。留空使用默认值。',
'ai.codebuddy.maxBudget': '预算上限 (USD)',
'ai.codebuddy.maxBudget.hint': '每次请求的最大花费(美元)。超出后自动停止。留空不限制。',
'ai.codebuddy.sandbox': '沙箱模式',
'ai.codebuddy.sandbox.hint': '在沙箱中执行工具调用,限制文件系统和网络访问。',
'ai.codebuddy.persistSession': '会话持久化',
'ai.codebuddy.persistSession.hint': '将 CodeBuddy 自身的会话记录保存到磁盘,以便 CodeBuddy 后续恢复会话。关闭后将停用 CodeBuddy 的会话存储;Netcatty 仍可能保存聊天记录和外部会话元数据。文件检查点会跳过。需要 CodeBuddy CLI 2.125.1 或更高版本。',
'ai.codebuddy.fileCheckpointing': '文件检查点',
'ai.codebuddy.fileCheckpointing.hint': '启用文件操作检查点,AI 修改文件后可回滚。',
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的输入',
@@ -347,6 +347,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
'settings.terminal.behavior.tabDoubleClick': '标签页双击行为',
'settings.terminal.behavior.tabDoubleClick.desc': '选择双击顶部终端标签页时执行的操作。',
'settings.terminal.behavior.tabDoubleClick.duplicate': '复制会话(新建连接)',
'settings.terminal.behavior.tabDoubleClick.copy': '复制标签页(复用连接)',
'settings.terminal.behavior.tabDoubleClick.disabled': '无操作',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
+7 -2
View File
@@ -8,6 +8,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
'ai.chat.contextCompacted': '上下文已壓縮',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活動',
'ai.chat.activity.plan': '計畫',
@@ -225,20 +226,24 @@ export const zhTWAiMessages: Messages = {
'ai.codebuddy.envVars': '環境變數',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.230)',
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.258)',
'ai.codebuddy.effort': '推理力度',
'ai.codebuddy.effort.default': '預設',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Low 節省 token,複雜診斷用 High/XHigh。',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Minimal/Low 節省 token,複雜診斷用 Max/High。',
'ai.codebuddy.maxTurns': '最大輪次',
'ai.codebuddy.maxTurns.hint': '限制每次請求的最大對話輪次,防止 AI 失控循環。留空使用預設值。',
'ai.codebuddy.maxBudget': '預算上限 (USD)',
'ai.codebuddy.maxBudget.hint': '每次請求的最大花費(美元)。超出後自動停止。留空不限制。',
'ai.codebuddy.sandbox': '沙箱模式',
'ai.codebuddy.sandbox.hint': '在沙箱中執行工具呼叫,限制檔案系統和網路存取。',
'ai.codebuddy.persistSession': '工作階段持久化',
'ai.codebuddy.persistSession.hint': '將 CodeBuddy 自身的工作階段記錄儲存到磁碟,以便 CodeBuddy 後續恢復工作階段。關閉後將停用 CodeBuddy 的工作階段儲存;Netcatty 仍可能儲存聊天記錄和外部工作階段中繼資料。檔案檢查點會略過。需要 CodeBuddy CLI 2.125.1 或更高版本。',
'ai.codebuddy.fileCheckpointing': '檔案檢查點',
'ai.codebuddy.fileCheckpointing.hint': '啟用檔案操作檢查點,AI 修改檔案後可回滾。',
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的輸入',
@@ -347,6 +347,11 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.tabDoubleClick': '分頁雙擊行為',
'settings.terminal.behavior.tabDoubleClick.desc': '選擇雙擊頂部終端分頁時要執行的動作。',
'settings.terminal.behavior.tabDoubleClick.duplicate': '複製工作階段(新連線)',
'settings.terminal.behavior.tabDoubleClick.copy': '複製分頁(重用連線)',
'settings.terminal.behavior.tabDoubleClick.disabled': '無動作',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
+54
View File
@@ -78,3 +78,57 @@ test('prepared apply callback runs after the snapshot with sentinel protection',
assert.equal(observedSentinel, true);
assert.equal(readInterruptedVaultApply(), null);
});
for (const localRestore of [false, true]) {
for (const encryptionFails of [false, true]) {
test(`protected header preparation (${localRestore ? 'restore' : 'sync'}, ${encryptionFails ? 'failure' : 'success'})`, async () => {
const { prepareSyncPayloadApply, prepareLocalVaultPayloadApply } = await import('./syncPayload.ts');
const { STORAGE_KEY_THEME, STORAGE_KEY_AI_PROVIDERS } = await import('../infrastructure/config/storageKeys.ts');
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
const calls: string[] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => {
calls.push('encrypt');
assert.equal(readInterruptedVaultApply(), null);
if (encryptionFails) throw new Error('keychain locked');
return sealed;
} },
dispatchEvent: () => true,
} });
localStorageValues.set(STORAGE_KEY_THEME, 'light');
const payload: SyncPayload = { ...emptyPayload(), settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
} };
const original = JSON.stringify(payload);
const importers = { importVaultData: () => {
calls.push('import');
assert.notEqual(readInterruptedVaultApply(), null);
} };
const applying = applyProtectedSyncPayload({
buildPreApplyPayload: () => { calls.push('snapshot'); return emptyPayload(); },
prepareApply: () => localRestore
? prepareLocalVaultPayloadApply(payload, importers, { prepareConvergentRestore: async () => {
calls.push('prepareReplica');
return async () => { calls.push('commitReplica'); };
} })
: prepareSyncPayloadApply(payload, importers),
translateProtectiveBackupFailure: (message) => message,
});
if (encryptionFails) {
await assert.rejects(applying);
assert.deepEqual(calls, ['encrypt']);
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'light');
assert.equal(localStorageValues.has(STORAGE_KEY_AI_PROVIDERS), false);
} else {
await applying;
assert.deepEqual(calls, localRestore
? ['encrypt', 'prepareReplica', 'snapshot', 'import', 'commitReplica']
: ['encrypt', 'snapshot', 'import']);
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'dark');
assert.equal(JSON.parse(localStorageValues.get(STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders['X-Tenant'], sealed);
}
assert.equal(readInterruptedVaultApply(), null);
assert.equal(JSON.stringify(payload), original);
});
}
}
+4 -1
View File
@@ -31,6 +31,7 @@ import {
type TerminalSessionInfo,
} from '../../infrastructure/ai/aiChatStreamingSupport';
import { useAgentCompactionUi } from './useAgentCompactionUi';
import { useI18n } from '../i18n/I18nProvider';
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
export type { ActiveCompactionUi } from './useAgentCompactionUi';
@@ -134,6 +135,7 @@ export function useAIChatStreaming({
updateMessageById,
persistContextCompaction,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
const { t } = useI18n();
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
@@ -195,7 +197,8 @@ export function useAIChatStreaming({
setStreamingForScope,
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
persistContextCompaction,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
translate: t,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction, t]);
const sendToExternalAgent = useCallback(async (
sessionId: string,
+73 -1
View File
@@ -1981,7 +1981,6 @@ test("tab bar position survives settings export and import, with a safe fallback
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
});
for (const enabled of [true, false]) {
test(`right-click long press preference survives sync (${enabled})`, async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
@@ -1995,3 +1994,76 @@ for (const enabled of [true, false]) {
assert.equal(restored.rightClickLongPressMenu, enabled);
});
}
test('custom provider headers use portable cloud secrets and local encryption with legacy preservation', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 2)]).toString('base64')}`;
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
credentialsEncrypt: async () => sealed,
credentialsDecrypt: async (value: string) => value === sealed ? 'tenant-secret' : value,
},
dispatchEvent: () => true,
} });
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: 'custom-headers', providerId: 'custom', enabled: true, customHeaders: { 'X-Tenant': sealed },
}]));
const plainSettings = buildSyncPayload(vault([]));
assert.equal(plainSettings.settings?.ai?.providers?.[0]?.customHeaders, undefined);
const cloud = await buildCloudSyncPayload(vault([]));
assert.deepEqual(cloud.settings?.ai?.providers?.[0]?.customHeaders, { 'X-Tenant': 'tenant-secret' });
await applySyncPayload(cloud, { importVaultData: () => {} });
const read = () => JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
await applySyncPayload(plainSettings, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
cloud.settings!.ai!.providers![0].customHeaders = {};
await applySyncPayload(cloud, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, {});
});
for (const localRestore of [false, true]) {
test(`header encryption failure leaves vault and settings untouched (${localRestore ? 'restore' : 'sync'})`, async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_THEME, 'light');
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, '[]');
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { throw new Error('keychain locked'); } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { Authorization: 'secret' } }] },
} };
const original = JSON.stringify(payload);
let imports = 0;
let completed = 0;
let commits = 0;
const importers = { importVaultData: () => { imports++; }, onSettingsApplied: () => { completed++; } };
await assert.rejects(localRestore
? applyLocalVaultPayload(payload, importers, { prepareConvergentRestore: async () => async () => { commits++; } })
: applySyncPayload(payload, importers));
assert.equal(imports, 0);
assert.equal(completed, 0);
assert.equal(commits, 0);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_THEME), 'light');
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS), '[]');
assert.equal(JSON.stringify(payload), original);
});
}
test('sync prepares header encryption once before importing and leaves input unchanged', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
const events: string[] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { events.push('encrypt'); return sealed; } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
} };
const original = JSON.stringify(payload);
await applySyncPayload(payload, { importVaultData: () => { events.push('import'); } });
assert.deepEqual(events, ['encrypt', 'import']);
assert.equal(JSON.stringify(payload), original);
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders, { 'X-Tenant': sealed });
});
+49 -20
View File
@@ -1,4 +1,5 @@
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
/**
* Sync Payload Builders - Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
@@ -265,7 +266,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
'autoCloseOnExit', 'disconnectedNoticeMode',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
@@ -402,13 +403,13 @@ const mergeAiProvidersPreservingLocalApiKeys = (
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
}
return incoming.map((provider) => {
if (provider.apiKey != null) return provider;
const id = typeof provider.id === 'string' ? provider.id : undefined;
const localProvider = id != null ? localById.get(id) : undefined;
if (localProvider && typeof localProvider.apiKey === 'string') {
return { ...provider, apiKey: localProvider.apiKey };
}
return provider;
return {
...provider,
...(provider.apiKey == null && typeof localProvider?.apiKey === 'string' ? { apiKey: localProvider.apiKey } : {}),
...(provider.customHeaders == null && localProvider?.customHeaders != null ? { customHeaders: localProvider.customHeaders } : {}),
};
});
};
@@ -564,7 +565,11 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
if (providers) ai.providers = providers.map((provider) => {
const next = { ...stripDeviceBoundApiKey(provider) };
delete next.customHeaders;
return next;
});
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
@@ -626,7 +631,12 @@ export async function collectCloudSyncableSettings(): Promise<SyncPayload['setti
};
if (providers) {
ai.providers = await Promise.all(providers.map(withPortableApiKey));
ai.providers = await Promise.all(providers.map(async (provider) => {
const next = await withPortableApiKey(provider);
return isRecord(provider.customHeaders)
? { ...next, customHeaders: await decryptProviderHeaders(provider.customHeaders as Record<string, string>) }
: next;
}));
}
if (webSearchConfig) {
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
@@ -663,7 +673,10 @@ function collectLocalBackupSettings(): SyncPayload['settings'] {
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<void> {
async function applySyncableSettings(
settings: NonNullable<SyncPayload['settings']>,
preparedProviders: Record<string, unknown>[] | undefined,
): Promise<void> {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
@@ -824,11 +837,10 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
const ai = settings.ai;
if (ai) {
if (ai.providers != null) {
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
if (preparedProviders != null) {
localStorageAdapter.write(
STORAGE_KEY_AI_PROVIDERS,
mergeAiProvidersPreservingLocalApiKeys(providers),
mergeAiProvidersPreservingLocalApiKeys(preparedProviders),
);
}
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
@@ -1145,7 +1157,7 @@ export async function buildLocalVaultPayloadAsync(
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
function applyPayload(
async function preparePayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: {
@@ -1153,11 +1165,20 @@ function applyPayload(
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
): Promise<() => Promise<void>> {
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
// so a previously poisoned cloud/backup snapshot can still restore host
// shells and let the user re-enter secrets (#2702).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
// Prepare credentials before any vault or settings mutation. Encryption can
// fail when local credential storage is unavailable; reuse the result below.
const providers = sanitizedPayload.settings?.ai?.providers;
const preparedProviders = providers == null ? undefined : await Promise.all(providers.map(async (provider) => {
const next = await withLocalEncryptedApiKey(provider);
return isRecord(provider.customHeaders)
? { ...next, customHeaders: await encryptProviderHeaders(await decryptProviderHeaders(provider.customHeaders as Record<string, string>)) }
: next;
}));
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
let hosts = migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled);
if (!options.includeLocalOnlyData) {
@@ -1189,7 +1210,7 @@ function applyPayload(
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
}
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
@@ -1200,7 +1221,7 @@ function applyPayload(
// Apply synced settings
if (sanitizedPayload.settings) {
await applySyncableSettings(sanitizedPayload.settings);
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
@@ -1215,21 +1236,28 @@ function applyPayload(
});
}
export function applySyncPayload(
export function prepareSyncPayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
options?: {
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
return applyPayload(payload, importers, {
): Promise<() => Promise<void>> {
return preparePayloadApply(payload, importers, {
includeLocalOnlyData: false,
applyPluginSidecars: options?.applyPluginSidecars,
currentHosts: options?.currentHosts,
});
}
export async function applySyncPayload(
...args: Parameters<typeof prepareSyncPayloadApply>
): Promise<void> {
const applyPreparedPayload = await prepareSyncPayloadApply(...args);
await applyPreparedPayload();
}
export async function prepareLocalVaultPayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
@@ -1244,9 +1272,10 @@ export async function prepareLocalVaultPayloadApply(
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const prepareConvergentRestore = dependencies.prepareConvergentRestore
?? prepareRestoredPayloadConvergentWrites;
const applyPreparedPayload = await preparePayloadApply(sanitizedPayload, importers, { includeLocalOnlyData: true });
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
return async () => {
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
await applyPreparedPayload();
await commitConvergentRestore();
};
}
+1 -1
View File
@@ -189,7 +189,7 @@ test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', (
const presets: AgentModelPreset[] = [{
id: 'glm-5.1',
name: 'GLM 5.1',
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
thinkingLevels: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
}];
+5 -5
View File
@@ -50,7 +50,7 @@ import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs';
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onBuildLocalPayload: () => SyncPayload;
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyConvergentPayload: (
payload: SyncPayload,
@@ -63,7 +63,7 @@ interface SyncDashboardProps {
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onBuildLocalPayload,
onApplyMigrationPayload,
onPrepareMigrationPayload,
onApplyPayload,
onApplyConvergentPayload,
onApplyLocalPayload,
@@ -336,7 +336,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
prepared: preparedConvergentMigration,
buildCurrentPayload: onBuildPayload,
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: onApplyMigrationPayload,
preparePayloadApply: onPrepareMigrationPayload,
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
});
@@ -349,7 +349,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} finally {
setConvergentBusy(false);
}
}, [onApplyMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
}, [onPrepareMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
const handleResolveConvergentConflict = useCallback(async (
addressKey: string,
@@ -1053,7 +1053,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onBuildLocalPayload: () => SyncPayload;
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyConvergentPayload: (
payload: SyncPayload,
+27 -7
View File
@@ -32,7 +32,7 @@ const {
} = await import("../application/state/terminalDragData.ts");
const {
activateLogViewTab,
createTopTabCopyDoubleClickHandler,
createTopTabSessionDoubleClickHandler,
formatSessionTopTabLabel,
formatSessionTopTabTooltip,
resolveSessionTabCodingCliIconState,
@@ -41,6 +41,7 @@ const {
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const topTabsQuickControlsSource = readFileSync(new URL("./TopTabsQuickControls.tsx", import.meta.url), "utf8");
const syncStatusButtonSource = readFileSync(new URL("./SyncStatusButton.tsx", import.meta.url), "utf8");
const switchSource = readFileSync(new URL("./ui/switch.tsx", import.meta.url), "utf8");
@@ -53,7 +54,6 @@ const appSource = [
].join("\n");
const externalMcpToggleSource = readFileSync(new URL("../application/state/useExternalMcpToggleState.ts", import.meta.url), "utf8");
const zhTwAiSource = readFileSync(new URL("../application/i18n/locales/zh-TW/ai.ts", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
@@ -248,17 +248,37 @@ test("disabling dynamic titles freezes a stored coding CLI icon and stops title
);
});
test("session top tabs copy the session on double click through the existing copy handler", () => {
test("session top tab double click dispatches the configured behavior", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createTopTabCopyDoubleClickHandler(
(sessionId) => copiedSessionIds.push(sessionId),
"session-1",
const duplicatedSessionIds: string[] = [];
const handlers = (["duplicate", "copy", "disabled"] as const).map((behavior) =>
createTopTabSessionDoubleClickHandler({
behavior,
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
onDuplicateSession: (sessionId) => duplicatedSessionIds.push(sessionId),
sessionId: `session-${behavior}`,
}),
);
handlers.forEach((handler) => handler({} as Parameters<typeof handler>[0]));
assert.deepEqual(duplicatedSessionIds, ["session-duplicate"]);
assert.deepEqual(copiedSessionIds, ["session-copy"]);
assert.match(topTabItemsSource, /behavior: tabDoubleClickBehavior/);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("duplicate double-click behavior falls back to copying when unavailable", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createTopTabSessionDoubleClickHandler({
behavior: "duplicate",
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
sessionId: "session-1",
});
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
assert.deepEqual(copiedSessionIds, ["session-1"]);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("session close button double click stays on the close button", () => {
+5 -1
View File
@@ -11,7 +11,7 @@ import {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import type { DynamicTabTitleMode, KeyBinding, TerminalTabDoubleClickBehavior } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
import {
@@ -168,6 +168,7 @@ interface TopTabsProps {
showHostTreeSidebar: boolean;
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
dynamicTabTitleMode?: DynamicTabTitleMode;
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
editorTabs: readonly EditorTabChrome[];
pluginViewTabs: readonly PluginViewTab[];
onClosePluginViewTab: (tabId: string) => void;
@@ -217,6 +218,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
showHostTreeSidebar,
switchTabKeyBinding,
dynamicTabTitleMode,
tabDoubleClickBehavior,
editorTabs,
pluginViewTabs,
onClosePluginViewTab,
@@ -901,6 +903,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEditHost={onEditHost}
renderBulkCloseItems={renderBulkCloseItems}
dynamicTabTitleMode={dynamicTabTitleMode}
tabDoubleClickBehavior={tabDoubleClickBehavior}
t={t}
tabAnimationClass={getTabAnimationClass(session.id)}
shortcutNumber={tabShortcutNumbers?.get(session.id)}
@@ -1320,6 +1323,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
prev.tabDoubleClickBehavior === next.tabDoubleClickBehavior &&
prev.hostById === next.hostById
);
};
@@ -18,6 +18,12 @@ const dynamicTabTitleModeOptions = (
}
).DYNAMIC_TAB_TITLE_MODE_OPTIONS;
const tabDoubleClickBehaviorOptions = (
terminalBehaviorSettings as {
TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
}
).TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS;
test("middle-click settings expose only supported behaviors", () => {
assert.ok(Array.isArray(middleClickBehaviorOptions));
assert.deepEqual(
@@ -34,6 +40,15 @@ test("dynamic tab title settings expose off, agent-only, and all modes", () => {
);
});
test("tab double-click settings expose duplicate, copy, and disabled modes", () => {
assert.ok(Array.isArray(tabDoubleClickBehaviorOptions));
assert.deepEqual(
tabDoubleClickBehaviorOptions.map((option) => option.value),
["duplicate", "copy", "disabled"],
);
assert.match(source, /updateTerminalSetting\("tabDoubleClickBehavior", v as TerminalTabDoubleClickBehavior\)/);
});
test("terminal behavior settings expose word separator editing", () => {
assert.match(source, /settings\.terminal\.behavior\.wordSeparators/);
assert.match(source, /terminalSettings\.wordSeparators/);
+1 -1
View File
@@ -112,7 +112,7 @@ function getSavedManagedAgentPathInfo(
return {
path: command,
binPath: command,
version: null,
version: managed.cliVersion ?? null,
available: savedAvailable,
installed: true,
authenticated: undefined,
+13 -10
View File
@@ -3,7 +3,7 @@ import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import {
buildCloudSyncPayload,
applySyncPayload,
prepareSyncPayloadApply,
getEffectivePortForwardingRulesForSync,
prepareLocalVaultPayloadApply,
} from "../../../application/syncPayload";
@@ -49,9 +49,9 @@ export default function SettingsSyncTab(props: {
);
}, [vault, getEffectivePortForwardingRules]);
const onApplyMigrationPayload = useCallback(
const onPrepareMigrationPayload = useCallback(
(payload: SyncPayload) =>
applySyncPayload(payload, {
prepareSyncPayloadApply(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
@@ -63,11 +63,11 @@ export default function SettingsSyncTab(props: {
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () => onApplyMigrationPayload(payload),
prepareApply: () => onPrepareMigrationPayload(payload),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[onApplyMigrationPayload, onBuildLocalPayload, t],
[onPrepareMigrationPayload, onBuildLocalPayload, t],
);
const onApplyConvergentPayload = useCallback(
@@ -76,14 +76,17 @@ export default function SettingsSyncTab(props: {
commitReplica: () => Promise<void>,
) => applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: async () => {
await onApplyMigrationPayload(payload);
await commitReplica();
prepareApply: async () => {
const applyPreparedPayload = await onPrepareMigrationPayload(payload);
return async () => {
await applyPreparedPayload();
await commitReplica();
};
},
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[onApplyMigrationPayload, onBuildLocalPayload, t],
[onPrepareMigrationPayload, onBuildLocalPayload, t],
);
const onApplyLocalPayload = useCallback(
@@ -112,7 +115,7 @@ export default function SettingsSyncTab(props: {
<CloudSyncSettings
onBuildPayload={onBuildPayload}
onBuildLocalPayload={onBuildLocalPayload}
onApplyMigrationPayload={onApplyMigrationPayload}
onPrepareMigrationPayload={onPrepareMigrationPayload}
onApplyPayload={onApplyPayload}
onApplyConvergentPayload={onApplyConvergentPayload}
onApplyLocalPayload={onApplyLocalPayload}
@@ -1,6 +1,6 @@
import React from "react";
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings, TerminalTabDoubleClickBehavior } from "../../../domain/models";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
@@ -31,6 +31,15 @@ export const DYNAMIC_TAB_TITLE_MODE_OPTIONS: Array<{
{ value: "all", labelKey: "settings.terminal.behavior.dynamicTabTitle.all" },
];
export const TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS: Array<{
value: TerminalTabDoubleClickBehavior;
labelKey: string;
}> = [
{ value: "duplicate", labelKey: "settings.terminal.behavior.tabDoubleClick.duplicate" },
{ value: "copy", labelKey: "settings.terminal.behavior.tabDoubleClick.copy" },
{ value: "disabled", labelKey: "settings.terminal.behavior.tabDoubleClick.disabled" },
];
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
t,
terminalSettings,
@@ -214,6 +223,22 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
</SettingRow>
<SettingRow
anchorId="terminal-tab-double-click"
label={t("settings.terminal.behavior.tabDoubleClick")}
description={t("settings.terminal.behavior.tabDoubleClick.desc")}
>
<Select
value={terminalSettings.tabDoubleClickBehavior}
options={TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
onChange={(v) => updateTerminalSetting("tabDoubleClickBehavior", v as TerminalTabDoubleClickBehavior)}
className="w-44"
/>
</SettingRow>
<SettingRow
anchorId="terminal-dynamic-tab-title"
label={t("settings.terminal.behavior.dynamicTabTitle")}
+41 -2
View File
@@ -4,7 +4,10 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import type { CodebuddyAdvancedOptions } from "../../../../infrastructure/ai/types";
import {
isCodebuddySessionPersistenceSupported,
type CodebuddyAdvancedOptions,
} from "../../../../infrastructure/ai/types";
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
const INTERNET_ENV_OPTIONS = [
@@ -15,10 +18,12 @@ const INTERNET_ENV_OPTIONS = [
const EFFORT_OPTIONS = [
{ value: "", labelKey: "ai.codebuddy.effort.default" },
{ value: "minimal", labelKey: "ai.codebuddy.effort.minimal" },
{ value: "low", labelKey: "ai.codebuddy.effort.low" },
{ value: "medium", labelKey: "ai.codebuddy.effort.medium" },
{ value: "high", labelKey: "ai.codebuddy.effort.high" },
{ value: "xhigh", labelKey: "ai.codebuddy.effort.xhigh" },
{ value: "max", labelKey: "ai.codebuddy.effort.max" },
] as const;
export const CodebuddyCard: React.FC<{
@@ -50,6 +55,13 @@ export const CodebuddyCard: React.FC<{
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const persistSessionSupported = Boolean(
found && isCodebuddySessionPersistenceSupported(pathInfo?.version),
);
// Unsupported/unknown CLIs keep their own default (persistence enabled),
// even if a stale false value remains in saved settings.
const persistSessionEnabled = !persistSessionSupported
|| advancedOptions?.persistSession !== false;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
@@ -193,7 +205,7 @@ export const CodebuddyCard: React.FC<{
)}
</div>
{/* Advanced SDK options (SDK 0.3.230) */}
{/* Advanced SDK options (SDK 0.3.258) */}
{onAdvancedOptionsChange && (
<div className="border-t border-border/60 pt-3">
<button
@@ -279,6 +291,33 @@ export const CodebuddyCard: React.FC<{
)} />
</button>
</div>
{/* Session Persistence */}
<div className="flex items-center justify-between gap-2">
<div>
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.persistSession')}</span>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.persistSession.hint')}</p>
</div>
<button
type="button"
role="switch"
disabled={!persistSessionSupported}
aria-checked={persistSessionEnabled}
onClick={() => {
if (!persistSessionSupported) return;
updateAdvanced({ persistSession: advancedOptions?.persistSession === false ? undefined : false });
}}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
!persistSessionSupported && "cursor-not-allowed opacity-50",
persistSessionEnabled ? "bg-primary" : "bg-muted",
)}
>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
persistSessionEnabled ? "translate-x-[18px]" : "translate-x-[3px]",
)} />
</button>
</div>
{/* File Checkpointing */}
<div className="flex items-center justify-between gap-2">
<div>
@@ -95,12 +95,13 @@ export const ModelSelector: React.FC<{
presetModels?: readonly string[];
placeholder?: string;
apiKey?: string;
customHeaders?: Record<string, string>;
providerId?: AIProviderId;
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
style?: ProviderStyle;
skipTLSVerify?: boolean;
onModelMetadata?: (model: FetchedModel) => void;
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, customHeaders, providerId, style, skipTLSVerify, onModelMetadata }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -124,6 +125,7 @@ export const ModelSelector: React.FC<{
baseURL,
effectiveModelsEndpoint,
apiKey,
customHeaders,
resolvedStyle,
skipTLSVerify,
});
@@ -152,7 +154,7 @@ export const ModelSelector: React.FC<{
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey, customHeaders);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
if (discoveryKeyRef.current !== requestKey) return;
@@ -171,7 +173,7 @@ export const ModelSelector: React.FC<{
} finally {
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
}
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
useEffect(() => {
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React, { act } from 'react';
import { JSDOM } from 'jsdom';
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
import { TooltipProvider } from '../../../ui/tooltip';
import { ProviderConfigForm } from './ProviderConfigForm';
import type { ProviderConfig } from '../../../../infrastructure/ai/types';
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 3)]).toString('base64')}`;
test('loading saved headers preserves model metadata and blocks saving until decrypted', async () => {
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true };
for (const [name, value] of Object.entries(globals)) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
let unlock!: (value: string) => void;
Object.assign(dom.window, { netcatty: {
credentialsDecrypt: () => new Promise<string>((resolve) => { unlock = resolve; }),
credentialsEncrypt: async () => sealed,
} });
const { createRoot } = await import('react-dom/client');
const root = createRoot(dom.window.document.getElementById('root')!);
let saved: Partial<ProviderConfig> | undefined;
try {
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider>
<ProviderConfigForm provider={{ id: 'custom', name: 'Custom', providerId: 'custom', enabled: true,
customHeaders: { 'X-Tenant': sealed }, modelContextWindows: { model: 12345 },
}} onSave={(updates) => { saved = updates; }} onCancel={() => {}} />
</TooltipProvider></I18nProvider>));
const save = () => [...dom.window.document.querySelectorAll('button')].find((button) => button.textContent === 'Save')!;
assert.equal(save().disabled, true);
await act(async () => { unlock('tenant-secret'); });
assert.equal(save().disabled, false);
assert.equal((dom.window.document.querySelector('[aria-label="Header value"]') as HTMLInputElement).value, 'tenant-secret');
await act(async () => save().click());
assert.deepEqual(saved?.customHeaders, { 'X-Tenant': sealed });
assert.deepEqual(saved?.modelContextWindows, { model: 12345 });
await act(async () => (dom.window.document.querySelector('[aria-label="Remove header"]') as HTMLButtonElement).click());
await act(async () => save().click());
assert.deepEqual(saved?.customHeaders, {});
assert.equal(dom.window.document.querySelector('[aria-label="Header name"]'), null);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
}
});
@@ -1,3 +1,5 @@
import { decryptProviderHeaders, encryptProviderHeaders, parseProviderHeaderRows, type HeaderRow } from '../../../../infrastructure/ai/providerHeaderCredentials';
import { ProviderHeadersEditor } from './ProviderHeadersEditor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
@@ -83,6 +85,27 @@ export const ProviderConfigForm: React.FC<{
iconId: provider.iconId ?? "",
iconDataUrl: provider.iconDataUrl ?? "",
});
const [headersSourceVersion, setHeadersSourceVersion] = useState(0);
const [headerRows, setHeaderRows] = useState<HeaderRow[]>([]);
const [headersLoading, setHeadersLoading] = useState(true);
const [headersLoadError, setHeadersLoadError] = useState(false);
const [saveError, setSaveError] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const parsedHeaders = useMemo(() => {
try { return { headers: parseProviderHeaderRows(headerRows), valid: true }; }
catch { return { headers: {}, valid: false }; }
}, [headerRows]);
const customHeaders = parsedHeaders.headers;
useEffect(() => {
let cancelled = false;
setHeadersLoading(true);
setHeadersLoadError(false);
decryptProviderHeaders(provider.customHeaders).then((headers) => {
if (!cancelled) setHeaderRows(Object.entries(headers).map(([name, value]) => ({ name, value })));
}).catch(() => { if (!cancelled) setHeadersLoadError(true); })
.finally(() => { if (!cancelled) setHeadersLoading(false); });
return () => { cancelled = true; };
}, [provider.customHeaders]);
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -107,12 +130,14 @@ export const ProviderConfigForm: React.FC<{
baseURL: form.baseURL || preset?.defaultBaseURL || "",
modelsEndpoint: preset?.modelsEndpoint ?? "",
apiKeySourceVersion,
headersSourceVersion,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
}), [
provider.providerId,
form.baseURL,
apiKeySourceVersion,
headersSourceVersion,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
@@ -121,11 +146,13 @@ export const ProviderConfigForm: React.FC<{
const probeFingerprint = useMemo(() => JSON.stringify({
baseURL: form.baseURL || preset?.defaultBaseURL || "",
apiKey: form.apiKey,
customHeaders,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
modelsEndpoint: preset?.modelsEndpoint ?? "",
}), [
form.apiKey,
customHeaders,
form.baseURL,
form.skipTLSVerify,
preset?.defaultBaseURL,
@@ -271,6 +298,7 @@ export const ProviderConfigForm: React.FC<{
bridge: getFetchBridge(),
baseURL,
apiKey: form.apiKey,
customHeaders,
providerId: provider.providerId,
style: resolvedStyle,
presetModelsEndpoint: preset?.modelsEndpoint,
@@ -324,9 +352,10 @@ export const ProviderConfigForm: React.FC<{
} finally {
if (probeRequestIdRef.current === requestId) setIsTesting(false);
}
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
}, [form.apiKey, customHeaders, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
const handleSave = useCallback(async () => {
if (isSaving || headersLoading || headersLoadError || !parsedHeaders.valid) return;
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
@@ -369,15 +398,24 @@ export const ProviderConfigForm: React.FC<{
iconDataUrl: form.iconDataUrl || undefined,
};
// Encrypt API key before saving
if (form.apiKey) {
updates.apiKey = await encryptField(form.apiKey);
} else {
updates.apiKey = undefined;
}
setIsSaving(true);
setSaveError(false);
try {
updates.customHeaders = await encryptProviderHeaders(customHeaders);
// Encrypt API key before saving
if (form.apiKey) {
updates.apiKey = await encryptField(form.apiKey);
} else {
updates.apiKey = undefined;
}
onSave(updates);
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
onSave(updates);
} catch {
setSaveError(true);
} finally {
setIsSaving(false);
}
}, [form, customHeaders, isSaving, headersLoading, headersLoadError, parsedHeaders.valid, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
return (
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
@@ -556,6 +594,17 @@ export const ProviderConfigForm: React.FC<{
</div>
</div>
<ProviderHeadersEditor
rows={headerRows}
onChange={(rows) => { setHeaderRows(rows); setHeadersSourceVersion((version) => version + 1); }}
disabled={headersLoading || headersLoadError || isSaving}
/>
{(!parsedHeaders.valid || headersLoadError || saveError) && (
<p role="alert" className="text-xs text-destructive">{t(!parsedHeaders.valid
? 'ai.providers.headers.invalid' : headersLoadError
? 'ai.providers.headers.loadError' : 'ai.providers.headers.saveError')}</p>
)}
{/* Base URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
@@ -589,7 +638,8 @@ export const ProviderConfigForm: React.FC<{
baseURL={resolvedBaseURL}
modelsEndpoint={preset?.modelsEndpoint}
presetModels={preset?.defaultModels}
apiKey={form.apiKey}
apiKey={headersLoading || headersLoadError || !parsedHeaders.valid ? undefined : form.apiKey}
customHeaders={headersLoading || headersLoadError || !parsedHeaders.valid ? undefined : customHeaders}
providerId={provider.providerId}
style={resolvedStyle}
skipTLSVerify={form.skipTLSVerify}
@@ -740,6 +790,7 @@ export const ProviderConfigForm: React.FC<{
variant="default"
size="sm"
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
disabled={isSaving || isDecrypting || headersLoading || headersLoadError || !parsedHeaders.valid}
onClick={() => void handleSave()}
>
<Check size={14} className="size-3.5 shrink-0" />
@@ -750,7 +801,7 @@ export const ProviderConfigForm: React.FC<{
size="sm"
className={PROVIDER_ACTION_CLASS}
onClick={() => void handleTestConnection()}
disabled={isTesting || isDecrypting}
disabled={isTesting || isDecrypting || headersLoading || headersLoadError || !parsedHeaders.valid}
>
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
@@ -0,0 +1,30 @@
import React from 'react';
import { Plus, X } from 'lucide-react';
import { useI18n } from '../../../../application/i18n/I18nProvider';
import type { HeaderRow } from '../../../../infrastructure/ai/providerHeaderCredentials';
import { Button } from '../../../ui/button';
export function ProviderHeadersEditor({ rows, onChange, disabled }: {
rows: HeaderRow[];
onChange: (rows: HeaderRow[]) => void;
disabled: boolean;
}) {
const { t } = useI18n();
const inputClass = 'min-w-0 flex-1 h-8 rounded-md border border-input bg-background px-2 text-xs disabled:opacity-50';
return <fieldset disabled={disabled} className="space-y-2">
<legend className="text-xs font-medium text-muted-foreground">{t('ai.providers.headers')}</legend>
<p className="text-[11px] text-muted-foreground">{t('ai.providers.headers.help')}</p>
{rows.map((row, index) => <div key={index} className="flex items-center gap-2">
<input aria-label={t('ai.providers.headers.name')} placeholder="X-Header" className={inputClass}
value={row.name} onChange={(e) => onChange(rows.map((item, i) => i === index ? { ...item, name: e.target.value } : item))} />
<input type="password" autoComplete="off" aria-label={t('ai.providers.headers.value')}
placeholder={t('ai.providers.headers.value')} className={inputClass}
value={row.value} onChange={(e) => onChange(rows.map((item, i) => i === index ? { ...item, value: e.target.value } : item))} />
<Button type="button" variant="ghost" size="sm" aria-label={t('ai.providers.headers.remove')}
onClick={() => onChange(rows.filter((_, i) => i !== index))}><X size={14} /></Button>
</div>)}
<Button type="button" variant="outline" size="sm" onClick={() => onChange([...rows, { name: '', value: '' }])}>
<Plus size={12} className="mr-1.5" />{t('ai.providers.headers.add')}
</Button>
</fieldset>;
}
+55 -4
View File
@@ -1,6 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { TreeNode } from "./SftpPaneTreeNode.tsx";
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
import type { SftpFileEntry } from "../../types";
@@ -18,7 +21,55 @@ test("tree activation enters directories and keeps file opening separate", () =>
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
});
test("tree row double click routes through the open action", () => {
const source = fs.readFileSync(new URL("./SftpPaneTreeNode.tsx", import.meta.url), "utf8");
assert.match(source, /onDoubleClick=\{\(\) => onOpenEntry\(entry, entryPath\)\}/);
test("tree double clicks preserve folder, parent, file and arrow actions", async () => {
const dom = new JSDOM('<div id="root"></div>');
const keys = ["window", "document", "IS_REACT_ACT_ENVIRONMENT"] as const;
const originals = keys.map(key => Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const container = dom.window.document.getElementById("root")!;
const root = createRoot(container);
const calls: string[] = [];
const noop = () => {};
const render = async (item: SftpFileEntry, isLoading = false) => {
await act(async () => root.render(React.createElement(TreeNode, {
entry: item, entryPath: `/root/${item.name}`, depth: 0,
columnTemplate: "1fr", visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
isSelected: false, isExpanded: false, isLoading, isDragOver: false,
onToggleExpand: () => calls.push("toggle"), onNodeClick: () => calls.push("select"),
onOpenEntry: () => calls.push("open"), onContextMenu: () => calls.push("menu"),
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
})));
calls.length = 0;
};
const doubleClick = async (target: Element) => {
for (const [type, detail] of [["click", 1], ["click", 2], ["dblclick", 2]] as const) {
await act(async () => { target.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, detail })); });
}
};
try {
await render(entry("docs", "directory"));
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
assert.deepEqual(calls, ["select", "select", "toggle"]);
calls.length = 0;
await doubleClick(container.querySelector('.lucide-chevron-right')!);
assert.deepEqual(calls, ["toggle", "toggle"]);
await render(entry("docs", "directory"), true);
await doubleClick(container.querySelector('.lucide-loader-circle, .lucide-loader-2')!);
assert.deepEqual(calls, ["select", "select"]);
for (const item of [entry("..", "directory"), entry("notes.txt", "file")]) {
await render(item);
await doubleClick(container.querySelector('[data-section]')!);
assert.deepEqual(calls, ["select", "select", "open"]);
}
} finally {
await act(async () => root.unmount());
dom.window.close();
keys.forEach((key, index) => {
const descriptor = originals[index];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
});
}
});
+9 -2
View File
@@ -67,7 +67,11 @@ export const TreeNode = React.memo<TreeNodeProps>(({
)}
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
onClick={e => onNodeClick(entry, entryPath, e)}
onDoubleClick={() => onOpenEntry(entry, entryPath)}
onDoubleClick={() => {
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
if (isDir) void onToggleExpand(entry, entryPath);
else onOpenEntry(entry, entryPath);
}}
onContextMenu={e => {
if (!isParentEntry) {
onContextMenu(entry, entryPath, e);
@@ -84,7 +88,10 @@ export const TreeNode = React.memo<TreeNodeProps>(({
className="flex min-w-0 items-center gap-1"
style={{ paddingLeft: depth * 16 + 8 }}
>
<span className="shrink-0 w-4 flex items-center justify-center">
<span
className="shrink-0 w-4 flex items-center justify-center"
onDoubleClick={e => { if (!isParentEntry && isDir) e.stopPropagation(); }}
>
{isParentEntry ? (
<CornerUpLeft size={14} className="text-muted-foreground" />
) : isDir ? (
+32 -6
View File
@@ -20,7 +20,7 @@ import { resolveSessionCodingCliProvider } from '../../domain/codingCliProviderM
import type { CodingCliProvider } from '../../domain/codingCliProviders';
import { resolveCodingCliActivityPhase, type CodingCliActivityPhase } from '../../domain/codingCliTitleParse';
import { resolveSessionTabTitle, resolveWorkspaceTabLabel } from '../../domain/sessionTabTitle';
import type { DynamicTabTitleMode } from '../../domain/models';
import type { DynamicTabTitleMode, TerminalTabDoubleClickBehavior } from '../../domain/models';
import { CodingCliProviderIcon } from '../icons/CodingCliProviderIcon';
import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types';
@@ -242,9 +242,28 @@ export const formatSessionTopTabLabel = (
};
export const createTopTabCopyDoubleClickHandler = (
onCopySession: (sessionId: string) => void,
sessionId: string,
): React.MouseEventHandler<HTMLDivElement> => () => onCopySession(sessionId);
onCopy: (id: string) => void,
id: string,
): React.MouseEventHandler<HTMLDivElement> => () => onCopy(id);
export const createTopTabSessionDoubleClickHandler = ({
behavior,
onCopySession,
onDuplicateSession,
sessionId,
}: {
behavior: TerminalTabDoubleClickBehavior;
onCopySession: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
sessionId: string;
}): React.MouseEventHandler<HTMLDivElement> => () => {
if (behavior === 'disabled') return;
if (behavior === 'duplicate') {
(onDuplicateSession ?? onCopySession)(sessionId);
return;
}
onCopySession(sessionId);
};
export const stopCloseButtonDoubleClickPropagation = (
event: Pick<React.MouseEvent, 'stopPropagation'>,
@@ -713,6 +732,7 @@ interface SessionTopTabProps {
onEditHost?: (host: Host) => void;
renderBulkCloseItems: RenderBulkCloseItems;
dynamicTabTitleMode?: DynamicTabTitleMode;
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
@@ -739,6 +759,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onEditHost,
renderBulkCloseItems,
dynamicTabTitleMode,
tabDoubleClickBehavior,
t,
tabAnimationClass,
shortcutNumber,
@@ -757,8 +778,13 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
activeTabStore.setActiveTabId(session.id);
}, [session.id]);
const handleDoubleClick = useMemo(
() => createTopTabCopyDoubleClickHandler(onCopySession, session.id),
[onCopySession, session.id],
() => createTopTabSessionDoubleClickHandler({
behavior: tabDoubleClickBehavior,
onCopySession,
onDuplicateSession,
sessionId: session.id,
}),
[tabDoubleClickBehavior, onCopySession, onDuplicateSession, session.id],
);
const addressTooltip = formatSessionTopTabTooltip(session);
const tabTitle = formatSessionTopTabLabel(session, dynamicTabTitleMode);
+12 -12
View File
@@ -29,20 +29,20 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="169.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="167.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="242.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="324.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="404.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="487.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="567.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="650.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="733.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<circle cx="765.0" cy="85.3" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5917</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+12 -12
View File
@@ -29,20 +29,20 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="169.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="167.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="242.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="324.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="404.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="487.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="567.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="650.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="733.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<circle cx="765.0" cy="85.3" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5917</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+15
View File
@@ -67,6 +67,8 @@ export interface KeywordHighlightRule {
customized?: boolean;
}
export type TerminalTabDoubleClickBehavior = 'duplicate' | 'copy' | 'disabled';
export interface TerminalSettings {
// Rendering
scrollback: number; // Number of lines kept in buffer
@@ -122,6 +124,8 @@ export interface TerminalSettings {
linkModifier: LinkModifier; // Modifier key to click links
autoCloseOnExit: boolean; // Automatically close terminal UI after eligible session exits
disconnectedNoticeMode: DisconnectedNoticeMode; // Non-blocking terminal line or legacy dialog after disconnect
/** Action when double-clicking a terminal session tab. */
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
// Keyword Highlighting
keywordHighlightEnabled: boolean;
@@ -358,6 +362,12 @@ const isDynamicTabTitleMode = (value: unknown): value is DynamicTabTitleMode =>
value === 'all'
);
const isTerminalTabDoubleClickBehavior = (value: unknown): value is TerminalTabDoubleClickBehavior => (
value === 'duplicate' ||
value === 'copy' ||
value === 'disabled'
);
const isHostInfoBarTitleMode = (value: unknown): value is HostInfoBarTitleMode => (
value === 'address' ||
value === 'label'
@@ -404,6 +414,9 @@ export const normalizeTerminalSettings = (
dynamicTabTitleMode: isDynamicTabTitleMode(settings?.dynamicTabTitleMode)
? settings.dynamicTabTitleMode
: DEFAULT_TERMINAL_SETTINGS.dynamicTabTitleMode,
tabDoubleClickBehavior: isTerminalTabDoubleClickBehavior(settings?.tabDoubleClickBehavior)
? settings.tabDoubleClickBehavior
: DEFAULT_TERMINAL_SETTINGS.tabDoubleClickBehavior,
hostInfoBarTitleMode: isHostInfoBarTitleMode(settings?.hostInfoBarTitleMode)
? settings.hostInfoBarTitleMode
: DEFAULT_TERMINAL_SETTINGS.hostInfoBarTitleMode,
@@ -535,6 +548,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
oscNotifications: 'always', // Honor OSC 9/777/99 desktop notifications by default
dynamicTabTitleMode: 'agent',
// Prefer an independent SSH login so a double-click never restores the source cwd.
tabDoubleClickBehavior: 'duplicate',
rendererType: 'auto', // Auto-detect best renderer based on hardware
hibernateHiddenTabs: false,
hibernateHiddenTabsDelaySec: 5,
+8
View File
@@ -384,6 +384,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.behavior.clearWipesScrollback.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-tab-double-click",
tab: "terminal",
labelKey: "settings.terminal.behavior.tabDoubleClick",
descriptionKey: "settings.terminal.behavior.tabDoubleClick.desc",
sectionKey: "settings.terminal.section.behavior",
keywords: ["tab", "double click", "复制会话", "双击"],
},
{
id: "terminal-dynamic-tab-title",
tab: "terminal",
+17
View File
@@ -128,6 +128,23 @@ test("normalizeTerminalSettings falls back for unsupported dynamic tab title mod
);
});
test("normalizeTerminalSettings defaults tab double click to a new connection", () => {
assert.equal(normalizeTerminalSettings().tabDoubleClickBehavior, "duplicate");
});
test("normalizeTerminalSettings preserves supported tab double-click behaviors", () => {
assert.equal(normalizeTerminalSettings({ tabDoubleClickBehavior: "duplicate" }).tabDoubleClickBehavior, "duplicate");
assert.equal(normalizeTerminalSettings({ tabDoubleClickBehavior: "copy" }).tabDoubleClickBehavior, "copy");
assert.equal(normalizeTerminalSettings({ tabDoubleClickBehavior: "disabled" }).tabDoubleClickBehavior, "disabled");
});
test("normalizeTerminalSettings rejects unsupported tab double-click behaviors", () => {
assert.equal(
normalizeTerminalSettings({ tabDoubleClickBehavior: "legacy" as never }).tabDoubleClickBehavior,
"duplicate",
);
});
test("normalizeTerminalSettings enables OSC desktop notifications by default", () => {
assert.equal(normalizeTerminalSettings().oscNotifications, "always");
});
+12 -1
View File
@@ -299,7 +299,7 @@ const WEB_SEARCH_KEY_PLACEHOLDER = "__WEB_SEARCH_KEY__";
function injectApiKeyIntoRequest(url, headers, providerId) {
if (!providerId) return { url, headers };
const resolved = resolveProviderApiKey(providerId);
if (!resolved || !resolved.apiKey) return { url, headers };
if (!resolved) return { url, headers };
const realKey = resolved.apiKey;
// Replace placeholder in all header values
@@ -314,6 +314,17 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
patchedUrl = url.replace(API_KEY_PLACEHOLDER, encodeURIComponent(realKey));
}
for (const [name, stored] of Object.entries(resolved.provider.customHeaders || {})) {
const value = decryptApiKeyValue(stored);
if (stored.startsWith(ENC_PREFIX) && (!value || value === stored)) {
throw new Error("Unable to decrypt custom HTTP headers");
}
// Replace case-insensitively, including SDK-generated authorization headers.
for (const key of Object.keys(patchedHeaders)) {
if (key.toLowerCase() === name.toLowerCase()) delete patchedHeaders[key];
}
Object.defineProperty(patchedHeaders, name, { value, enumerable: true, configurable: true });
}
return { url: patchedUrl, headers: patchedHeaders };
}
+29
View File
@@ -1836,3 +1836,32 @@ test("non-streaming AI requests send Content-Length instead of chunked encoding"
ctx.restore();
}
});
test('custom provider headers are decrypted before HTTP and override auth case-insensitively', async () => {
let context;
const { bridge, restore } = loadBridgeWithMocks({ registerSdkStreamHandlers: (ctx) => { context = ctx; } });
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: {
app: { getPath: () => process.cwd() }, session: {},
safeStorage: { isEncryptionAvailable: () => true, decryptString: () => 'Bearer tenant-secret' },
} });
bridge.registerHandlers(ipcMain);
const sealed = 'enc:v1:' + Buffer.from('ciphertext').toString('base64');
context.providerConfigs = [{ id: 'header-test', apiKey: 'key', customHeaders: { authorization: sealed, 'X-Tenant': 'tenant' } }];
const server = http.createServer((req, res) => { res.end(JSON.stringify(req.headers)); });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const request = context.injectApiKeyIntoRequest(`http://127.0.0.1:${server.address().port}`, { Authorization: 'Bearer __IPC_SECURED__', 'X-Other': 'keep' }, 'header-test');
assert.equal(request.headers.Authorization, undefined);
const response = await fetch(request.url, { headers: request.headers });
const headers = await response.json();
assert.equal(headers.authorization, 'Bearer tenant-secret');
assert.equal(headers['x-tenant'], 'tenant');
assert.equal(headers['x-other'], 'keep');
context.electronModule.safeStorage.isEncryptionAvailable = () => false;
assert.throws(() => context.injectApiKeyIntoRequest(request.url, {}, 'header-test'), /Unable to decrypt/);
} finally {
await new Promise((resolve) => server.close(resolve));
restore();
}
});
+139 -24
View File
@@ -76,7 +76,7 @@ function buildCodebuddyThinkingEnv(thinking) {
// ---------------------------------------------------------------------------
/** Convert neutral injectMcp configs into the SDK's keyed mcpServers map.
* Supports stdio (default), sse, http, and sdk (in-process) transport types (SDK 0.3.230). */
* Supports stdio (default), sse, http, and sdk (in-process) transport types (SDK 0.3.258). */
function toSdkMcpServers(injectedMcpServers) {
const map = {};
for (const cfg of injectedMcpServers || []) {
@@ -193,7 +193,7 @@ function codebuddyBuiltinTools(toolIntegrationMode) {
: [...MCP_MODE_TOOLS];
}
const CODEBUDDY_REASONING_LEVELS = ["low", "medium", "high", "xhigh"];
const CODEBUDDY_REASONING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
const CODEBUDDY_REASONING_LEVEL_SET = new Set(CODEBUDDY_REASONING_LEVELS);
function splitCodebuddyModelSelection(model) {
@@ -207,12 +207,39 @@ function splitCodebuddyModelSelection(model) {
return { model: model.slice(0, slash), effort };
}
function attachCodebuddyThinkingLevels(preset) {
/**
* Narrow a model-declared effort list to the SDK's canonical Effort union.
* Accepts whatever the CLI/runtime reports (SDK 0.3.258 `RawLanguageModel
* .reasoning.supportedEfforts`), keeps only known values, preserves canonical
* order, and returns null when nothing usable was declared so callers can fall
* back to the full union.
*/
function normalizeCodebuddyThinkingLevels(supported) {
if (!Array.isArray(supported) || supported.length === 0) return null;
const ordered = CODEBUDDY_REASONING_LEVELS.filter((level) => supported.includes(level));
return ordered.length > 0 ? ordered : null;
}
function attachCodebuddyThinkingLevels(preset, capability) {
if (!preset) return preset;
// Prefer the levels the model actually declares (SDK 0.3.258). Without a
// usable declaration the full Effort union is offered, matching prior
// behaviour exactly.
const levels = normalizeCodebuddyThinkingLevels(capability?.supportedEfforts)
|| [...CODEBUDDY_REASONING_LEVELS];
const declaredDefault = capability?.defaultEffort;
let defaultThinkingLevel;
if (declaredDefault && levels.includes(declaredDefault)) {
defaultThinkingLevel = declaredDefault;
} else if (levels.includes("medium")) {
defaultThinkingLevel = "medium";
} else {
defaultThinkingLevel = levels[0];
}
return {
...preset,
thinkingLevels: [...CODEBUDDY_REASONING_LEVELS],
defaultThinkingLevel: "medium",
thinkingLevels: levels,
defaultThinkingLevel,
encodeDefaultThinking: false,
};
}
@@ -220,7 +247,7 @@ function attachCodebuddyThinkingLevels(preset) {
function buildCodebuddyQueryOptions({
cwd, model, env, injectedMcpServers, abortController,
resume, pathToCodebuddyCode, toolIntegrationMode, thinking,
// Phase 1: SDK 0.3.230 options
// Phase 1: SDK 0.3.258 options
systemPrompt, effort, maxTurns, maxBudgetUsd, fallbackModel,
sandbox, agents, outputFormat, enableFileCheckpointing,
traceId, parentSpanId, persistSession, sessionId, hooks,
@@ -258,7 +285,7 @@ function buildCodebuddyQueryOptions({
if (thinkingConfig) {
options.thinking = thinkingConfig;
}
// --- SDK 0.3.230 options ---
// --- SDK 0.3.258 options ---
// System prompt: string is treated as append to default.
if (systemPrompt) {
options.systemPrompt = typeof systemPrompt === "string"
@@ -304,7 +331,7 @@ function buildCodebuddyQueryOptions({
if (sessionId) options.sessionId = sessionId;
// Lifecycle hooks.
if (hooks && typeof hooks === "object") options.hooks = hooks;
// Supported by @tencent-ai/agent-sdk 0.3.230 Options.elicitation.
// Supported by @tencent-ai/agent-sdk 0.3.258 Options.elicitation.
// QueryController advertises capabilities.elicitation.form during
// initialization and routes elicitation_create control requests here.
if (elicitation && typeof elicitation === "object") options.elicitation = elicitation;
@@ -660,37 +687,119 @@ function mapCodebuddyModels(models) {
if (!m) return null;
const id = m.id || m.modelId || m.value;
if (!id) return null;
return attachCodebuddyThinkingLevels({
id,
name: m.name || m.displayName || id,
description: m.description,
});
// SDK 0.3.258: raw model records carry reasoning capability metadata
// (RawLanguageModel.reasoning.supportedEfforts / defaultEffort). Simple
// ModelInfo responses omit it — then the full Effort union is offered,
// exactly as before.
const reasoning = m.reasoning && typeof m.reasoning === "object" ? m.reasoning : null;
return attachCodebuddyThinkingLevels(
{
id,
name: m.name || m.displayName || id,
description: m.description,
},
reasoning,
);
})
.filter(Boolean);
}
/**
* Fetch available CodeBuddy models via the SDK control channel. Opens a
* streaming (idle) session so no turn is billed, asks supportedModels(), then
* tears down. Returns [] on failure (caller falls back to curated presets).
* Fetch available CodeBuddy models via the SDK control channel. Prefer the V2
* session API because it exposes RawLanguageModel reasoning metadata. Fall
* back to the legacy idle query API for older SDK/CLI combinations.
* @param {object} args
* @param {string} [args.pathToCodebuddyCode]
* @param {object} [args.env]
* @param {Function} [args.queryFn] inject query() for tests
* @param {Function} [args.createSessionFn] inject V2 createSession() for tests
*/
async function raceCodebuddyModelRequest(request, signal) {
if (!signal) return { type: "models", models: await request() };
let onAbort;
const abortPromise = new Promise((resolve) => {
onAbort = () => resolve({ type: "aborted" });
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([
Promise.resolve().then(request).then((models) => ({ type: "models", models })),
abortPromise,
]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function listCodebuddyModelsViaSession({ createSessionFn, pathToCodebuddyCode, env, signal }) {
if (typeof createSessionFn !== "function") return null;
let session;
try {
session = createSessionFn({ pathToCodebuddyCode, env });
if (typeof session?.getAvailableModelsRaw !== "function") return null;
const rawResult = await raceCodebuddyModelRequest(
() => session.getAvailableModelsRaw(),
signal,
);
if (rawResult.type === "aborted") return [];
if (Array.isArray(rawResult.models) && rawResult.models.length > 0) {
return mapCodebuddyModels(rawResult.models);
}
// Some CLI versions implement the V2 control request but only return the
// simplified catalog. Preserve model discovery in that case, without
// pretending that reasoning capabilities were declared.
if (typeof session.getAvailableModels === "function") {
const simpleResult = await raceCodebuddyModelRequest(
() => session.getAvailableModels(),
signal,
);
if (simpleResult.type === "aborted") return [];
if (Array.isArray(simpleResult.models) && simpleResult.models.length > 0) {
return mapCodebuddyModels(simpleResult.models);
}
}
return [];
} catch {
return signal?.aborted ? [] : null;
} finally {
try { session?.close?.(); } catch { /* best effort */ }
}
}
async function listCodebuddyModels({
pathToCodebuddyCode,
env,
queryFn,
createSessionFn,
abortController,
signal,
}) {
const externalSignal = signal || abortController?.signal;
if (externalSignal?.aborted) return [];
let sdk;
let createSession = createSessionFn;
if (!createSession && !queryFn) {
try {
sdk = await import("@tencent-ai/agent-sdk");
createSession = sdk.unstable_v2_createSession;
} catch { /* use the legacy path below */ }
}
const v2Models = await listCodebuddyModelsViaSession({
createSessionFn: createSession,
pathToCodebuddyCode,
env,
signal: externalSignal,
});
if (v2Models !== null) return v2Models;
let query = queryFn;
if (!query) {
let sdk;
try { sdk = await import("@tencent-ai/agent-sdk"); } catch { return []; }
try {
sdk ||= await import("@tencent-ai/agent-sdk");
} catch { return []; }
query = sdk.query;
}
const queryAbortController = new AbortController();
@@ -740,13 +849,13 @@ async function listCodebuddyModels({
}
// ---------------------------------------------------------------------------
// Hooks (SDK 0.3.230 lifecycle callbacks)
// Hooks (SDK 0.3.258 lifecycle callbacks)
// ---------------------------------------------------------------------------
/**
* Build SDK hooks option from an emitter. Registers PreToolUse / PostToolUse /
* PostToolUseFailure / SessionEnd / Notification hooks that forward events to
* the renderer via the existing emitter event channel.
* PostToolUseFailure / SessionEnd / Notification / PostCompact hooks that
* forward events to the renderer via the existing emitter event channel.
* @param {object} emitter createStreamEmitter(...)
* @returns {object} hooks map suitable for Options.hooks
*/
@@ -825,6 +934,12 @@ function buildCodebuddyHooks(
title: input.title,
notificationType: input.notification_type,
})),
// SDK 0.3.258 — fires after context compaction, carrying the summary that
// was written. Distinct from PreCompact (which the SDK also exposes).
PostCompact: makeHook("PostCompact", (input) => ({
trigger: input.trigger,
compactSummary: input.compact_summary,
})),
};
for (const [eventName, matchers] of Object.entries(additionalHooks || {})) {
if (!Array.isArray(matchers) || matchers.length === 0) continue;
@@ -834,7 +949,7 @@ function buildCodebuddyHooks(
}
// ---------------------------------------------------------------------------
// Elicitation handler (SDK 0.3.230 — interactive confirmations)
// Elicitation handler (SDK 0.3.258 — interactive confirmations)
// ---------------------------------------------------------------------------
/**
@@ -930,7 +1045,7 @@ function buildCodebuddyElicitation(emitter, pendingMap, { chatSessionId } = {})
}
// ---------------------------------------------------------------------------
// MCP server status & account info (SDK 0.3.230)
// MCP server status & account info (SDK 0.3.258)
// ---------------------------------------------------------------------------
/**
@@ -998,7 +1113,7 @@ async function getCodebuddyAccountInfo({ pathToCodebuddyCode, env, queryFn }) {
}
// ---------------------------------------------------------------------------
// Plugin management (SDK 0.3.230)
// Plugin management (SDK 0.3.258)
// ---------------------------------------------------------------------------
async function codebuddyInstallPlugin(options) {
@@ -6,6 +6,7 @@ const {
buildCodebuddyCanUseTool,
buildCodebuddyPromptInput,
codebuddyBuiltinTools,
listCodebuddyModels,
mapCodebuddyModels,
runCodebuddyTurn,
translateCodebuddyMessage,
@@ -426,7 +427,7 @@ test("mapCodebuddyModels maps model ids and drops invalid entries", () => {
id: "glm-5.1",
name: "GLM-5.1",
description: undefined,
thinkingLevels: ["low", "medium", "high", "xhigh"],
thinkingLevels: ["minimal", "low", "medium", "high", "xhigh", "max"],
defaultThinkingLevel: "medium",
encodeDefaultThinking: false,
},
@@ -434,7 +435,7 @@ test("mapCodebuddyModels maps model ids and drops invalid entries", () => {
id: "cb-1",
name: "CodeBuddy 1",
description: "default",
thinkingLevels: ["low", "medium", "high", "xhigh"],
thinkingLevels: ["minimal", "low", "medium", "high", "xhigh", "max"],
defaultThinkingLevel: "medium",
encodeDefaultThinking: false,
},
@@ -442,7 +443,7 @@ test("mapCodebuddyModels maps model ids and drops invalid entries", () => {
id: "cb-2",
name: "CodeBuddy 2",
description: undefined,
thinkingLevels: ["low", "medium", "high", "xhigh"],
thinkingLevels: ["minimal", "low", "medium", "high", "xhigh", "max"],
defaultThinkingLevel: "medium",
encodeDefaultThinking: false,
},
@@ -450,11 +451,77 @@ test("mapCodebuddyModels maps model ids and drops invalid entries", () => {
assert.deepEqual(mapCodebuddyModels(null), []);
});
test("mapCodebuddyModels honours model-declared effort capabilities", () => {
const [declared, fallback] = mapCodebuddyModels([
{
id: "glm-5.1",
name: "GLM-5.1",
reasoning: { supportedEfforts: ["high", "low"], defaultEffort: "high" },
},
{ id: "cb-1", name: "CodeBuddy 1" },
]);
// Declared levels are kept and re-ordered to the canonical union order; the
// declared default wins over the generic "medium" fallback.
assert.deepEqual(declared.thinkingLevels, ["low", "high"]);
assert.equal(declared.defaultThinkingLevel, "high");
// No declaration → the full Effort union with the medium default.
assert.deepEqual(fallback.thinkingLevels, ["minimal", "low", "medium", "high", "xhigh", "max"]);
assert.equal(fallback.defaultThinkingLevel, "medium");
});
test("mapCodebuddyModels ignores unusable effort declarations", () => {
const models = mapCodebuddyModels([
{ id: "m1", reasoning: { supportedEfforts: ["turbo"] } },
{ id: "m2", reasoning: { supportedEfforts: [] } },
{ id: "m3", reasoning: "garbage" },
]);
for (const model of models) {
assert.deepEqual(model.thinkingLevels, ["minimal", "low", "medium", "high", "xhigh", "max"]);
assert.equal(model.defaultThinkingLevel, "medium");
}
});
test("mapCodebuddyModels picks a usable default when the declaration omits medium", () => {
const [model] = mapCodebuddyModels([
{ id: "m1", reasoning: { supportedEfforts: ["max", "minimal"], defaultEffort: "bogus" } },
]);
assert.deepEqual(model.thinkingLevels, ["minimal", "max"]);
// Unusable declared default + medium absent → fall back to the lowest level.
assert.equal(model.defaultThinkingLevel, "minimal");
});
test("listCodebuddyModels uses V2 raw model capabilities when available", async () => {
let closed = false;
const models = await listCodebuddyModels({
pathToCodebuddyCode: "/usr/local/bin/codebuddy",
env: { CODEBUDDY_ENV: "test" },
createSessionFn: (options) => {
assert.equal(options.pathToCodebuddyCode, "/usr/local/bin/codebuddy");
assert.deepEqual(options.env, { CODEBUDDY_ENV: "test" });
return {
async getAvailableModelsRaw() {
return [{
value: "glm-5.1",
displayName: "GLM-5.1",
reasoning: { supportedEfforts: ["high", "low"], defaultEffort: "high" },
}];
},
close() { closed = true; },
};
},
});
assert.equal(closed, true);
assert.equal(models[0].id, "glm-5.1");
assert.deepEqual(models[0].thinkingLevels, ["low", "high"]);
assert.equal(models[0].defaultThinkingLevel, "high");
});
// ---------------------------------------------------------------------------
// SDK 0.3.230 options
// SDK 0.3.258 options
// ---------------------------------------------------------------------------
test("buildCodebuddyQueryOptions passes SDK 0.3.230 options", () => {
test("buildCodebuddyQueryOptions passes SDK 0.3.258 options", () => {
const opts = buildCodebuddyQueryOptions({
cwd: "/tmp",
env: {},
@@ -569,6 +636,7 @@ test("buildCodebuddyHooks returns hook matchers that emit events", async () => {
assert.ok(Array.isArray(hooks.PostToolUseFailure));
assert.ok(Array.isArray(hooks.SessionEnd));
assert.ok(Array.isArray(hooks.Notification));
assert.ok(Array.isArray(hooks.PostCompact));
// Invoke PreToolUse hook callback
const preHook = hooks.PreToolUse[0].hooks[0];
@@ -583,6 +651,23 @@ test("buildCodebuddyHooks returns hook matchers that emit events", async () => {
assert.equal(events[0].ev.toolName, "Bash");
});
test("buildCodebuddyHooks forwards PostCompact with the compaction summary", async () => {
const { events, emitter } = collector();
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
const hooks = buildCodebuddyHooks(emitter);
const postCompact = hooks.PostCompact[0].hooks[0];
const result = await postCompact(
{ hook_event_name: "PostCompact", trigger: "auto", compact_summary: "summarised history" },
undefined,
{ signal: new AbortController().signal },
);
assert.deepEqual(result, { continue: true });
assert.equal(events[0].ev.hookEvent, "PostCompact");
assert.equal(events[0].ev.trigger, "auto");
assert.equal(events[0].ev.compactSummary, "summarised history");
});
test("buildCodebuddyHooks blocks non-Netcatty Bash commands in skills mode", async () => {
const { emitter } = collector();
emitter.emitEvent = () => {};
@@ -47,6 +47,7 @@ function computeOptionsFingerprint(sessionOptions) {
agents: sessionOptions.agents,
thinking: sessionOptions.thinking,
effort: sessionOptions.effort,
persistSession: sessionOptions.persistSession,
hasHooks: Boolean(sessionOptions.hooks),
hasCanUseTool: typeof sessionOptions.canUseTool === "function",
hasElicitation: Boolean(sessionOptions.elicitation),
@@ -337,7 +338,7 @@ class CodebuddySessionManager {
* Report mid-turn steer as unsupported for the current V2 Session API.
*/
async steer() {
// SDK 0.3.230 Session.send() starts a new turn by resetting the shared
// SDK 0.3.258 Session.send() starts a new turn by resetting the shared
// message iterator and discarding pending messages. Calling it while
// runTurn() owns session.stream() can strand that active consumer.
// Keep this disabled until the SDK exposes a dedicated mid-turn steer API.
@@ -227,6 +227,7 @@ test("computeOptionsFingerprint detects option changes", () => {
const diffModel = { cwd: "/tmp", model: "glm-4", maxTurns: 10, effort: "high" };
const diffMaxTurns = { cwd: "/tmp", model: "glm-5", maxTurns: 20, effort: "high" };
const diffEffort = { cwd: "/tmp", model: "glm-5", maxTurns: 10, effort: "low" };
const diffPersistSession = { ...base, persistSession: false };
const diffExtraArgs = {
...base,
extraArgs: { "dangerously-skip-permissions": "false" },
@@ -235,6 +236,7 @@ test("computeOptionsFingerprint detects option changes", () => {
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffModel));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffMaxTurns));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffEffort));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffPersistSession));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffExtraArgs));
});
+5 -1
View File
@@ -186,7 +186,7 @@ const DRIVER_REGISTRY = {
resume: ctx.resumeSessionId,
pathToCodebuddyCode: ctx.binPath,
toolIntegrationMode: ctx.toolIntegrationMode,
// SDK 0.3.230 options
// SDK 0.3.258 options
systemPrompt: ctx.systemPrompt,
effort: ctx.effort,
maxTurns: ctx.maxTurns,
@@ -198,6 +198,7 @@ const DRIVER_REGISTRY = {
enableFileCheckpointing: ctx.enableFileCheckpointing,
traceId: ctx.traceId,
parentSpanId: ctx.parentSpanId,
persistSession: ctx.persistSession,
hooks: codebuddy.buildCodebuddyHooks(ctx.emitter, {
toolIntegrationMode: ctx.toolIntegrationMode,
additionalHooks: ctx.hooks,
@@ -239,6 +240,9 @@ const DRIVER_REGISTRY = {
agents: options.agents,
thinking: options.thinking,
effort: options.effort,
// SessionOptions gained persistSession in SDK 0.3.258; without this the
// V2 path would silently drop the setting (query() already forwards it).
persistSession: options.persistSession,
};
const v2Result = await codebuddySessionManager.runTurn({
sessionKey,
@@ -523,10 +523,10 @@ function registerSdkStreamHandlers(ctx) {
model, existingSessionId, toolIntegrationMode,
defaultTargetSession, userSkillsContext, agentEnv: requestedAgentEnv, agentCommand,
codexRuntime: requestedCodexRuntime, permissionMode,
// SDK 0.3.230 advanced options (passed from renderer via sdkAgentAdapter)
// SDK 0.3.258 advanced options (passed from renderer via sdkAgentAdapter)
effort, maxTurns, maxBudgetUsd, fallbackModel,
sandbox, agents, outputFormat, enableFileCheckpointing,
traceId, parentSpanId,
traceId, parentSpanId, persistSession,
} = payload;
const backendKey = resolveBackendKey(sdkBackend);
@@ -793,7 +793,7 @@ function registerSdkStreamHandlers(ctx) {
// when the CLI hits a security restriction, route the decision
// through the renderer approval UI instead of throwing an error.
requestApprovalFromRenderer: mcpServerBridge.requestApprovalFromRenderer,
// SDK 0.3.230 advanced options
// SDK 0.3.258 advanced options
effort: effort || undefined,
maxTurns: maxTurns || undefined,
maxBudgetUsd: maxBudgetUsd || undefined,
@@ -804,6 +804,7 @@ function registerSdkStreamHandlers(ctx) {
enableFileCheckpointing: enableFileCheckpointing != null ? enableFileCheckpointing : undefined,
traceId: traceId || undefined,
parentSpanId: parentSpanId || undefined,
persistSession: persistSession != null ? persistSession : undefined,
};
const result = codexRuntime === "app-server"
? await codexAppServerRuntime.runTurn(commonTurnContext)
@@ -979,7 +980,7 @@ function registerSdkStreamHandlers(ctx) {
const runtime = sdkRequestRuntimes.get(requestId);
if (!runtime) return { status: "busy" };
// SDK 0.3.230 Session.send() resets the active message stream, so it
// SDK 0.3.258 Session.send() resets the active message stream, so it
// cannot safely implement mid-turn steering.
if (runtime.backendKey === "codebuddy") {
return { status: "unsupported" };
@@ -1096,7 +1097,7 @@ function registerSdkStreamHandlers(ctx) {
}
});
// --- CodeBuddy SDK 0.3.230 IPC handlers ---
// --- CodeBuddy SDK 0.3.258 IPC handlers ---
ipcMain.handle("netcatty:ai:sdk-agent:mcp-status", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
+7 -1
View File
@@ -635,7 +635,13 @@ function createSessionOpsApi(ctx) {
_rc_cwd=$(readlink "/proc/$1/cwd" 2>/dev/null)
if [ -n "$_rc_cwd" ]; then printf '%s\\n' "$_rc_cwd"; return 0; fi
if command -v lsof >/dev/null 2>&1; then
_rc_cwd=$(LC_ALL=C lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n1)
# An unknown-type record can put a readlink error in its name field.
# Only a confirmed directory name is usable as an upload destination.
_rc_cwd=$(LC_ALL=C lsof -a -p "$1" -d cwd -Fnt 2>/dev/null | awk '
/^f/ { is_dir=0 }
/^t/ { is_dir=($0 == "tDIR" || $0 == "tVDIR") }
/^n/ && is_dir { print substr($0, 2); exit }
')
if [ -n "$_rc_cwd" ]; then printf 'NETCATTY_LSOF_CWD=%s\\n' "$_rc_cwd"; return 0; fi
fi
return 1
@@ -29,7 +29,7 @@ function makeApi(session, siblingSessions = [], overrides = {}) {
sessions,
setTimeout: overrides.setTimeout || setTimeout,
clearTimeout: overrides.clearTimeout || clearTimeout,
quoteShellArg,
quoteShellArg: overrides.quoteShellArg || quoteShellArg,
log: () => {},
});
}
@@ -123,6 +123,47 @@ test("session cwd probe decodes the marked lsof pathname", async () => {
assert.deepEqual(result, { success: true, cwd: "/srv/中文" });
});
test("lsof cwd fallback accepts only directory records, not diagnostic names (#3237)", async () => {
let script;
const api = makeApi({
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: {
exec(_command, callback) {
callback(null, makePwdStream("/srv/app", "4242"));
},
},
}, [], {
quoteShellArg(value) {
if (!script && value.includes("read_shell_cwd()")) script = value;
return quoteShellArg(value);
},
});
await api.getSessionPwd(null, { sessionId: "session-1" });
const helper = script.match(/ read_shell_cwd\(\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(helper, "exercise the actual remote cwd helper");
const { spawnSync } = require("node:child_process");
for (const [output, expected] of [
["p4242\nfcwd\ntunknown\nn/proc/4242/cwd (readlink: Permission denied)\n", null],
["p4242\nfcwd\ntunknown\nn/proc/4242/cwd (readlink: No such file or directory)\n", null],
["p4242\nfcwd\nn/proc/4242/cwd\n", null],
["p4242\nfcwd\ntDIR\nn/srv/app\n", "/srv/app"],
["p4242\nfcwd\ntVDIR\nn/usr/home/alice\n", "/usr/home/alice"],
["p4242\nfcwd\ntDIR\nn/tmp/literal (readlink: Permission denied)\n", "/tmp/literal (readlink: Permission denied)"],
["p4242\nfcwd\ntDIR\nn/tmp/\\xe4\\xb8\\xad\\xe6\\x96\\x87\n", "/tmp/\\xe4\\xb8\\xad\\xe6\\x96\\x87"],
]) {
const result = spawnSync("sh", ["-c", `
readlink() { return 1; }
lsof() { printf '%s' "$LSOF_TEST_OUTPUT"; }
${helper}
read_shell_cwd 4242
`], { encoding: "utf8", env: { ...process.env, LSOF_TEST_OUTPUT: output } });
assert.equal(result.status, expected === null ? 1 : 0, output);
assert.equal(result.stdout, expected === null ? "" : `NETCATTY_LSOF_CWD=${expected}\n`, output);
}
});
test("session cwd probe closes a remote command that exceeds its timeout", async () => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
+1
View File
@@ -1899,6 +1899,7 @@ function createPreloadApi(ctx) {
fallbackModel: codebuddyOptions.fallbackModel,
sandbox: codebuddyOptions.sandbox,
enableFileCheckpointing: codebuddyOptions.enableFileCheckpointing,
persistSession: codebuddyOptions.persistSession,
}
: {};
return ipcRenderer.invoke("netcatty:ai:sdk-agent:stream", {
+3 -1
View File
@@ -1,3 +1,4 @@
import { decryptProviderHeaders } from './providerHeaderCredentials';
import { decryptField } from '../persistence/secureFieldAdapter';
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from './modelDiscoveryHeaders';
import { normalizeOllamaSdkBaseURL } from './ollamaCompatBaseUrl';
@@ -61,6 +62,7 @@ export function providerModelCacheKey(provider: ProviderConfig): string {
provider.baseURL ?? '',
provider.skipTLSVerify ? '1' : '0',
credentialFingerprint(provider.apiKey),
credentialFingerprint(JSON.stringify(provider.customHeaders)),
].join('|');
}
@@ -140,7 +142,7 @@ export async function fetchProviderModelCatalog(
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, endpoint);
const headers = buildModelDiscoveryHeaders(style, apiKey);
const headers = buildModelDiscoveryHeaders(style, apiKey, await decryptProviderHeaders(provider.customHeaders));
const result = await bridge.aiFetch(
url,
'GET',
@@ -573,3 +573,75 @@ test('an item id whose every fragment lacks ciphertext still discards the tool e
assert.equal(sdkMessages.length, 1);
assert.equal(sdkMessages[0].content, 'Running it.');
});
test('Anthropic-format signed thinking survives build and stream preparation', () => {
// Anthropic-compatible providers (including DeepSeek's /anthropic endpoint)
// validate signed thinking blocks: prior assistant turns must echo back
// content[type=thinking] with their signature on every request that carries
// tools, or they reject the turn with 400 "The `content[].thinking` in the
// thinking mode must be passed back to the API". The turn driver preserves
// reasoning for anthropic-style providers, so the signed thinking block
// replayed here must survive final stream preparation.
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: 'Running it.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
reasoningParts: [{
text: 'prior thinking',
providerOptions: { anthropic: { signature: 'sig-abc' } },
}],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-1', content: 'output' }],
},
{
id: 'assistant-2',
role: 'assistant',
content: 'All done.',
timestamp: 4,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
reasoningParts: [{
text: 'final thinking',
providerOptions: { anthropic: { signature: 'sig-xyz' } },
}],
},
},
];
const built = buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('anthropic-1', 'anthropic', 'deepseek-v4-flash'),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
const prepared = prepareCattyMessagesForStream(built, { preserveReasoning: true });
const toolExchangeContent = prepared[0].content;
assert.ok(Array.isArray(toolExchangeContent));
assert.deepEqual(
toolExchangeContent.map(part => part.type),
['reasoning', 'text', 'tool-call'],
);
const toolThinking = toolExchangeContent.find(part => part.type === 'reasoning');
assert.deepEqual(toolThinking?.providerOptions?.anthropic, { signature: 'sig-abc' });
const plainContent = prepared.at(-1).content;
assert.ok(Array.isArray(plainContent));
const plainThinking = plainContent.find(part => part.type === 'reasoning');
assert.equal(plainThinking?.text, 'final thinking');
assert.deepEqual(plainThinking?.providerOptions?.anthropic, { signature: 'sig-xyz' });
});
@@ -275,6 +275,16 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
),
);
const preserveStatelessResponsesReasoning = reasoningProviderOptions?.openai?.store === false;
// Anthropic-compatible providers validate signed thinking blocks: every
// content[type=thinking] block (with its signature) captured on prior
// assistant turns must be echoed back verbatim on subsequent requests that
// carry tools, or the API rejects the turn with "The `content[].thinking`
// in the thinking mode must be passed back to the API" (DeepSeek's
// Anthropic-format endpoint is strict about this even for earlier turns).
// Keep the reasoning parts buildCattySdkMessages replayed instead of
// pruning them like generic Chat Completions histories.
const preserveAnthropicThinkingReplay = resolveProviderStyle(context.activeProvider) === 'anthropic';
const preserveReasoningForStream = preserveStatelessResponsesReasoning || preserveAnthropicThinkingReplay;
const reasoningReserveTokens = estimateReasoningOutputReserve(reasoningProviderOptions);
// Fold thinking budget into compaction maxOutput only. reservedTokens is
// added to estimated input separately, so adding the budget there too
@@ -290,7 +300,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
const prepareMessagesForStream = (messages: ModelMessage[]): ModelMessage[] => {
const pruned = prepareCattyMessagesForStream(messages, {
preserveReasoning: preserveStatelessResponsesReasoning,
preserveReasoning: preserveReasoningForStream,
});
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
pruned,
@@ -103,6 +103,7 @@ test('plan updates replace the same activity across tool message boundaries', as
status: 'completed',
items: [{ text: 'Run command', completed: true }],
});
onEvent?.({ type: 'hook', hookEvent: 'PostCompact' });
onDone?.();
});
return { ok: true };
@@ -135,6 +136,7 @@ test('plan updates replace the same activity across tool message boundaries', as
reportStreamError: () => {},
setStreamingForScope: () => {},
getLatestSession: () => session,
translate: (key) => key === 'ai.chat.contextCompacted' ? 'Context compacted (localized)' : key,
};
const agentConfig: ExternalAgentConfig = {
id: 'codex',
@@ -183,6 +185,10 @@ test('plan updates replace the same activity across tool message boundaries', as
assert.deepEqual(planActivities[0].type === 'plan_update' ? planActivities[0].items : [], [
{ text: 'Run command', completed: true },
]);
assert.equal(
session.messages.findLast(message => message.role === 'assistant')?.statusText,
'Context compacted (localized)',
);
});
test('accepted steering persists a user bubble and routes buffered output to a continuation', async () => {
@@ -364,6 +364,16 @@ async function runExternalTurn(
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
});
}
} else if (hookEvent === 'PostCompact') {
// SDK 0.3.258 — history was summarised; tell the user why earlier
// detail is no longer present in this turn's context.
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
statusText: ui.translate?.('ai.chat.contextCompacted') ?? 'Context compacted',
}));
});
}
},
onElicitationCreate: (elicitationId: string, request: Record<string, unknown>) => {
@@ -49,6 +49,7 @@ export interface TurnUiCallbacks {
sessionId: string,
compaction: import('../../types').AISessionContextCompaction,
) => void;
translate?: (key: string) => string;
}
export interface CattyTurnContext {
@@ -57,3 +57,12 @@ test("resolveModelsDiscoveryEndpoint falls back to the preset path only when the
assert.equal(resolveModelsDiscoveryEndpoint("google", "/custom/list"), "/custom/list");
assert.equal(resolveModelsDiscoveryEndpoint("google", undefined), undefined);
});
test('custom discovery headers override default auth regardless of case', () => {
for (const [style, name] of [['openai', 'authorization'], ['anthropic', 'X-API-Key'], ['google', 'X-Goog-Api-Key']] as const) {
const headers = buildModelDiscoveryHeaders(style, 'default-key', { [name]: 'custom-key', 'X-Tenant': 'tenant' });
assert.equal(headers[name], 'custom-key');
assert.equal(Object.keys(headers).filter(key => key.toLowerCase() === name.toLowerCase()).length, 1);
assert.equal(headers['X-Tenant'], 'tenant');
}
});
+14 -1
View File
@@ -38,7 +38,7 @@ export function resolveModelsDiscoveryEndpoint(
* Returning an empty object when the key is missing lets the caller still
* issue an unauthenticated probe (e.g. against local Ollama).
*/
export function buildModelDiscoveryHeaders(
function buildDefaultModelDiscoveryHeaders(
style: ProviderStyle,
apiKey: string | undefined,
): Record<string, string> {
@@ -56,3 +56,16 @@ export function buildModelDiscoveryHeaders(
return { Authorization: `Bearer ${apiKey}` };
}
}
export function buildModelDiscoveryHeaders(
style: ProviderStyle,
apiKey: string | undefined,
customHeaders: Record<string, string> = {},
): Record<string, string> {
const headers = buildDefaultModelDiscoveryHeaders(style, apiKey);
const overridden = new Set(Object.keys(customHeaders).map((name) => name.toLowerCase()));
return Object.fromEntries([
...Object.entries(headers).filter(([name]) => !overridden.has(name.toLowerCase())),
...Object.entries(customHeaders),
]);
}
+2 -1
View File
@@ -154,6 +154,7 @@ export async function probeProviderConnection(input: {
bridge: ProviderProbeFetchBridge | undefined;
baseURL: string;
apiKey: string;
customHeaders?: Record<string, string>;
providerId: string;
style: ProviderStyle;
presetModelsEndpoint?: string;
@@ -181,7 +182,7 @@ export async function probeProviderConnection(input: {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, endpoint);
const headers = buildModelDiscoveryHeaders(input.style, input.apiKey);
const headers = buildModelDiscoveryHeaders(input.style, input.apiKey, input.customHeaders);
const result = await bridge.aiFetch(
url,
"GET",
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseProviderHeaderRows, encryptProviderHeaders, decryptProviderHeaders } from './providerHeaderCredentials';
const encrypted = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 1)]).toString('base64')}`;
function bridge(value: object) {
Object.defineProperty(globalThis, 'window', { value: { netcatty: value }, configurable: true });
}
test('header rows preserve values and reject ambiguous or invalid names and values', () => {
assert.deepEqual(parseProviderHeaderRows([{ name: ' X-Tenant ', value: ' tenant ' }, { name: '', value: '' }]), { 'X-Tenant': ' tenant ' });
for (const rows of [
[{ name: 'X-Tenant', value: 'a' }, { name: 'x-tenant', value: 'b' }],
[{ name: '', value: 'secret' }], [{ name: 'Bad Name', value: 'x' }],
[{ name: 'Content-Length', value: '1' }], [{ name: 'tRaNsFeR-EnCoDiNg', value: 'chunked' }],
[{ name: 'X-Tenant', value: 'a\r\nb' }], [{ name: 'X-Tenant', value: '中文' }],
]) assert.throws(() => parseProviderHeaderRows(rows));
assert.equal(Object.getOwnPropertyDescriptor(parseProviderHeaderRows([{ name: '__proto__', value: 'safe' }]), '__proto__')?.value, 'safe');
});
test('header values round trip using the credential bridge without plaintext persistence', async () => {
bridge({ credentialsEncrypt: async () => encrypted, credentialsDecrypt: async (value: string) => value === encrypted ? 'secret' : value });
const saved = await encryptProviderHeaders({ 'X-Tenant': 'secret', 'X-Empty': '' });
assert.deepEqual(saved, { 'X-Tenant': encrypted, 'X-Empty': '' });
assert.deepEqual(await decryptProviderHeaders(saved), { 'X-Tenant': 'secret', 'X-Empty': '' });
assert.deepEqual(await decryptProviderHeaders({ Legacy: 'plain' }), { Legacy: 'plain' });
});
test('missing or failed encryption blocks saving; unread encrypted headers block loading', async () => {
bridge({});
await assert.rejects(encryptProviderHeaders({ Authorization: 'secret' }));
await assert.rejects(decryptProviderHeaders({ Authorization: encrypted }));
bridge({ credentialsEncrypt: async () => { throw Error('locked'); }, credentialsDecrypt: async (value: string) => value });
await assert.rejects(encryptProviderHeaders({ Authorization: 'secret' }));
await assert.rejects(decryptProviderHeaders({ Authorization: encrypted }));
});
@@ -0,0 +1,42 @@
import { decryptFieldResult, encryptField } from '../persistence/secureFieldAdapter';
export type HeaderRow = { name: string; value: string };
/** Validate before requests as well as saving. HTTP names are case insensitive. */
export function parseProviderHeaderRows(rows: HeaderRow[]): Record<string, string> {
const entries: [string, string][] = [];
const names = new Set<string>();
for (const row of rows) {
if (!row.name && !row.value) continue;
const name = row.name.trim();
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)
|| /[^\t\x20-\x7e\x80-\xff]/.test(row.value)
|| ['content-length', 'transfer-encoding'].includes(name.toLowerCase())
|| names.has(name.toLowerCase())) {
throw new Error('Invalid or duplicate HTTP header');
}
names.add(name.toLowerCase());
entries.push([name, row.value]);
}
return Object.fromEntries(entries);
}
export async function encryptProviderHeaders(headers: Record<string, string>): Promise<Record<string, string>> {
return Object.fromEntries(await Promise.all(Object.entries(headers).map(async ([name, value]) => {
if (!value) return [name, value];
const encrypted = await encryptField(value);
// Unlike ordinary settings, never fall back to saving header secrets in plaintext.
if (!encrypted?.startsWith('enc:v1:') || encrypted === value) {
throw new Error('Secure header storage is unavailable');
}
return [name, encrypted];
})));
}
export async function decryptProviderHeaders(headers: Record<string, string> = {}): Promise<Record<string, string>> {
return Object.fromEntries(await Promise.all(Object.entries(headers).map(async ([name, value]) => {
const result = await decryptFieldResult(value);
if (result.unread) throw new Error('Unable to decrypt custom HTTP headers');
return [name, result.value ?? ''];
})));
}
+2 -2
View File
@@ -22,8 +22,8 @@ export function isOpencodeEndpoint(baseURL: string | undefined): boolean {
/**
* Build the extra SDK request headers for a provider config.
*
* - User-configured `customHeaders` always win verbatim (they were previously
* collected in settings but never applied).
* - User-configured `customHeaders` take precedence. Stored encrypted values are
* resolved by the main process before sending.
* - OpenCode endpoints get an automatic `x-opencode-session` header carrying
* the chat session id, so OpenCode Go can route the conversation. A
* user-supplied session header takes precedence over the automatic one.
+36
View File
@@ -581,3 +581,39 @@ test('runSdkAgentTurn forwards CodeBuddy options, hooks, and elicitation events'
]);
assert.deepEqual(errors, []);
});
test('runSdkAgentTurn strips unsupported CodeBuddy session persistence', async () => {
let streamArgs: unknown[] = [];
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiSdkAgentStream: async (...args: unknown[]) => {
streamArgs = args;
return { ok: true };
},
aiSdkAgentCancel: async () => ({ ok: true }),
onAiSdkAgentEvent: () => () => {},
onAiSdkAgentDone: (_requestId: unknown, callback: unknown) => {
queueMicrotask(callback as () => void);
return () => {};
},
onAiSdkAgentError: () => () => {},
};
await runSdkAgentTurn(
bridge,
'request-codebuddy-old',
'chat-codebuddy-old',
{
id: 'codebuddy-old',
name: 'CodeBuddy',
command: 'codebuddy',
enabled: true,
sdkBackend: 'codebuddy',
cliVersion: '2.125.0',
codebuddyOptions: { persistSession: false },
},
'hello',
createCallbacks([]),
);
assert.equal(streamArgs[17], undefined);
});
+14 -1
View File
@@ -12,6 +12,7 @@ import type {
AIToolIntegrationMode,
ExternalAgentConfig,
} from './types';
import { isCodebuddySessionPersistenceSupported } from './types';
import { getExternalAgentSdkBackend, getManualAgentCommand } from './managedAgents';
import { encodeSdkSessionIdentity } from './harness/sdkSessionIdentity';
import { mapSdkStreamEventToAgentEvents } from './harness/agentEventAdapter';
@@ -98,6 +99,18 @@ interface StreamEvent {
[key: string]: unknown;
}
function getCodebuddySdkOptions(config: ExternalAgentConfig): ExternalAgentConfig['codebuddyOptions'] {
const options = config.codebuddyOptions;
if (!options || options.persistSession !== false) return options;
if (isCodebuddySessionPersistenceSupported(config.cliVersion)) return options;
// The SDK translates false to --no-session-persistence. Do not send that
// flag to an older or unprobed CLI; undefined preserves its default behavior.
const supportedOptions = { ...options };
delete supportedOptions.persistSession;
return Object.keys(supportedOptions).length > 0 ? supportedOptions : undefined;
}
export interface FileAttachment {
base64Data: string;
mediaType: string;
@@ -353,7 +366,7 @@ export async function runSdkAgentTurn(
agentCommand,
sdkBackend === 'codex' ? (config.codexRuntime ?? 'sdk') : undefined,
permissionMode,
sdkBackend === 'codebuddy' ? config.codebuddyOptions : undefined,
sdkBackend === 'codebuddy' ? getCodebuddySdkOptions(config) : undefined,
).then((result) => {
if (result?.ok === false) {
settle(() => {
+11 -1
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import {
CLAUDE_MODEL_PRESETS,
CODEBUDDY_MODEL_PRESETS,
CODEBUDDY_PERSIST_SESSION_MIN_CLI_VERSION,
CODEX_GPT_5_6_MIN_CLI_VERSION,
CODEX_MODEL_PRESETS,
CURSOR_MODEL_PRESETS,
@@ -14,6 +15,7 @@ import {
resolveAgentCliVersion,
resolveAgentModelSelection,
resolveDiscoveredAgentCliVersion,
isCodebuddySessionPersistenceSupported,
} from './types';
test('Claude presets advertise effort levels separately from the model id', () => {
@@ -44,10 +46,18 @@ test('getAgentModelPresets returns Cursor presets for cursor-agent paths and sdk
);
assert.deepEqual(
getAgentModelPresets(undefined, 'codebuddy')[0]?.thinkingLevels,
['low', 'medium', 'high', 'xhigh'],
['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
);
});
test('CodeBuddy session persistence is gated by CLI version', () => {
assert.equal(CODEBUDDY_PERSIST_SESSION_MIN_CLI_VERSION, '2.125.1');
assert.equal(isCodebuddySessionPersistenceSupported('CodeBuddy 2.125.0'), false);
assert.equal(isCodebuddySessionPersistenceSupported('CodeBuddy 2.125.1'), true);
assert.equal(isCodebuddySessionPersistenceSupported('2.126.0'), true);
assert.equal(isCodebuddySessionPersistenceSupported(undefined), false);
});
test('getAgentModelPresets keeps Codex presets separate from CodeBuddy presets', () => {
assert.deepEqual(getAgentModelPresets('codex'), CODEX_MODEL_PRESETS);
assert.notDeepEqual(CODEBUDDY_MODEL_PRESETS, CODEX_MODEL_PRESETS);
+26 -6
View File
@@ -63,7 +63,7 @@ export interface ProviderConfig {
apiKey?: string; // encrypted via credentialBridge (enc:v1: prefix)
baseURL?: string; // custom endpoint URL
defaultModel?: string;
customHeaders?: Record<string, string>;
customHeaders?: Record<string, string>; // values encrypted via credentialBridge; decrypted at request boundary
enabled: boolean;
skipTLSVerify?: boolean; // skip TLS certificate verification (for self-signed certs)
/** User override for the model context window, in tokens. Wins over discovered model metadata. */
@@ -306,14 +306,14 @@ export interface ExternalAgentConfig {
acpArgs?: string[];
/** Internal: disabled only because the managed CLI was unavailable. */
autoDisabledUntilAvailable?: boolean;
/** CodeBuddy-specific advanced options (SDK 0.3.230). */
/** CodeBuddy-specific advanced options (SDK 0.3.258). */
codebuddyOptions?: CodebuddyAdvancedOptions;
}
/** Advanced options specific to the CodeBuddy backend (SDK 0.3.230+). */
/** Advanced options specific to the CodeBuddy backend (SDK 0.3.258+). */
export interface CodebuddyAdvancedOptions {
/** Effort level for model reasoning. */
effort?: 'low' | 'medium' | 'high' | 'xhigh';
/** Effort level for model reasoning (SDK 0.3.258 Effort union). */
effort?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
/** Maximum conversation turns per request. */
maxTurns?: number;
/** Maximum budget in USD per request. */
@@ -324,6 +324,14 @@ export interface CodebuddyAdvancedOptions {
enableFileCheckpointing?: boolean;
/** Fallback model when primary is unavailable. */
fallbackModel?: string;
/**
* Persist the session transcript to disk. Defaults to true; set false to keep
* the conversation in memory only (CodeBuddy writes no session data under
* its config dir, and file checkpointing is skipped). Netcatty chat history
* and external session metadata are unaffected. Requires CodeBuddy CLI >=
* 2.125.1.
*/
persistSession?: boolean;
}
// Discovered agent from system PATH
@@ -587,6 +595,9 @@ const CODEX_REASONING_LEVELS_5_6_LUNA = ['low', 'medium', 'high', 'xhigh', 'max'
/** Official catalog `minimal_client_version` for GPT-5.6 family. */
export const CODEX_GPT_5_6_MIN_CLI_VERSION = '0.144.0';
/** Minimum CodeBuddy CLI version that supports `persistSession: false`. */
export const CODEBUDDY_PERSIST_SESSION_MIN_CLI_VERSION = '2.125.1';
function codexPreset(
id: string,
name: string,
@@ -666,6 +677,15 @@ export function filterAgentModelPresetsForCliVersion(
});
}
/** Return whether the installed CodeBuddy CLI accepts session persistence options. */
export function isCodebuddySessionPersistenceSupported(
cliVersionText: string | null | undefined,
): boolean {
const cliSemver = extractCliSemver(cliVersionText);
return cliSemver !== null
&& compareSemverCore(cliSemver, CODEBUDDY_PERSIST_SESSION_MIN_CLI_VERSION) >= 0;
}
/** Resolve the model id (with optional /effort) for auto-selection. */
export function resolveAgentModelSelection(preset: AgentModelPreset): string {
const levels = preset.thinkingLevels;
@@ -781,7 +801,7 @@ export const CURSOR_MODEL_PRESETS: AgentModelPreset[] = [
// CodeBuddy's SDK model enumeration can be empty depending on CLI/account
// state; keep a CLI-supported fallback list so users can still pass --model.
const CODEBUDDY_REASONING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const;
const CODEBUDDY_REASONING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
function codebuddyPreset(id: string, name: string): AgentModelPreset {
return {
+16 -5
View File
@@ -123,7 +123,7 @@
"@cursor/sdk": "^1.0.18",
"@github/copilot-sdk": "1.0.0",
"@opencode-ai/sdk": "^1.17.9",
"@tencent-ai/agent-sdk": "^0.3.230",
"@tencent-ai/agent-sdk": "^0.3.258",
"@vscode/windows-process-tree": "^0.7.0"
}
},
@@ -8614,14 +8614,15 @@
}
},
"node_modules/@tencent-ai/agent-sdk": {
"version": "0.3.230",
"resolved": "https://registry.npmjs.org/@tencent-ai/agent-sdk/-/agent-sdk-0.3.230.tgz",
"integrity": "sha512-Jc4tvpCrNvqra8sY8Vf+NEODsPcHj42RK3/TGAa35brzpNWPMydSb5BATdq/NLKLBoAqn8KJsS+Day3ItQCFTQ==",
"version": "0.3.258",
"resolved": "https://registry.npmjs.org/@tencent-ai/agent-sdk/-/agent-sdk-0.3.258.tgz",
"integrity": "sha512-3m3ildYkb965g/7zGeYoniA1Jeylpv19nUswvHrrxXixpIKePgYqNqTYHVIG24TBBp632/xBs5Gn2Fu+o/9rBQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@agentclientprotocol/sdk": "^0.25.0",
"@modelcontextprotocol/sdk": "^1.29.0"
"@modelcontextprotocol/sdk": "^1.29.0",
"uuidv7": "^1.2.1"
},
"peerDependencies": {
"zod": "^4.0.0"
@@ -19678,6 +19679,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuidv7": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.2.1.tgz",
"integrity": "sha512-4kPkK3/XTQW9Hbm4CaqfICn+kY9LJtDVEOfgsRRra/+n2Ofg4NqzRFceAkxvQ/Ud/6BpHOPzj8cirqM7TzTN5Q==",
"license": "Apache-2.0",
"optional": true,
"bin": {
"uuidv7": "cli.js"
}
},
"node_modules/uvu": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
+1 -1
View File
@@ -176,7 +176,7 @@
"@cursor/sdk": "^1.0.18",
"@github/copilot-sdk": "1.0.0",
"@opencode-ai/sdk": "^1.17.9",
"@tencent-ai/agent-sdk": "^0.3.230",
"@tencent-ai/agent-sdk": "^0.3.258",
"@vscode/windows-process-tree": "^0.7.0"
},
"overrides": {