Merge remote-tracking branch 'origin/main' into feat/nested-git-repos

# Conflicts:
#	packages/ui/src/components/views/GitView.tsx
#	packages/ui/src/stores/DOCUMENTATION.md
#	packages/ui/src/stores/useGitStore.ts
This commit is contained in:
jaygupta17
2026-08-30 09:48:35 +05:30
640 changed files with 46571 additions and 5636 deletions
+57
View File
@@ -8,9 +8,66 @@ import {
} from '@/components/chat/message/selectionMarkdown';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { shortcutRegistry } from '@/lib/shortcuts';
const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]';
interface ActiveSelectionToolbarActions {
addToChat: () => void;
dismiss: () => void;
}
interface ActiveSelectionToolbarRegistration extends ActiveSelectionToolbarActions {
resumeGlobalShortcuts: () => void;
}
const activeSelectionToolbarRegistrations: ActiveSelectionToolbarRegistration[] = [];
let activeSelectionToolbarVersion = 0;
const releaseSelectionToolbar = (registration: ActiveSelectionToolbarRegistration): void => {
const index = activeSelectionToolbarRegistrations.indexOf(registration);
if (index === -1) return;
activeSelectionToolbarRegistrations.splice(index, 1);
registration.resumeGlobalShortcuts();
activeSelectionToolbarVersion += 1;
};
export const registerActiveSelectionToolbar = (
actions: ActiveSelectionToolbarActions,
): (() => void) => {
const registration: ActiveSelectionToolbarRegistration = {
...actions,
resumeGlobalShortcuts: shortcutRegistry.suspend(),
};
activeSelectionToolbarRegistrations.push(registration);
activeSelectionToolbarVersion += 1;
return () => releaseSelectionToolbar(registration);
};
export const hasActiveSelectionToolbar = (): boolean => activeSelectionToolbarRegistrations.length > 0;
export const getActiveSelectionToolbarVersion = (): number => activeSelectionToolbarVersion;
export const invokeActiveSelectionAddToChat = (): boolean => {
const registration = activeSelectionToolbarRegistrations.at(-1);
if (!registration) return false;
releaseSelectionToolbar(registration);
registration.addToChat();
return true;
};
export const dismissActiveSelectionToolbar = (): boolean => {
const registration = activeSelectionToolbarRegistrations.at(-1);
if (!registration) return false;
releaseSelectionToolbar(registration);
registration.dismiss();
return true;
};
const isInsideChatComposer = (node: Node | null): boolean => {
if (!node) {
return false;
+196
View File
@@ -807,6 +807,8 @@ export interface VSCodeAPI {
pickFiles?(options?: { extensions?: string[] }): Promise<unknown>;
saveImage?(payload: unknown): Promise<unknown>;
saveMarkdown?(payload: unknown): Promise<unknown>;
/** Add a directory as a VS Code workspace folder; resolves with the full folder list after the add. */
addWorkspaceFolder?(path: string): Promise<Array<{ name: string; path: string }>>;
}
export interface PushSubscribePayload {
@@ -1143,6 +1145,199 @@ export type GitHubDeviceFlowComplete =
| { connected: true; user: GitHubUserSummary; scope?: string }
| { connected: false; status?: string; error?: string };
export type LinearUserSummary = {
id: string;
name: string | null;
displayName: string | null;
email: string | null;
avatarUrl: string | null;
};
export type LinearOrganizationSummary = {
id: string;
name: string;
urlKey: string | null;
};
export type LinearWorkspaceSummary = {
id: string;
name: string | null;
urlKey: string | null;
current: boolean;
user?: LinearUserSummary | null;
authorizedAt?: number | null;
};
export type LinearAuthStatus = {
connected: boolean;
user?: LinearUserSummary | null;
organization?: LinearOrganizationSummary | null;
scope?: string;
workspaces?: LinearWorkspaceSummary[];
};
export type LinearAuthStart = {
authorizationUrl: string;
expiresIn: number;
scope: string;
};
export type LinearAuthOrigin = 'desktop' | 'web';
export type LinearIssueState = {
id: string | null;
name: string | null;
type: string | null;
};
export type LinearWorkflowState = {
id: string;
name: string;
type: string | null;
position: number;
};
export type LinearIssueAssignee = {
name: string | null;
displayName: string | null;
avatarUrl: string | null;
};
export type LinearIssueTeam = {
id: string;
key: string;
name: string;
};
export type LinearIssuePriority = 0 | 1 | 2 | 3 | 4;
export type LinearIssueLabel = {
id: string;
name: string;
color: string | null;
};
export type LinearIssueSummary = {
id: string;
identifier: string;
title: string;
url: string;
state?: LinearIssueState | null;
assignee?: LinearIssueAssignee | null;
team?: LinearIssueTeam | null;
priority?: LinearIssuePriority | null;
labels?: LinearIssueLabel[];
};
export type LinearIssueComment = {
id: string;
body: string;
createdAt: string | null;
user?: { name: string | null; displayName: string | null; avatarUrl?: string | null } | null;
};
export type LinearIssue = LinearIssueSummary & {
description?: string | null;
comments?: LinearIssueComment[];
};
export type LinearIssueListStatus = 'all' | 'backlog' | 'todo' | 'started' | 'inReview' | 'completed' | 'canceled' | 'duplicate';
export type LinearIssueListAssignee = 'any' | 'me';
export type LinearIssueListPriority = 'all' | 'none' | 'urgent' | 'high' | 'medium' | 'low';
export type LinearIssuesListOptions = {
query?: string;
cursor?: string;
status?: LinearIssueListStatus;
assignee?: LinearIssueListAssignee;
teamId?: string;
priority?: LinearIssueListPriority;
};
export type LinearIssuesListResult = {
connected: boolean;
issues?: LinearIssueSummary[];
cursor?: string | null;
hasMore?: boolean;
};
export type LinearIssueGetResult = {
connected: boolean;
issue?: LinearIssue | null;
};
export type LinearIssueStatesResult = {
connected: boolean;
states?: LinearWorkflowState[];
};
export type LinearIssueUpdateInput = {
id: string;
stateId: string;
};
export type LinearIssueUpdateResult = {
connected: boolean;
issue?: LinearIssue | null;
};
export type LinearTeamMapping = {
id: string;
key: string;
name: string;
projectPath: string | null;
};
export type LinearMappingResult = {
connected: boolean;
defaultProjectPath?: string | null;
teams?: LinearTeamMapping[];
};
export type LinearMappingWrite = {
defaultProjectPath: string | null;
teamProjectPaths: { [teamId: string]: string };
};
export type LinearSessionStatusKind = 'started' | 'completed' | 'failure';
export type LinearSessionStatusPostInput = {
kind: LinearSessionStatusKind;
sessionId: string;
issueIdentifier?: string;
sessionOrigin?: string;
};
export type LinearSessionStatusPostResult =
| { connected: false }
| { connected: true; posted: true; commentId: string | null }
| {
connected: true;
posted: false;
skipped: 'already-posted' | 'issue-not-found' | 'not-started' | 'disabled' | 'origin-not-public';
};
export type LinearPreferences = {
/** Status comments are off until the user opts in. */
sessionComments: boolean;
};
export interface LinearAPI {
authStatus(): Promise<LinearAuthStatus>;
authStart(origin?: LinearAuthOrigin): Promise<LinearAuthStart>;
authDisconnect(): Promise<{ removed: boolean }>;
authActivate(organizationId: string): Promise<LinearAuthStatus>;
issuesList(options?: LinearIssuesListOptions): Promise<LinearIssuesListResult>;
issueGet(id: string): Promise<LinearIssueGetResult>;
issueStates(teamId: string): Promise<LinearIssueStatesResult>;
issueUpdate(input: LinearIssueUpdateInput): Promise<LinearIssueUpdateResult>;
mappingGet(): Promise<LinearMappingResult>;
mappingSet(mapping: LinearMappingWrite): Promise<LinearMappingResult>;
sessionStatusPost(input: LinearSessionStatusPostInput): Promise<LinearSessionStatusPostResult>;
preferencesGet(): Promise<LinearPreferences>;
preferencesSet(preferences: LinearPreferences): Promise<LinearPreferences>;
}
export interface GitHubAPI {
authStatus(): Promise<GitHubAuthStatus>;
authStart(): Promise<GitHubDeviceFlowStart>;
@@ -1267,6 +1462,7 @@ export interface RuntimeAPIs {
permissions: PermissionsAPI;
notifications: NotificationsAPI;
github?: GitHubAPI;
linear?: LinearAPI;
push?: PushAPI;
diagnostics?: DiagnosticsAPI;
clientAuth?: ClientAuthAPI;
+87 -2
View File
@@ -16,6 +16,7 @@ const upsertedSessions: unknown[] = [];
const childStoreSessions: Session[] = [];
const currentSessionSwitches: string[] = [];
const metadataPatches: Array<{ sessionId: string; result: Record<string, unknown> }> = [];
const parentSyncMessages: Message[] = [];
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
@@ -48,6 +49,7 @@ mock.module('@/stores/useGlobalSessionsStore', () => ({
}));
mock.module('@/sync/sync-refs', () => ({
registerSessionDirectory: (sessionId: string, directory: string) => { registeredDirectories.push(`${sessionId}:${directory}`); },
getSyncMessages: () => parentSyncMessages,
getSyncChildStores: () => ({
children: new Map([['/project', {
getState: () => ({ session: childStoreSessions }),
@@ -56,7 +58,7 @@ mock.module('@/sync/sync-refs', () => ({
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages } =
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
@@ -74,6 +76,15 @@ const record = (id: string): { info: Message; parts: Part[] } => ({
parts: [],
});
// SAFETY: `findLastCompletedAssistantMessageID` reads only `id`, `role` and
// `time`, which are the fields spelled out here.
const assistantMessage = (id: string, completed?: number) =>
({ id, sessionID: 'parent-1', role: 'assistant', time: { created: 1, completed } }) as Message;
// SAFETY: same narrow read as `assistantMessage`.
const userMessage = (id: string) =>
({ id, sessionID: 'parent-1', role: 'user', time: { created: 1 } }) as Message;
const startInput = {
parentSessionId: 'parent-1',
question: 'wtf is kafka',
@@ -90,6 +101,7 @@ beforeEach(() => {
childStoreSessions.length = 0;
currentSessionSwitches.length = 0;
metadataPatches.length = 0;
parentSyncMessages.length = 0;
useBtwStore.setState({ byParent: {} });
forkSessionImpl = () => Promise.reject(new Error('no forkSession stub'));
getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]);
@@ -121,6 +133,17 @@ describe('filterBtwTailMessages', () => {
});
});
describe('findLastCompletedAssistantMessageID', () => {
test('skips an assistant turn that is still streaming', () => {
const messages = [assistantMessage('msg-1', 10), userMessage('msg-2'), assistantMessage('msg-3')];
expect(findLastCompletedAssistantMessageID(messages)).toBe('msg-1');
});
test('a session with no completed assistant turn has no fork point', () => {
expect(findLastCompletedAssistantMessageID([userMessage('msg-1')])).toBe(null);
});
});
describe('startBtwSession', () => {
test('forks, marks the fork, links the parent, and routes the question to the fork', async () => {
forkSessionImpl = (sessionId, messageId, directory) => {
@@ -151,6 +174,45 @@ describe('startBtwSession', () => {
expect(useBtwStore.getState().byParent).toEqual({});
});
test('forks at the last completed assistant turn, not at the in-flight one', async () => {
parentSyncMessages.push(assistantMessage('msg-1', 10), userMessage('msg-2'), assistantMessage('msg-3'));
const forkPoints: Array<string | undefined> = [];
forkSessionImpl = (_sessionId, messageId) => {
forkPoints.push(messageId);
return Promise.resolve(makeSession('fork-1', '/project'));
};
await startBtwSession(startInput);
expect(forkPoints).toEqual(['msg-1']);
});
test('the boundary falls back to the fork point when the cloned tail reads empty', async () => {
parentSyncMessages.push(assistantMessage('msg-1', 10));
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.resolve([]);
await startBtwSession(startInput);
// Not `null`: a null boundary would show the whole inherited transcript.
expect(metadataPatches[0]?.result).toEqual({
openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' },
});
});
test('the first question carries the boundary instruction as a synthetic part', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
const sentParts: unknown[] = [];
sendMessageImpl = (...args) => {
sentParts.push(args[6]);
return Promise.resolve();
};
await startBtwSession(startInput);
expect(sentParts).toEqual([[{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true }]]);
});
test('an empty parent produces a marker without a boundary', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.resolve([]);
@@ -229,7 +291,9 @@ describe('promoteBtwSession', () => {
expect(metadataPatches).toEqual([
{ sessionId: 'parent-1', result: {} },
{ sessionId: 'fork-1', result: {} },
// The fork stops being a btw session but stays marked as promoted: its
// transcript still carries the boundary instructions.
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
]);
expect(currentSessionSwitches).toEqual(['fork-1']);
});
@@ -240,3 +304,24 @@ describe('promoteBtwSession', () => {
expect(currentSessionSwitches).toEqual([]);
});
});
describe('buildBtwSyntheticTexts', () => {
test('a send routed to an active fork carries only the boundary instruction', () => {
// Regression: a promoted parent that opens a new btw fork used to send the
// promotion notice into the fork alongside the boundary instruction, telling
// the fork both that btw constraints apply and that they no longer apply.
expect(buildBtwSyntheticTexts({ isBtwActive: true, isPromotedBtwSession: true }))
.toEqual([BTW_BOUNDARY_INSTRUCTION]);
expect(buildBtwSyntheticTexts({ isBtwActive: true, isPromotedBtwSession: false }))
.toEqual([BTW_BOUNDARY_INSTRUCTION]);
});
test('a promoted session with no active fork carries the promotion notice', () => {
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: true }))
.toEqual([BTW_PROMOTION_NOTICE]);
});
test('an ordinary session carries neither', () => {
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: false })).toEqual([]);
});
});
+110 -4
View File
@@ -4,7 +4,7 @@ import * as sessionActions from '@/sync/session-actions';
import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs';
import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs';
import { Binary } from '@/sync/binary';
/**
@@ -30,6 +30,93 @@ export type StartBtwInput = {
variant?: string;
};
/**
* Sent as a synthetic part with every message inside a btw session.
*
* A btw session is a fork, so the model receives the parent's whole
* conversation — including whatever plan was in flight when `/btw` was typed.
* Without this the fork reads that plan as its own active task and carries on
* with it instead of answering the side question, which is the opposite of
* what `/btw` is for.
*
* The wording is deliberately position-independent: it names the history
* inherited from the parent thread rather than "everything before this
* boundary". The instruction rides along with each send instead of being
* pinned once at fork time, so a positional phrasing would be re-anchored
* every turn and would end up telling the model to disregard the btw
* session's own earlier turns.
*/
export const BTW_BOUNDARY_INSTRUCTION = [
'You are in a btw session, a side conversation forked from a main thread.',
'The history inherited from the parent thread is reference context only. It is not your current task.',
'Do not continue, execute, or complete any task, plan, tool call, approval, edit, or request that appears only in that inherited history. Only instructions the user sends inside this btw session are active.',
'Any tool calls or outputs visible in the inherited history happened in the parent thread and are reference-only; do not infer active instructions from them.',
'Sub-agents are off-limits in this btw session. Do not interact with any existing or new sub-agents, even if sub-agents were used in the inherited history.',
'Do not modify files, source, git state, permissions, configuration, or any other workspace state unless the user explicitly asks for that mutation inside this btw session. If they do, keep it minimal, local to the request, and avoid disrupting the main thread.',
].join('\n');
/**
* Sent with every message in a session that was promoted out of `/btw`.
*
* `BTW_BOUNDARY_INSTRUCTION` is persisted on each message the session sent
* while it was a side conversation, and there is no API to remove a message
* part after the fact — so promotion cannot delete those lines, only answer
* them. Without this, a promoted session keeps reading "no sub-agents, do not
* touch the workspace" out of its own history, in a session that is no longer
* a side conversation.
*
* It rides along with every send for the same reason the boundary does: the
* instructions it revokes are re-read on every turn, so a one-shot notice
* would lose its position relative to them as the conversation grows.
*/
export const BTW_PROMOTION_NOTICE =
'This session started as a btw side conversation and has since been promoted to a normal session. '
+ 'The btw constraints in the history above no longer apply: this is now the main thread, and the '
+ 'usual tool, sub-agent and workspace permissions are in force.';
/**
* The btw framing texts a composer send carries.
*
* The boundary instruction rides with every send routed to an active btw fork,
* so the inherited transcript stays reference material for the whole side
* conversation. The promotion notice is the opposite case: it tells a promoted
* session that the btw constraints in its own history are lifted. A send routed
* to a fresh fork is never that session, so the two never travel together.
*/
export const buildBtwSyntheticTexts = (state: {
isBtwActive: boolean;
isPromotedBtwSession: boolean;
}): string[] => {
if (state.isBtwActive) return [BTW_BOUNDARY_INSTRUCTION];
return state.isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : [];
};
/** The boundary as an `additionalParts` entry for `sendMessage`. */
const btwBoundaryParts = (): Array<{ text: string; synthetic: true }> =>
[{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true }];
/**
* The parent's last assistant turn that actually finished.
*
* `/btw` is typically typed *while* the main thread is working — that is the
* moment a side question comes up. Forking at HEAD then clones a turn that is
* still streaming: the fork inherits a truncated assistant message and the
* user instruction that provoked it as the newest, most salient thing in its
* context. Anchoring the fork to the last completed turn instead means the
* inherited transcript is always a settled conversation.
*
* Returns `null` when the parent has no completed assistant turn yet (a brand
* new session); the caller then keeps the previous fork-at-HEAD behavior.
*/
export const findLastCompletedAssistantMessageID = (messages: readonly Message[]): string | null => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== 'assistant') continue;
if (message.time.completed !== undefined) return message.id;
}
return null;
};
export const btwSessionTitle = (question: string): string => `btw: ${question}`;
/**
@@ -53,7 +140,16 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
setPanelState(input.parentSessionId, { creating: true });
try {
await sessionActions.waitForConnectionOrThrow();
const forked = await opencodeClient.forkSession(input.parentSessionId, undefined, input.directory);
// Fork at the parent's last completed assistant turn rather than at HEAD,
// so a `/btw` typed mid-turn does not inherit a half-finished one.
const forkPointMessageID = findLastCompletedAssistantMessageID(
getSyncMessages(input.parentSessionId, input.directory),
);
const forked = await opencodeClient.forkSession(
input.parentSessionId,
forkPointMessageID ?? undefined,
input.directory,
);
// The server may canonicalize the worktree path; the prompt must use the
// same directory identity as the forked session.
@@ -67,7 +163,14 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// id of the newest cloned message. Message ids are server-generated and
// ascending, so everything the fork produces sorts after it.
const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory);
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id ?? null;
// A `null` boundary makes the panel show every inherited message, so an
// empty read must not be taken as "the fork inherited nothing" when we
// know it did: having picked a fork point proves the parent had turns.
// Fall back to that id — the fork's own messages are created later and
// still sort after it, so the tail stays complete either way.
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id
?? forkPointMessageID
?? null;
// The fork inherits the parent's metadata and title wholesale: replace
// the metadata with the btw marker, and rename it (rename is
@@ -95,7 +198,10 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
input.agent,
[],
undefined,
undefined,
// The very first question already needs the boundary: the fork is at
// its most dangerous here, with the parent's in-flight plan as the
// newest thing in its context.
btwBoundaryParts(),
input.variant,
'normal',
{ sessionId: forked.id, directory: sessionDirectory },
+51
View File
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { marked } from 'marked';
import { copyMarkdownToClipboard } from './clipboard';
import { flattenAssistantTextParts } from './messages/messageText';
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const originalClipboardItem = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem');
@@ -84,4 +87,52 @@ describe('copyMarkdownToClipboard', () => {
expect(result).toEqual({ ok: true, method: 'clipboard' });
expect(fallbackText).toBe('# title');
});
test('assistant copy payload keeps Markdown block separation in every clipboard format', async () => {
let writtenItem: { data: Record<string, Blob> } | undefined;
class FakeClipboardItem {
static supports(type: string): boolean {
return type === 'text/markdown';
}
readonly data: Record<string, Blob>;
constructor(data: Record<string, Blob>) {
this.data = data;
}
}
Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: FakeClipboardItem });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
clipboard: {
write: async (items: Array<{ data: Record<string, Blob> }>) => {
writtenItem = items[0];
},
},
},
});
const parts = [
{ id: 'p0', sessionID: 's', messageID: 'm', type: 'text', text: '第一段' },
{ id: 'p1', sessionID: 's', messageID: 'm', type: 'text', text: '第二段' },
{ id: 'p2', sessionID: 's', messageID: 'm', type: 'text', text: '```js\nconsole.log(1)\n\n\nconsole.log(2)\n```' },
{ id: 'p3', sessionID: 's', messageID: 'm', type: 'text', text: '第三段' },
];
// Same path as ChatMessage.tsx handleCopyMessage:
const text = flattenAssistantTextParts(parts as Parameters<typeof flattenAssistantTextParts>[0]);
const html = marked.parse(text, { gfm: true, breaks: false }) as string;
const result = await copyMarkdownToClipboard(text, html);
const expected = '第一段\n\n第二段\n\n```js\nconsole.log(1)\n\n\nconsole.log(2)\n```\n\n第三段';
expect(result).toEqual({ ok: true, method: 'clipboard' });
expect(await writtenItem?.data['text/plain']?.text()).toBe(expected);
expect(await writtenItem?.data['text/markdown']?.text()).toBe(expected);
const htmlText = await writtenItem?.data['text/html']?.text();
expect(htmlText).toContain('<p>第一段</p>');
expect(htmlText).toContain('<p>第二段</p>');
expect(htmlText).not.toContain('<p>第一段\n第二段</p>');
});
});
+15
View File
@@ -13,6 +13,8 @@ import {
} from '@/sync/session-directory-resolution';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getRecentSendFailures } from '@/sync/send-failure-log';
import { getRecentSessionErrors } from '@/sync/session-error-log';
import { buildOpenCodeStatusReport } from '@/lib/openCodeStatus';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useStreamingStore } from '@/sync/streaming';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -386,6 +388,9 @@ export const debugUtils = {
// this session, so a "my message disappeared" report is not a rejected
// send and needs a different explanation.
recentSendFailures: getRecentSendFailures(),
// Same reasoning: empty means OpenCode reported no failed turn in this
// app session.
recentSessionErrors: getRecentSessionErrors(),
currentSessionDirectoryResolution: sessionState.currentSessionId
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
: null,
@@ -395,6 +400,16 @@ export const debugUtils = {
return report;
},
/**
* The same text the status report dialog (Ctrl/Cmd+Shift+L) shows, for a
* console or remote session that cannot press the shortcut.
*/
async statusReport() {
const text = await buildOpenCodeStatusReport();
console.log(text);
return text;
},
/**
* Prompt sends that were rejected and rolled back in this app session.
* Newest first; empty means no send was rejected.
+12 -1
View File
@@ -5,6 +5,7 @@ import type { DraftStarterRef } from '@/lib/draftStarters';
import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isVSCodeBootstrapPresent } from '@/lib/vscodeBootstrap';
type ManagedRemoteTunnelPreset = {
id: string;
@@ -573,6 +574,12 @@ export const startDesktopWindowDrag = async (): Promise<boolean> => {
};
export const isVSCodeRuntime = (): boolean => {
// Prefer extension-host bootstrap config: it is injected in webview HTML
// before any store module evaluates, so startup does not depend on
// RuntimeAPIs registration order (see #2359).
if (isVSCodeBootstrapPresent()) {
return true;
}
const apis = getRegisteredRuntimeAPIs();
return apis?.runtime?.isVSCode === true;
};
@@ -810,7 +817,11 @@ export const restartToApplyUpdate = async (): Promise<boolean> => {
return false;
}
return restartDesktopApp();
// Unlike a plain restart, an install failure (rejected signature, disabled
// updater session) must reach the update dialog instead of being reduced to
// a boolean the caller cannot explain.
await invokeDesktop('desktop_restart');
return true;
};
export const restartDesktopApp = async (): Promise<boolean> => {
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, mock, test } from 'bun:test';
type RuntimeApisStub = { runtime?: { isVSCode?: boolean } } | null;
let registeredRuntimeApis: RuntimeApisStub = null;
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: (): RuntimeApisStub => registeredRuntimeApis,
}));
interface TestWindow {
__VSCODE_CONFIG__?: { workspaceFolder: string; workspaceFolders: { name: string; path: string }[] };
}
/**
* bun test runs without a DOM, so `globalThis` has no `window` binding to
* assign through. Defining the property directly installs the stub without
* asserting that it is a real `Window`.
*/
const setTestWindow = (value: TestWindow | undefined): void => {
if (value === undefined) {
Reflect.deleteProperty(globalThis, 'window');
return;
}
Object.defineProperty(globalThis, 'window', { value, configurable: true, writable: true });
};
const { isVSCodeRuntime } = await import('./desktop');
describe('desktop isVSCodeRuntime bootstrap detection', () => {
afterEach(() => {
registeredRuntimeApis = null;
setTestWindow(undefined);
});
test('detects VS Code from bootstrap config before RuntimeAPIs register', () => {
registeredRuntimeApis = null;
setTestWindow({
__VSCODE_CONFIG__: {
workspaceFolder: '/Users/me/project-a',
workspaceFolders: [{ name: 'project-a', path: '/Users/me/project-a' }],
},
});
expect(isVSCodeRuntime()).toBe(true);
});
test('falls back to registered RuntimeAPIs when bootstrap is absent', () => {
registeredRuntimeApis = {
runtime: { isVSCode: true },
};
setTestWindow({});
expect(isVSCodeRuntime()).toBe(true);
});
test('does not classify an unregistered web runtime as VS Code', () => {
registeredRuntimeApis = null;
setTestWindow({});
expect(isVSCodeRuntime()).toBe(false);
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
export const MAX_OPEN_FILE_LINES = 5_000;
export const MAX_OPEN_FILE_LINES = 20_000;
export const countLinesWithLimit = (content: string, limit: number): number => {
if (!content) {
+44 -32
View File
@@ -8,6 +8,7 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { notifyGitStatusInvalidated } from './gitStatusInvalidation';
export type {
GitRemote,
@@ -19,6 +20,17 @@ const getRuntimeGit = () => {
return getRegisteredRuntimeAPIs()?.git ?? null;
};
// Runtime git adapters (the VS Code bridge today) do not go through the HTTP
// adapter's cache, so the invalidation signal `useGitStore` relies on has to be
// emitted here, at the dispatch layer, once a runtime mutation succeeds. The
// HTTP adapter keeps emitting it itself when it clears its own cache, so a
// mutation is announced exactly once on either path.
const runtimeStatusMutation = async <T>(directory: string, mutation: Promise<T>): Promise<T> => {
const result = await mutation;
notifyGitStatusInvalidated(directory);
return result;
};
const requestChatForceScrollBottom = (sessionId: string) => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('openchamber:chat-force-scroll-bottom', {
@@ -144,49 +156,49 @@ export async function revertGitFile(
options?: { scope?: 'all' | 'working' }
): Promise<void> {
const runtime = getRuntimeGit();
if (runtime) return runtime.revertGitFile(directory, filePath, options);
if (runtime) return runtimeStatusMutation(directory, runtime.revertGitFile(directory, filePath, options));
return gitHttp.revertGitFile(directory, filePath, options);
}
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitFile) return runtime.stageGitFile(directory, filePath);
if (runtime?.stageGitFile) return runtimeStatusMutation(directory, runtime.stageGitFile(directory, filePath));
return gitHttp.stageGitFile(directory, filePath);
}
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitFiles) return runtime.stageGitFiles(directory, filePaths);
if (runtime?.stageGitFiles) return runtimeStatusMutation(directory, runtime.stageGitFiles(directory, filePaths));
return gitHttp.stageGitFiles(directory, filePaths);
}
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitFile) return runtime.unstageGitFile(directory, filePath);
if (runtime?.unstageGitFile) return runtimeStatusMutation(directory, runtime.unstageGitFile(directory, filePath));
return gitHttp.unstageGitFile(directory, filePath);
}
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitFiles) return runtime.unstageGitFiles(directory, filePaths);
if (runtime?.unstageGitFiles) return runtimeStatusMutation(directory, runtime.unstageGitFiles(directory, filePaths));
return gitHttp.unstageGitFiles(directory, filePaths);
}
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitHunk) return runtime.stageGitHunk(directory, filePath, patch);
if (runtime?.stageGitHunk) return runtimeStatusMutation(directory, runtime.stageGitHunk(directory, filePath, patch));
return gitHttp.stageGitHunk(directory, filePath, patch);
}
export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitHunk) return runtime.unstageGitHunk(directory, filePath, patch);
if (runtime?.unstageGitHunk) return runtimeStatusMutation(directory, runtime.unstageGitHunk(directory, filePath, patch));
return gitHttp.unstageGitHunk(directory, filePath, patch);
}
export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.revertGitHunk) return runtime.revertGitHunk(directory, filePath, patch);
if (runtime?.revertGitHunk) return runtimeStatusMutation(directory, runtime.revertGitHunk(directory, filePath, patch));
return gitHttp.revertGitHunk(directory, filePath, patch);
}
@@ -204,13 +216,13 @@ export async function getGitBranches(directory: string): Promise<import('./api/t
export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.deleteGitBranch(directory, payload);
if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload));
return gitHttp.deleteGitBranch(directory, payload);
}
export async function deleteRemoteBranch(directory: string, payload: import('./api/types').GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.deleteRemoteBranch(directory, payload);
if (runtime) return runtimeStatusMutation(directory, runtime.deleteRemoteBranch(directory, payload));
return gitHttp.deleteRemoteBranch(directory, payload);
}
@@ -855,7 +867,7 @@ export async function createGitCommit(
options: import('./api/types').CreateGitCommitOptions = {}
): Promise<import('./api/types').GitCommitResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.createGitCommit(directory, message, options);
if (runtime) return runtimeStatusMutation(directory, runtime.createGitCommit(directory, message, options));
return gitHttp.createGitCommit(directory, message, options);
}
@@ -864,7 +876,7 @@ export async function gitPush(
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
): Promise<import('./api/types').GitPushResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitPush(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.gitPush(directory, options));
return gitHttp.gitPush(directory, options);
}
@@ -873,7 +885,7 @@ export async function gitPull(
options: import('./api/types').GitPullOptions = {}
): Promise<import('./api/types').GitPullResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitPull(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.gitPull(directory, options));
return gitHttp.gitPull(directory, options);
}
@@ -882,7 +894,7 @@ export async function gitFetch(
options: { remote?: string; branch?: string } = {}
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitFetch(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.gitFetch(directory, options));
return gitHttp.gitFetch(directory, options);
}
@@ -900,31 +912,31 @@ export async function countGitStashFiles(directory: string, refs: string[]): Pro
export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.stashGitChanges(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.stashGitChanges(directory, options));
return gitHttp.stashGitChanges(directory, options);
}
export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.applyGitStash(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.applyGitStash(directory, options));
return gitHttp.applyGitStash(directory, options);
}
export async function popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.popGitStash(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.popGitStash(directory, options));
return gitHttp.popGitStash(directory, options);
}
export async function dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.dropGitStash(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.dropGitStash(directory, options));
return gitHttp.dropGitStash(directory, options);
}
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.checkoutBranch(directory, branch);
if (runtime) return runtimeStatusMutation(directory, runtime.checkoutBranch(directory, branch));
return gitHttp.checkoutBranch(directory, branch);
}
@@ -934,7 +946,7 @@ export async function createBranch(
startPoint?: string
): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.createBranch(directory, name, startPoint);
if (runtime) return runtimeStatusMutation(directory, runtime.createBranch(directory, name, startPoint));
return gitHttp.createBranch(directory, name, startPoint);
}
@@ -944,7 +956,7 @@ export async function renameBranch(
newName: string
): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.renameBranch(directory, oldName, newName);
if (runtime) return runtimeStatusMutation(directory, runtime.renameBranch(directory, oldName, newName));
return gitHttp.renameBranch(directory, oldName, newName);
}
@@ -1051,7 +1063,7 @@ export async function removeRemote(
payload: import('./api/types').GitRemoveRemotePayload
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.removeRemote(directory, payload);
if (runtime) return runtimeStatusMutation(directory, runtime.removeRemote(directory, payload));
return gitHttp.removeRemote(directory, payload);
}
@@ -1060,13 +1072,13 @@ export async function rebase(
options: { onto: string }
): Promise<import('./api/types').GitRebaseResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.rebase(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.rebase(directory, options));
return gitHttp.rebase(directory, options);
}
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.abortRebase(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.abortRebase(directory));
return gitHttp.abortRebase(directory);
}
@@ -1075,7 +1087,7 @@ export async function merge(
options: { branch: string }
): Promise<import('./api/types').GitMergeResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.merge(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.merge(directory, options));
return gitHttp.merge(directory, options);
}
@@ -1084,7 +1096,7 @@ export async function checkoutCommit(
hash: string
): Promise<import('./api/types').CheckoutCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.checkoutCommit(directory, hash);
if (runtime) return runtimeStatusMutation(directory, runtime.checkoutCommit(directory, hash));
return gitHttp.checkoutCommit(directory, hash);
}
@@ -1093,7 +1105,7 @@ export async function cherryPick(
hash: string
): Promise<import('./api/types').CherryPickResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.cherryPick(directory, hash);
if (runtime) return runtimeStatusMutation(directory, runtime.cherryPick(directory, hash));
return gitHttp.cherryPick(directory, hash);
}
@@ -1102,7 +1114,7 @@ export async function revertCommit(
hash: string
): Promise<import('./api/types').RevertCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.revertCommit(directory, hash);
if (runtime) return runtimeStatusMutation(directory, runtime.revertCommit(directory, hash));
return gitHttp.revertCommit(directory, hash);
}
@@ -1113,25 +1125,25 @@ export async function resetToCommit(
force?: boolean
): Promise<import('./api/types').ResetToCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.resetToCommit(directory, hash, mode, force);
if (runtime) return runtimeStatusMutation(directory, runtime.resetToCommit(directory, hash, mode, force));
return gitHttp.resetToCommit(directory, hash, mode, force);
}
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.abortMerge(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.abortMerge(directory));
return gitHttp.abortMerge(directory);
}
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.continueRebase(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.continueRebase(directory));
return gitHttp.continueRebase(directory);
}
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.continueMerge(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.continueMerge(directory));
return gitHttp.continueMerge(directory);
}
+225
View File
@@ -1,13 +1,34 @@
import { describe, expect, test } from 'bun:test';
import {
abortMerge,
abortRebase,
applyGitStash,
checkoutBranch,
checkoutCommit,
cherryPick,
continueMerge,
continueRebase,
createBranch,
deleteGitBranch,
deleteRemoteBranch,
dropGitStash,
getGitBranches,
getGitStatus,
gitFetch,
merge,
popGitStash,
rebase,
removeRemote,
renameBranch,
resetToCommit,
revertCommit,
stageGitFile,
stageGitFiles,
stashGitChanges,
unstageGitFile,
unstageGitFiles,
} from './gitApiHttp';
import type { GitStatus } from './api/types';
type FetchCall = {
input: RequestInfo | URL;
@@ -169,6 +190,210 @@ describe('gitApiHttp status cache', () => {
});
});
const statusPayload = (overrides: Partial<GitStatus> = {}): GitStatus => ({
current: 'main',
tracking: null,
ahead: 0,
behind: 0,
files: [],
isClean: true,
...overrides,
});
const jsonResponse = <T>(payload: T) => new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
const installStatusMutationFetchMock = () => {
// SAFETY: `statusUrls` starts empty and only ever receives request URLs, which
// are strings; the annotation names that element type up front.
const mock = {
statusUrls: [] as string[],
behind: 0,
};
// SAFETY: the mock receives only the (input, init) pair production code passes
// and always resolves to a Response, so it honours the fetch contract; the
// assertion supplies the overload signatures a plain arrow function cannot.
globalThis.fetch = (async (input) => {
const url = String(input);
if (url.startsWith('/api/git/status')) {
mock.statusUrls.push(url);
return jsonResponse(statusPayload({ behind: mock.behind }));
}
return jsonResponse({ success: true });
}) as typeof fetch;
return mock;
};
/**
* Seeds the status cache, performs the mutation, and asserts the next status
* read issues a fresh request that observes the post-mutation state instead of
* serving the pre-mutation cache entry.
*/
const expectStatusInvalidatedBy = async <T>(
directory: string,
mutate: () => Promise<T>
): Promise<void> => {
const mock = installStatusMutationFetchMock();
const seeded = await getGitStatus(directory);
expect(seeded.behind).toBe(0);
mock.behind = 2;
const cached = await getGitStatus(directory);
expect(cached.behind).toBe(0);
expect(mock.statusUrls).toHaveLength(1);
await mutate();
const refreshed = await getGitStatus(directory);
expect(refreshed.behind).toBe(2);
expect(mock.statusUrls).toHaveLength(2);
};
describe('gitApiHttp post-mutation status invalidation (#2281)', () => {
test('checkout and branch mutations invalidate cached status', async () => {
installWindowMock();
try {
await expectStatusInvalidatedBy('/repo-2281-checkout', () => checkoutBranch('/repo-2281-checkout', 'feature'));
await expectStatusInvalidatedBy('/repo-2281-create-branch', () => createBranch('/repo-2281-create-branch', 'feature/new'));
await expectStatusInvalidatedBy('/repo-2281-rename-branch', () => renameBranch('/repo-2281-rename-branch', 'old', 'new'));
await expectStatusInvalidatedBy('/repo-2281-delete-branch', () => deleteGitBranch('/repo-2281-delete-branch', { branch: 'feature/old' }));
} finally {
restoreMocks();
}
});
test('stash lifecycle mutations invalidate cached status', async () => {
installWindowMock();
try {
await expectStatusInvalidatedBy('/repo-2281-stash', () => stashGitChanges('/repo-2281-stash', { message: 'WIP' }));
await expectStatusInvalidatedBy('/repo-2281-stash-apply', () => applyGitStash('/repo-2281-stash-apply', { ref: 'stash@{0}' }));
await expectStatusInvalidatedBy('/repo-2281-stash-pop', () => popGitStash('/repo-2281-stash-pop', { ref: 'stash@{0}' }));
await expectStatusInvalidatedBy('/repo-2281-stash-drop', () => dropGitStash('/repo-2281-stash-drop', { ref: 'stash@{0}' }));
} finally {
restoreMocks();
}
});
test('merge and rebase lifecycle mutations invalidate cached status', async () => {
installWindowMock();
try {
await expectStatusInvalidatedBy('/repo-2281-merge', () => merge('/repo-2281-merge', { branch: 'feature' }));
await expectStatusInvalidatedBy('/repo-2281-merge-abort', () => abortMerge('/repo-2281-merge-abort'));
await expectStatusInvalidatedBy('/repo-2281-merge-continue', () => continueMerge('/repo-2281-merge-continue'));
await expectStatusInvalidatedBy('/repo-2281-rebase', () => rebase('/repo-2281-rebase', { onto: 'main' }));
await expectStatusInvalidatedBy('/repo-2281-rebase-abort', () => abortRebase('/repo-2281-rebase-abort'));
await expectStatusInvalidatedBy('/repo-2281-rebase-continue', () => continueRebase('/repo-2281-rebase-continue'));
} finally {
restoreMocks();
}
});
test('history mutations invalidate cached status', async () => {
installWindowMock();
try {
await expectStatusInvalidatedBy('/repo-2281-checkout-commit', () => checkoutCommit('/repo-2281-checkout-commit', 'abc123'));
await expectStatusInvalidatedBy('/repo-2281-cherry-pick', () => cherryPick('/repo-2281-cherry-pick', 'abc123'));
await expectStatusInvalidatedBy('/repo-2281-revert-commit', () => revertCommit('/repo-2281-revert-commit', 'abc123'));
await expectStatusInvalidatedBy('/repo-2281-reset', () => resetToCommit('/repo-2281-reset', 'abc123', 'mixed'));
} finally {
restoreMocks();
}
});
test('remote-side mutations invalidate cached status', async () => {
installWindowMock();
try {
await expectStatusInvalidatedBy('/repo-2281-delete-remote-branch', () => deleteRemoteBranch('/repo-2281-delete-remote-branch', { branch: 'feature', remote: 'origin' }));
await expectStatusInvalidatedBy('/repo-2281-remove-remote', () => removeRemote('/repo-2281-remove-remote', { remote: 'origin' }));
} finally {
restoreMocks();
}
});
test('a failed mutation does not invalidate cached status', async () => {
installWindowMock();
const statusUrls: string[] = [];
// SAFETY: see installStatusMutationFetchMock - the mock honours the fetch
// contract; the assertion supplies its overload signatures.
globalThis.fetch = (async (input) => {
const url = String(input);
if (url.startsWith('/api/git/status')) {
statusUrls.push(url);
return jsonResponse(statusPayload());
}
return new Response(JSON.stringify({ error: 'checkout failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof fetch;
try {
const directory = '/repo-2281-failed-checkout';
await getGitStatus(directory);
const error = await captureError(async () => {
await checkoutBranch(directory, 'feature');
});
expect(error).toBeInstanceOf(Error);
// SAFETY: the assertion above established that `error` is an Error.
expect((error as Error).message).toBe('checkout failed');
await getGitStatus(directory);
expect(statusUrls).toHaveLength(1);
} finally {
restoreMocks();
}
});
test('a status request admitted before a mutation cannot satisfy the post-mutation refresh', async () => {
installWindowMock();
const statusResolvers: Array<(response: Response) => void> = [];
const statusUrls: string[] = [];
// SAFETY: see installStatusMutationFetchMock - the mock honours the fetch
// contract; the assertion supplies its overload signatures.
globalThis.fetch = (async (input) => {
const url = String(input);
if (url.startsWith('/api/git/status')) {
statusUrls.push(url);
return new Promise<Response>((resolve) => {
statusResolvers.push(resolve);
});
}
return jsonResponse({ success: true });
}) as typeof fetch;
try {
const directory = '/repo-2281-deferred';
const preMutationRead = getGitStatus(directory);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(statusUrls).toHaveLength(1);
await checkoutBranch(directory, 'feature');
const postMutationRead = getGitStatus(directory);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(statusUrls).toHaveLength(2);
statusResolvers[1](jsonResponse(statusPayload({ current: 'feature' })));
statusResolvers[0](jsonResponse(statusPayload({ current: 'main' })));
const [preMutationStatus, postMutationStatus] = await Promise.all([preMutationRead, postMutationRead]);
expect(preMutationStatus.current).toBe('main');
expect(postMutationStatus.current).toBe('feature');
// The late pre-mutation response must not repopulate the cache.
const cachedRead = await getGitStatus(directory);
expect(cachedRead.current).toBe('feature');
expect(statusUrls).toHaveLength(2);
} finally {
restoreMocks();
}
});
});
describe('gitApiHttp request priority', () => {
test('leaves low-level reads outside the background policy', async () => {
installWindowMock();
+37 -30
View File
@@ -38,6 +38,7 @@ import type {
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { getRuntimeKey } from './runtime-switch';
import { notifyGitStatusInvalidated } from './gitStatusInvalidation';
const API_BASE = '/api/git';
const GIT_STATUS_CACHE_TTL_MS = 1200;
@@ -66,6 +67,20 @@ const invalidateGitStatusCache = (directory: string): void => {
gitStatusCache.delete(statusKey);
gitStatusInFlight.delete(statusKey);
}
notifyGitStatusInvalidated(directory);
};
// Shared success path for status-affecting mutations. The payload is parsed
// before invalidating so a failed mutation (non-ok response handled by the
// caller, or a malformed body) cannot publish a false state change.
const completeStatusMutation = async <T>(directory: string, response: Response): Promise<T> => {
// SAFETY: every caller rejects non-ok responses before reaching here, and on
// success each git route returns the body declared by that route's return
// type in `./api/types`. The assertion names that per-route contract; there is
// no narrower type available at this shared success path.
const result = await response.json() as T;
invalidateGitStatusCache(directory);
return result;
};
function buildUrl(
@@ -491,7 +506,7 @@ export async function deleteGitBranch(directory: string, payload: GitDeleteBranc
throw new Error(error.error || 'Failed to delete branch');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
@@ -510,7 +525,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe
throw new Error(error.error || 'Failed to delete remote branch');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> {
@@ -530,7 +545,7 @@ export async function removeRemote(directory: string, payload: GitRemoveRemotePa
throw new Error(error.error || 'Failed to remove remote');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function generateCommitMessage(
@@ -737,9 +752,7 @@ export async function createGitCommit(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create commit');
}
const result = await response.json();
invalidateGitStatusCache(directory);
return result;
return completeStatusMutation(directory, response);
}
export async function gitPush(
@@ -755,9 +768,7 @@ export async function gitPush(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to push');
}
const result = await response.json();
invalidateGitStatusCache(directory);
return result;
return completeStatusMutation(directory, response);
}
export async function gitPull(
@@ -773,9 +784,7 @@ export async function gitPull(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to pull');
}
const result = await response.json();
invalidateGitStatusCache(directory);
return result;
return completeStatusMutation(directory, response);
}
export async function gitFetch(
@@ -791,9 +800,7 @@ export async function gitFetch(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to fetch');
}
const result = await response.json();
invalidateGitStatusCache(directory);
return result;
return completeStatusMutation(directory, response);
}
export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> {
@@ -828,7 +835,7 @@ export async function stashGitChanges(directory: string, options: { message?: st
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to stash changes');
}
return response.json();
return completeStatusMutation(directory, response);
}
const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => {
@@ -841,7 +848,7 @@ const postStashRef = async (directory: string, path: string, options: { ref: str
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || `Failed to ${path}`);
}
return response.json();
return completeStatusMutation(directory, response);
};
export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options);
@@ -858,7 +865,7 @@ export async function checkoutBranch(directory: string, branch: string): Promise
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to checkout branch');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function createBranch(
@@ -875,7 +882,7 @@ export async function createBranch(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create branch');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function renameBranch(
@@ -892,7 +899,7 @@ export async function renameBranch(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to rename branch');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function getGitLog(
@@ -1095,7 +1102,7 @@ export async function rebase(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to rebase');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
@@ -1106,7 +1113,7 @@ export async function abortRebase(directory: string): Promise<{ success: boolean
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to abort rebase');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function merge(
@@ -1122,7 +1129,7 @@ export async function merge(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to merge');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function checkoutCommit(
@@ -1138,7 +1145,7 @@ export async function checkoutCommit(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to checkout commit');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function cherryPick(
@@ -1154,7 +1161,7 @@ export async function cherryPick(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to cherry-pick');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function revertCommit(
@@ -1170,7 +1177,7 @@ export async function revertCommit(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to revert commit');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function resetToCommit(
@@ -1188,7 +1195,7 @@ export async function resetToCommit(
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to reset');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
@@ -1199,7 +1206,7 @@ export async function abortMerge(directory: string): Promise<{ success: boolean
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to abort merge');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
@@ -1210,7 +1217,7 @@ export async function continueRebase(directory: string): Promise<{ success: bool
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to continue rebase');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
@@ -1221,7 +1228,7 @@ export async function continueMerge(directory: string): Promise<{ success: boole
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to continue merge');
}
return response.json();
return completeStatusMutation(directory, response);
}
export async function stash(
@@ -0,0 +1,35 @@
/**
* Minimal notification channel for git status invalidation.
*
* Every successful status-affecting git mutation must call
* `notifyGitStatusInvalidated`. `useGitStore` subscribes and bumps its
* per-directory status mutation revision so an immediate refresh cannot join an
* in-flight status request admitted before the mutation, and a stale response
* cannot commit over newer authoritative state.
*
* Runtime parity: this is about the store's in-flight status request, not about
* adapter caching, so it applies to every runtime. The HTTP adapter in
* `gitApiHttp.ts` emits it where it clears its own cache; runtime adapters (the
* VS Code bridge) have no cache of their own, so the dispatch layer in
* `gitApi.ts` emits it for them after a successful runtime mutation. Either
* path announces a mutation exactly once.
*/
type GitStatusInvalidationListener = (directory: string) => void;
const listeners = new Set<GitStatusInvalidationListener>();
export const subscribeGitStatusInvalidations = (
listener: GitStatusInvalidationListener
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const notifyGitStatusInvalidated = (directory: string): void => {
for (const listener of listeners) {
listener(directory);
}
};
+20
View File
@@ -228,6 +228,25 @@ const DE_MESSAGES: BootstrapMessages = {
loadingData: (providersText, agentsText) => `Daten werden geladen (${providersText}, ${agentsText})…`,
};
const TR_MESSAGES: BootstrapMessages = {
startingApi: 'OpenCode API başlatılıyor…',
initializing: 'Başlatılıyor…',
connecting: 'Bağlanıyor…',
connected: 'Bağlandı!',
connectionError: 'Bağlantı hatası',
disconnected: 'Bağlantı kesildi',
reconnecting: 'Yeniden bağlanıyor…',
initialDataLoadFailed: 'OpenCode bağlandı ancak ilk veri yükleme başarısız oldu.',
cliNotFound: 'OpenCode CLI bulunamadı. Lütfen önce kurun.',
providersReady: '✓ Sağlayıcılar',
providersLoading: '… Sağlayıcılar',
agentsReady: '✓ Agent\'ler',
agentsLoading: '… Agent\'ler',
startingDevServer: (hostLabel) => `Webview dev sunucusu başlatılıyor (${hostLabel})...`,
waitingDevServer: (hostLabel, attempt) => `Webview dev sunucusu bekleniyor (${hostLabel})... deneme ${attempt}`,
loadingData: (providersText, agentsText) => `Veriler yükleniyor (${providersText}, ${agentsText})…`,
};
export const getBootstrapMessages = (locale: Locale): BootstrapMessages => {
return BOOTSTRAP_MESSAGES[locale];
};
@@ -244,6 +263,7 @@ const BOOTSTRAP_MESSAGES: Record<Locale, BootstrapMessages> = {
ko: KO_MESSAGES,
pl: PL_MESSAGES,
ja: JA_MESSAGES,
tr: TR_MESSAGES,
};
export const readStoredLocaleForBootstrap = (): Locale => {
+1
View File
@@ -13,6 +13,7 @@ const INTL_LOCALE_BY_LOCALE: Record<Locale, string> = {
ko: 'ko-KR',
pl: 'pl-PL',
ja: 'ja-JP',
tr: 'tr-TR',
};
const getIntlLocale = (locale: Locale): string => INTL_LOCALE_BY_LOCALE[locale] ?? 'en-US';
@@ -11,6 +11,7 @@ import { dict as ptBrDict } from './messages/pt-BR';
import { dict as ukDict } from './messages/uk';
import { dict as zhCnDict } from './messages/zh-CN';
import { dict as zhTwDict } from './messages/zh-TW';
import { dict as trDict } from './messages/tr';
const localeDictionaries = {
en: enDict,
@@ -24,6 +25,7 @@ const localeDictionaries = {
pl: plDict,
'zh-CN': zhCnDict,
'zh-TW': zhTwDict,
tr: trDict,
} as const;
describe('i18n dictionaries', () => {
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung',
@@ -1080,18 +1081,20 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal erweitert umschalten',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Auswahl zum Chat hinzufügen',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Seitenleiste umschalten',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Sitzungs-Tab wechseln',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Vorherige Sitzung',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Nächste Sitzung',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Aktuelle Sitzung umbenennen',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Auto-Genehmigung umschalten',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Sitzungs-Tab schließen',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Tastenkürzel öffnen',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Plan-Kontextpanel umschalten',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Dienstemenü umschalten',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Dienste-Tab durchschalten',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Thema wechseln',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent wechseln',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Favorites Modell vorwärts durchschalten',
@@ -1100,6 +1103,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Eingabe erweitern',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Konversations-Zeitleiste öffnen',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Prompt-Navigator umschalten',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Diese Sequenz teilt ein kontextabhängiges Präfix mit {action}. Wenn dessen Kontext aktiv ist, hat diese Aktion Vorrang.',
'settings.openchamber.keyboardShortcuts.category.session': 'Sitzungssteuerung',
'settings.openchamber.keyboardShortcuts.category.models': 'Modelle und Agenten',
'settings.openchamber.keyboardShortcuts.category.panels': 'Panels und Werkzeuge',
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
'settings.openchamber.keyboardShortcuts.category.application': 'Anwendung',
'settings.openchamber.keyboardShortcuts.actions.edit': 'Bearbeiten',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen',
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} bearbeiten',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Drücken Sie bis zu zwei Tastenkombinationen mit jeweils höchstens drei Tasten. Warten Sie nach der ersten bis zu 3 Sekunden auf eine zweite Kombination. Wählen Sie Bestätigen zum Anwenden oder Abbrechen zum Verwerfen. Mit der Rücktaste entfernen Sie die letzte.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Erste Kombination',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Tasten drücken…',
'settings.openchamber.keyboardShortcuts.unassigned': 'Nicht zugewiesen',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Dies kollidiert mit der von {action} verwendeten Sequenz. Wählen Sie eine andere Kombination.',
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Diese Kombination wird bereits von {action} verwendet.',
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Diese Kombination kollidiert mit einem integrierten Tastenkürzel, das nicht ersetzt werden kann.',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Projektauswahl für Entwurf öffnen',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Worktree-Auswahl für Entwurf öffnen',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Letzte Sitzungen öffnen',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Spracheingabe',
'settings.projects.sidebar.total': 'Gesamt {count}',
'settings.projects.sidebar.actions.addProject': 'Projekt hinzufügen',
'settings.projects.page.empty.noProjects': 'Keine Projekte verfügbar.',
@@ -1771,7 +1795,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Server',
'settings.voice.page.provider.local': 'Lokal',
'settings.voice.page.tooltip.sttLocal': 'On-device Transkription auf dem OpenChamber-Server. Modelle werden automatisch heruntergeladen; kein API-Schlüssel erforderlich.',
'settings.voice.page.tooltip.localTts': 'On-device Synthese auf dem OpenChamber-Server (Kokoro, Englisch). Das Modell wird automatisch heruntergeladen; kein API-Schlüssel erforderlich.',
'settings.voice.page.tooltip.localTts': 'On-Device-Synthese auf dem OpenChamber-Server (Kokoro für Englisch; Modelle für andere Sprachen werden beim ersten Einsatz geladen). Kein API-Schlüssel nötig.',
'settings.voice.page.field.followTextLanguage': 'Stimme an die Sprache des Textes anpassen',
'settings.voice.page.field.followTextLanguageAria': 'Stimme an die Sprache des Textes anpassen',
'settings.voice.page.field.followTextLanguageInfo': 'Ist eine Antwort in einer anderen Sprache, wird eine Stimme für diese Sprache verwendet: eine passende macOS-Stimme oder ein lokales Modell, das beim ersten Einsatz geladen wird.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (Englisch)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 europäische Sprachen)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (mehrsprachig)',
@@ -1859,7 +1886,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Neuen Inhalten beim Streaming folgen',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Neuen Inhalten automatisch folgen, während eine Antwort gestreamt wird',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen.',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen; das Senden einer Nachricht aus der Mitte des Chats lässt die Ansicht dann ebenfalls an Ort und Stelle.',
'settings.openchamber.visual.section.messageAppearance': 'Nachrichten-Erscheinungsbild',
'settings.openchamber.visual.section.toolsAndFiles': 'Werkzeuge & Dateien',
'settings.openchamber.visual.section.composer': 'Komponist',
@@ -1979,6 +2006,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': 'Entwurfsnachrichten speichern',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Als Datei anhängen',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Direkt einfügen',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Anonyme Nutzungsberichte senden',
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Anonyme Nutzungsberichte senden',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Hilft uns zu verstehen, welche App-Versionen aktiv genutzt werden, damit wir Verbesserungen priorisieren können. Es werden nur die App-Version, Plattform und Laufzeit gesammelt - keine persönlichen Daten oder Code.',
@@ -2188,5 +2222,6 @@ export const settingsDict = {
'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden',
'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden',
'chat.message.userText.collapseAria': 'Benutzernachricht einklappen',
...linearIntegrationI18n.de,
...thirdPartyIntegrationI18n.de,
};
+92 -16
View File
@@ -1,7 +1,11 @@
import { settingsDict } from './de.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
...settingsDict,
...linearIssuePickerI18n.de,
...linearPanelI18n.de,
'common.language.german': 'Deutsch',
'common.loading': 'Wird geladen...',
'common.unavailable': 'Nicht verfügbar',
@@ -15,6 +19,7 @@ export const dict = {
'common.language.korean': 'Koreanisch',
'common.language.polish': 'Polnisch',
'common.language.japanese': 'Japanisch',
'common.language.turkish': 'Türkisch',
'common.revealPath.finder': 'Im Finder anzeigen',
'common.revealPath.fileExplorer': 'In Datei-Explorer öffnen',
'common.revealPath.fileManager': 'In Dateimanager öffnen',
@@ -102,6 +107,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Projekt wechseln',
'mobile.sessions.section.projects': 'Projekte',
'mobile.sessions.section.chats': 'Chats',
'mobile.sessions.empty.noProjectsTitle': 'Noch keine Projekte',
'mobile.sessions.empty.noProjectsDescription': 'Füge ein Projekt hinzu, um mit deinem Code zu chatten.',
'mobile.sessions.empty.noSessionsTitle': 'Noch keine Sitzungen',
@@ -348,7 +354,7 @@ export const dict = {
'multirun.launcher.attachments.attach': 'Anhängen',
'multirun.launcher.attachments.tooltip': 'Denselben Dateien an alle Durchläufe senden',
'multirun.launcher.models.label': 'Modelle',
'multirun.launcher.models.info': 'Wählen Sie 2-{max} Modelle. Das gleiche Modell kann mehrfach hinzugefügt werden.',
'multirun.launcher.models.info': 'Wählen Sie 2 oder mehr Modelle. Das gleiche Modell kann mehrfach hinzugefügt werden.',
'multirun.launcher.toast.fileTooLarge': 'Datei "{fileName}" ist zu groß (max. 10MB)',
'multirun.launcher.toast.attachFailed': 'Fehler beim Anhängen von "{fileName}"',
'multirun.launcher.toast.attachedSingle': '{count} Datei angehängt',
@@ -1117,6 +1123,11 @@ export const dict = {
'contextPanel.browser.annotate.submit': 'Anhängen',
'contextPanel.browser.trustNotice': 'Seiten, die hier geöffnet werden, laufen mit vollständigem Zugriff auf OpenChamber — erforderlich für Inspect und Screenshots. Öffnen Sie nur Seiten, denen Sie vertrauen: Eine bösartige Seite könnte Ihre Daten lesen oder in Ihrem Namen handeln.',
'contextPanel.tab.closeTabAria': '{label}-Registerkarte schließen',
'contextPanel.tab.menu.close': 'Schließen',
'contextPanel.tab.menu.closeOthers': 'Andere schließen',
'contextPanel.tab.menu.closeToLeft': 'Tabs links daneben schließen',
'contextPanel.tab.menu.closeToRight': 'Tabs rechts daneben schließen',
'contextPanel.tab.menu.closeAll': 'Alle Tabs schließen',
'contextPanel.actions.collapsePanel': 'Panel einklappen',
'contextPanel.actions.expandPanel': 'Panel ausklappen',
'contextPanel.actions.closePanel': 'Panel schließen',
@@ -1246,6 +1257,12 @@ export const dict = {
'filesView.editor.disableLineWrap': 'Zeilenumbruch deaktivieren',
'filesView.editor.enableLineWrap': 'Zeilenumbruch aktivieren',
'filesView.editor.findInFile': 'In Datei suchen',
'filesView.preview.find.placeholder': 'In Vorschau suchen',
'filesView.preview.find.nextAria': 'Nächster Treffer',
'filesView.preview.find.previousAria': 'Vorheriger Treffer',
'filesView.preview.find.closeAria': 'Suche schließen',
'filesView.preview.find.noMatches': 'Keine Treffer',
'filesView.preview.find.countAria': '{current} von {total}',
'filesView.editor.goToLine': 'Gehe zu Zeile',
'filesView.editor.switchToEditMode': 'Zum Bearbeitungsmodus wechseln',
'filesView.editor.switchToPreviewMode': 'Zum Vorschau-Modus wechseln',
@@ -1502,7 +1519,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.planImported': 'Plan importiert',
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Fehler beim Lesen der Plan-Datei',
'inlineComment.range.lines': 'Zeilen {start}-{end}',
'inlineComment.input.placeholder': 'Kommentar hinzufügen... (Cmd+Enter zum Speichern)',
'inlineComment.input.placeholder': 'Kommentar hinzufügen... ({shortcut} zum Speichern)',
'inlineComment.input.placeholderShort': 'Kommentar hinzufügen...',
'inlineComment.actions.cancel': 'Abbrechen',
'inlineComment.actions.save': 'Speichern',
@@ -1544,6 +1561,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminalpanel ({shortcut})',
'chat.recap.aria': 'Sitzungs-Zusammenfassung',
'chat.recap.label': 'Zusammenfassung:',
'chat.sessionError.title': 'OpenCode hat diese Antwort abgebrochen',
'chat.sessionError.noDetails': 'OpenCode hat keine Details gemeldet. Öffne den Statusbericht (Strg/Cmd+Umschalt+L), um die letzten Fehler zu sehen.',
'chat.sessionError.noReply': 'OpenCode hat keine Antwort auf diese Nachricht begonnen.',
'chat.goal.dialog.titleCreate': 'Sitzungsziel festlegen',
'chat.goal.dialog.titleManage': 'Sitzungsziel',
'chat.goal.dialog.objectiveLabel': 'Ziel',
@@ -1615,6 +1635,7 @@ export const dict = {
'directoryExplorerDialog.actions.openInFinder': 'Im Finder öffnen',
'directoryExplorerDialog.actions.adding': 'Füge hinzu...',
'directoryExplorerDialog.actions.addProject': 'Projekt hinzufügen',
'directoryExplorerDialog.actions.addSelected': 'Ausgewählte hinzufügen',
'directoryExplorerDialog.actions.addLocalProject': 'Lokales Projekt hinzufügen',
'directoryExplorerDialog.actions.cloneRepository': 'Repository klonen',
'directoryExplorerDialog.actions.cloneAndAdd': 'Klonen & hinzufügen',
@@ -1632,6 +1653,7 @@ export const dict = {
'directoryExplorerDialog.browse.parentDirectory': 'Übergeordnetes Verzeichnis',
'directoryExplorerDialog.browse.addedBadge': 'Hinzugefügt',
'directoryExplorerDialog.browse.quickAdd': 'Hinzufügen',
'directoryExplorerDialog.browse.selectForAdd': 'Zum Hinzufügen auswählen',
'directoryExplorerDialog.footer.navigate': 'Navigieren',
'directoryExplorerDialog.footer.select': 'Auswählen',
'directoryExplorerDialog.footer.add': 'Hinzufügen',
@@ -1640,6 +1662,7 @@ export const dict = {
'directoryExplorerDialog.toast.desktopDeniedAccess': 'Desktop hat den Zugriff auf das Verzeichnis verweigert.',
'directoryExplorerDialog.toast.failedToOpenDirectory': 'Fehler beim Öffnen des Verzeichnisses',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Desktop konnte keinen Dateizugriff gewähren.',
'directoryExplorerDialog.toast.addedProjects': '{count} Projekt(e) hinzugefügt',
'directoryExplorerDialog.toast.failedToAddProject': 'Fehler beim Hinzufügen des Projekts',
'directoryExplorerDialog.toast.cloneUrlRequired': 'Geben Sie eine Repository-URL ein, bevor Sie klonen.',
'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Bitte wählen Sie einen gültigen Verzeichnispfad aus.',
@@ -1688,22 +1711,18 @@ export const dict = {
'helpDialog.item.focusChatInput': 'Chat-Eingabe fokussieren',
'helpDialog.item.togglePromptNavigator': 'Aufforderungs-Navigator umschalten',
'helpDialog.item.abortActiveRun': 'Aktuelle Ausführung abbrechen (Doppeltaste)',
'helpDialog.item.toggleRightSidebar': 'Rechte Seitenleiste umschalten',
'helpDialog.item.openRightSidebarGitTab': 'Git-Registerkarte der rechten Seitenleiste öffnen',
'helpDialog.item.openRightSidebarFilesTab': 'Datei-Registerkarte der rechten Seitenleiste öffnen',
'helpDialog.item.toggleTerminalDock': 'Terminal-Dock umschalten',
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
'helpDialog.item.switchSessionTab': 'Sitzungs-Tab wechseln',
'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)',
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
'helpDialog.item.openSettings': 'Einstellungen öffnen',
'helpDialog.keyCombiner.or': 'oder',
'helpDialog.proTips.title': 'Pro-Tipps:',
'helpDialog.proTips.commandPalette': 'Verwenden Sie die Befehlspalette ({shortcut}), um schnell auf alle Aktionen zuzugreifen',
'helpDialog.proTips.recentSessions': 'Die 5 zuletzt verwendeten Sitzungen erscheinen in der Befehlspalette',
'helpDialog.proTips.themeCycling': 'Themenwechsel merken sich Ihre Einstellung über Sitzungen hinweg',
'helpDialog.proTips.leaderSequences': 'Zweistufige Kürzel: erst die Kombination, dann die zweite Taste — Esc bricht ab',
'header.actions.rightSidebarWithShortcut': 'Rechte Seitenleiste ({shortcut})',
'header.actions.toggleRightSidebarAria': 'Rechte Seitenleiste umschalten',
'header.actions.openAppMenu': 'OpenChamber-Menü',
@@ -1787,8 +1806,6 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'Keine übereinstimmenden Branches',
'session.newWorktree.localBranches': 'Lokale Branches',
'session.newWorktree.remoteBranches': 'Remote-Branches',
'session.newWorktree.otherLocalBranches': 'Andere lokale Branches',
'session.newWorktree.otherRemoteBranches': 'Andere Remote-Branches',
'session.newWorktree.branchName': 'Branch-Name',
'session.newWorktree.branchNamePlaceholder': 'feature/mein-geil-feature',
'session.newWorktree.actions.change': 'Ändern',
@@ -1911,7 +1928,6 @@ export const dict = {
'chat.statusRow.actions.stopGeneratingAria': 'Generierung stoppen',
'chat.statusRow.tasksTitle': 'Aufgaben',
'chat.statusRow.summary.activeLeft': '{active} aktiv · {left} übrig',
'chat.statusRow.aborted': 'Abgebrochen',
'chat.revertIndicator.redo': 'Wiederholen',
'chat.revertIndicator.redoAria': 'Wiederholen — wiederhergestellte Nachrichten',
'chat.revertPopover.title': 'Zurückgesetzt',
@@ -2022,10 +2038,8 @@ export const dict = {
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
'chat.textSelection.comment.attach': 'Anhängen',
'chat.textSelection.actions.newSession': 'Neue Sitzung',
'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen',
'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen',
'chat.textSelection.title.newSessionWithSelection': 'Neue Sitzung mit Auswahl erstellen',
'chat.textSelection.title.saveInsightToNotes': 'Ausgewählten Text zu Notizen speichern',
'chat.messageBody.actions.revertAria': 'Zu dieser Nachricht zurückkehren',
'chat.messageBody.actions.revert': 'Von hier zurückkehren',
@@ -2118,7 +2132,12 @@ export const dict = {
'chat.chatInput.toast.attachmentsTooLarge': 'Anhänge sind zu groß zum Senden. Bitte versuche, die Anzahl oder Größe der Bilder zu reduzieren.',
'chat.chatInput.toast.sendAttachmentsFailed': 'Fehler beim Senden der Anhänge. Versuche weniger Dateien oder kleinere Bilder.',
'chat.chatInput.toast.messageSendFailed': 'Nachricht konnte nicht gesendet werden. Anhänge wurden wiederhergestellt.',
'chat.chatInput.toast.noModelSelected': 'Wähle vor dem Senden einen Anbieter und ein Modell aus.',
'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Fehler beim Anhängen des eingefügten Texts als Datei',
'chat.chatInput.toast.largeTextPaste.title': 'Großer Text erkannt',
'chat.chatInput.toast.largeTextPaste.attach': 'Als Datei anhängen',
'chat.chatInput.toast.largeTextPaste.inline': 'Direkt einfügen',
'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt',
'chat.chatInput.toast.attachFileFailed': 'Fehler beim Anhängen der Datei',
'chat.chatInput.toast.attachNamedFailed': 'Fehler beim Anhängen von {name}',
@@ -2166,6 +2185,7 @@ export const dict = {
'chat.toolPart.showRawJson': 'Rohe JSON anzeigen',
'chat.toolPart.showFormattedJson': 'Formatierte JSON anzeigen',
'chat.toolPart.showNavigableJson': 'Navigierbare JSON anzeigen',
'chat.toolPart.openFile': 'Datei öffnen',
'chat.toolPart.openFileAtFirstChange': 'Datei bei erster Änderung öffnen',
'chat.toolPart.openFileDiff': 'Datei-Unterschied öffnen',
'chat.toolPart.copyOutput': 'Ausgabe kopieren',
@@ -2297,6 +2317,15 @@ export const dict = {
'commandPalette.item.toggleSidebar': 'Seitenleiste umschalten',
'commandPalette.item.showContextUsage': 'Kontextnutzung anzeigen',
'commandPalette.item.toggleTerminal': 'Terminal umschalten',
'commandPalette.item.cycleTheme': 'Thema wechseln',
'commandPalette.item.showOpenCodeStatus': 'OpenCode-Status anzeigen',
'commandPalette.item.toggleMemoryDebug': 'Memory-Debug-Panel umschalten',
'commandPalette.item.pinSession': 'Sitzung anheften oder lösen',
'commandPalette.item.copySessionId': 'Sitzungs-ID kopieren',
'commandPalette.item.openMultiRun': 'Multi-Run-Launcher öffnen',
'commandPalette.item.openArchive': 'Archivierte Sitzungen öffnen',
'commandPalette.item.openNotes': 'Notizbereich öffnen',
'commandPalette.item.openTodos': 'To-do-Bereich öffnen',
'commandPalette.item.openSettings': 'Einstellungen öffnen...',
'commandPalette.session.untitled': 'Unbenannte Sitzung',
'openCodeStatusDialog.title': 'OpenCode-Status',
@@ -2510,6 +2539,9 @@ export const dict = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey-Anmeldung wurde abgebrochen.',
'sessionAuth.error.enterPasswordForPasskey': 'Geben Sie Ihr Passwort ein, um einen Passkey hinzuzufügen.',
'sessionAuth.locked.tunnelTitle': 'Tunnel-Zugriff erforderlich',
'sessionAuth.expired.banner': 'Deine Sitzung ist abgelaufen — melde dich an, um fortzufahren.',
'sessionAuth.expired.loginAction': 'Anmelden',
'sessionAuth.expired.sendBlocked': 'Sitzung abgelaufen — melde dich an, um Nachrichten zu senden.',
'sessionAuth.locked.unlockTitle': 'OpenChamber entsperren',
'sessionAuth.locked.tunnelDescription': 'Öffnen Sie diesen Tunnel über den Einmal-Verbindungslink aus der Desktop-Anwendung.',
'sessionAuth.locked.passwordDescription': 'Diese Sitzung ist passwortgeschützt.',
@@ -2788,6 +2820,10 @@ export const dict = {
'updateDialog.status.updating': 'Aktualisierung läuft...',
'updateDialog.error.updateFailed': 'Aktualisierung fehlgeschlagen',
'updateDialog.error.takingLonger': 'Die Aktualisierung dauert länger als erwartet. Warten Sie einen Moment und aktualisieren Sie die Seite oder führen Sie folgenden Befehl aus: openchamber update',
'updateDialog.error.signatureRejected': 'Das heruntergeladene Update wurde abgelehnt: Seine Codesignatur passt nicht zu dieser Installation. Meist bedeutet das, dass die laufende Kopie nicht aus einer offiziellen signierten Version stammt. Installieren Sie OpenChamber aus einer offiziellen Version und aktualisieren Sie erneut.',
'updateDialog.error.updaterDisabled': 'Der Updater wurde nach einer fehlgeschlagenen Installation gestoppt. Beenden Sie OpenChamber, öffnen Sie es erneut und versuchen Sie das Update noch einmal.',
'updateDialog.error.restartFailed': 'Neustart zum Installieren des Updates fehlgeschlagen.',
'updateDialog.error.restartUnavailable': 'Das Installieren des Updates erfordert die OpenChamber-Desktop-App.',
'mobileUpdate.toast.available.title': 'OpenChamber-Update verfügbar',
'mobileUpdate.toast.available.description': 'Version {version} ist für Android bereit.',
'mobileUpdate.toast.actions.download': 'Herunterladen',
@@ -2808,6 +2844,7 @@ export const dict = {
'memoryDebugPanel.title': 'Debug Panel',
'memoryDebugPanel.tabs.memory': 'Speicher',
'memoryDebugPanel.tabs.streaming': 'Streaming',
'memoryDebugPanel.tabs.requests': 'Anfragen',
'memoryDebugPanel.section.sessionsInMemory': 'Sitzungen im Speicher',
'memoryDebugPanel.section.uiStreamingMetrics': 'UI-Streaming-Metriken',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code Bridge Metriken',
@@ -2845,6 +2882,16 @@ export const dict = {
'memoryDebugPanel.streaming.copy.copied': 'Streaming-Debug-JSON kopiert',
'memoryDebugPanel.streaming.copy.failed': 'Fehler beim Kopieren der JSON-Datei',
'memoryDebugPanel.streaming.copy.hint': 'Kopieren exportiert sowohl UI- als auch VS Code-Streaming-Metriken als JSON',
'memoryDebugPanel.requests.inFlight': 'Laufend',
'memoryDebugPanel.requests.peak': 'Spitze',
'memoryDebugPanel.requests.duration': 'Dauer',
'memoryDebugPanel.requests.totalRequests': 'Gesamtanfragen',
'memoryDebugPanel.requests.tracking': 'Aufzeichnung',
'memoryDebugPanel.requests.now': 'jetzt',
'memoryDebugPanel.requests.noSamples': 'Noch keine Anfragen aufgezeichnet. Lassen Sie dieses Panel geöffnet, um Fetch-Aktivität zu erfassen.',
'memoryDebugPanel.requests.chartLabel': 'Laufende Fetch-Anfragen im Zeitverlauf, Spitze {peak}',
'memoryDebugPanel.requests.windowHint': 'letzte {seconds}s',
'memoryDebugPanel.requests.percentileChartLabel': 'Perzentile des Alters laufender Anfragen (p50, p90, p99, max) im Zeitverlauf',
'memoryDebugPanel.common.idle': 'inaktiv',
'memoryDebugPanel.common.live': 'live',
'memoryDebugPanel.common.notAvailable': 'n/a',
@@ -2908,7 +2955,7 @@ export const dict = {
'quota.window.premium': 'Premium-Interaktionen',
'quota.window.chat': 'Chat-Anfragen',
'quota.window.completions': 'Vervollständigungen',
'quota.window.premiumInteractions': 'Premium-Interaktionen',
'quota.window.premiumInteractions': 'KI-Guthaben',
'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen',
'terminalView.actions.restart': 'Terminal neu starten',
'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}',
@@ -2976,11 +3023,33 @@ export const dict = {
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
'sessions.sidebar.session.copyId.error': 'Sitzungs-ID konnte nicht kopiert werden',
'sessions.sidebar.session.menu.moveToWorktree': 'In neuen Worktree verschieben',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'In Worktree verschieben',
'sessions.sidebar.session.menu.newWorktree': 'Neuer Worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Sitzung in einen neuen Worktree verschoben',
'sessions.sidebar.session.moveToWorktree.failed': 'Sitzung konnte nicht in einen neuen Worktree verschoben werden',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.',
'sessions.sidebar.session.moveToWorktree.main': 'Haupt-Worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktrees werden aktualisiert...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees konnten nicht geladen werden',
'sessions.sidebar.session.moveToWorktree.current': 'Aktueller Worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sitzung in Worktree verschoben',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Sitzung konnte nicht in Worktree verschoben werden',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Zeigt vorhandene Worktrees und die Option, für diese Sitzung einen neuen zu erstellen.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch und verschiebt diese Sitzung samt Untersitzungen dorthin. Bei ungespeicherten Änderungen in der Quelle wählst du, ob sie mit verschoben werden.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Die Quelle hat ungespeicherte Änderungen',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Geänderte Dateien in diesem Worktree: {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode verfolgt diese Änderungen nach Verzeichnis, nicht nach Sitzung.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Verschiebt diese Sitzung und ihre Untersitzungen, ohne die Quelldateien zu verändern.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Überträgt die Änderungen im Sitzungsverzeichnis. Nicht committete und unversionierte Dateien verlassen die Quelle nach Erfolg.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Gemappte (staged) Änderungen bleiben in der Quelle und werden ans Ziel kopiert.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Die Übertragung kann fehlschlagen, wenn das Ziel eine andere Git-Basis verwendet.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Nur Sitzung verschieben',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Alle Quelländerungen verschieben',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Abbrechen',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Die Änderungen in der Quelle konnten nicht geprüft werden. Es wurde kein Worktree und keine Sitzung geändert.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Das Ziel konnte die Änderungen der Quelle nicht übernehmen. Sitzung und Änderungen wurden nicht verschoben. Versuche es erneut und wähle Nur Sitzung verschieben.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Die Verbindung brach ab, bevor das Ziel den Wechsel bestätigt hat. Die Sitzung wurde möglicherweise nicht verschoben, und deine nicht committeten Änderungen liegen eventuell schon im Ziel-Worktree. Sieh dort nach, bevor du es erneut versuchst.',
'sessions.sidebar.session.export.failedLoadHistory': 'Die vollständige Sitzungshistorie konnte nicht geladen werden',
'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben',
'gitView.header.updateBranch': 'Branch aktualisieren',
@@ -2994,6 +3063,11 @@ export const dict = {
'gitView.pr.segment.comments': 'Kommentare',
'gitView.pr.comments.addAll': 'Alle hinzufügen',
'contextPanel.mode.pr': 'PR',
'contextRail.configure.open': 'Panels konfigurieren',
'contextRail.configure.dialogTitle': 'Leisten-Panels',
'contextRail.configure.dialogDescription': 'Wähle, welche Panels die Leiste zeigt. Ausgeblendete Panels behalten ihre Daten und bleiben über die Befehlspalette erreichbar.',
'contextRail.configure.showAll': 'Alle anzeigen',
'contextRail.configure.noneWarning': 'Alle Panels sind ausgeblendet.',
'contextRail.aria.rail': 'Kontextleiste',
'contextPanel.editorEmpty.title': 'Kein Kontext ausgewählt',
'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.',
@@ -3085,7 +3159,8 @@ export const dict = {
'chat.commandAutocomplete.command.scheduleTaskDescription': 'Eine geplante Aufgabe erstellen',
'chat.chatInput.toast.scheduleTaskFailed': 'Aufgabe konnte nicht geplant werden',
'chat.container.sessionLoadError.title': 'Sitzung konnte nicht geladen werden',
'chat.container.sessionLoadError.description': 'Die Sitzung konnte nicht geladen werden.',
'chat.container.sessionLoadError.description': 'Die Unterhaltung konnte nicht geladen werden — der Server ist womöglich offline oder nicht erreichbar. Nichts ist verloren; versuche es erneut, sobald er wieder da ist.',
'chat.container.sessionLoadError.authDescription': 'Deine Sitzung ist abgelaufen, daher hat der Server die Anfrage abgelehnt. Melde dich an, dann wird die Unterhaltung geladen.',
'chat.container.sessionLoadError.retry': 'Erneut versuchen',
'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...',
'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden',
@@ -3104,6 +3179,7 @@ export const dict = {
'updateDialog.changelog.title': 'Neuigkeiten',
'chat.workStatus.ariaLabel': 'Arbeitsstatus',
'chat.workStatus.context.label': 'Kontext',
'chat.workStatus.cost.breakdown': 'Sitzung {session} · Unteragenten {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} Datei geändert',
'chat.workStatus.git.changedFilePlural': '{count} Dateien geändert',
'chat.workStatus.pr.untitled': 'Pull Request ohne Titel',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking',
@@ -1133,7 +1134,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'This combo is already used by another shortcut. Overwrite and clear that other mapping?',
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Press keys...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capture a shortcut first.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. It is still saved.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. You can still save it.',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Go to line (files editor)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Open command palette',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Focus input',
@@ -1142,18 +1143,20 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Toggle terminal expanded',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Add selection to chat',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Toggle sidebar',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Switch session tab',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Previous session',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Next session',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Rename current session',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Toggle permission auto-accept',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Close session tab',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Open keyboard shortcuts',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Toggle plan context panel',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Toggle services menu',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Cycle services tab',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Cycle theme',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Cycle agent',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Cycle favorite model forward',
@@ -1162,6 +1165,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Expand input',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Open conversation timeline',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Toggle prompt navigator',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'This sequence shares a contextual prefix with {action}. That action takes priority while its context is active.',
'settings.openchamber.keyboardShortcuts.category.session': 'Session Controls',
'settings.openchamber.keyboardShortcuts.category.models': 'Models & Agents',
'settings.openchamber.keyboardShortcuts.category.panels': 'Panels & Tools',
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirm',
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations, with at most three keys each. After the first, wait up to 3 seconds for a second combination. Use Confirm to apply or Cancel to discard. Backspace removes the last one.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'First combination',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…',
'settings.openchamber.keyboardShortcuts.unassigned': 'Unassigned',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'This conflicts with the sequence used by {action}. Choose a different combination.',
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'This combination is already used by {action}.',
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'This combination conflicts with a built-in shortcut, which cannot be replaced.',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Open draft project picker',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Open draft worktree picker',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open recent sessions',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Voice input',
'settings.projects.sidebar.total': 'Total {count}',
'settings.projects.sidebar.actions.addProject': 'Add project',
'settings.projects.page.empty.noProjects': 'No projects available.',
@@ -1838,7 +1862,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Server',
'settings.voice.page.provider.local': 'Local',
'settings.voice.page.tooltip.sttLocal': 'On-device transcription on the OpenChamber server. Models download automatically; no API key needed.',
'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro, English). The model downloads automatically; no API key needed.',
'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro for English; models for other languages download on first use). No API key needed.',
'settings.voice.page.field.followTextLanguage': 'Match the voice to the language of the text',
'settings.voice.page.field.followTextLanguageAria': 'Match the voice to the language of the text',
'settings.voice.page.field.followTextLanguageInfo': 'When a reply is in another language, a voice for that language is used: a matching macOS voice, or a local model that downloads on first use.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (English)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 European languages)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingual)',
@@ -1932,7 +1959,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Follow new content while streaming',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatically follow new content while a response streams',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually.',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually; sending a message while scrolled up then also leaves the view where it is.',
'settings.openchamber.visual.section.messageAppearance': 'Message Appearance',
'settings.openchamber.visual.section.toolsAndFiles': 'Tools & Files',
'settings.openchamber.visual.section.composer': 'Composer',
@@ -2062,6 +2089,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': 'Persist Draft Messages',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Enable spellcheck in text inputs',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs',
'settings.openchamber.visual.field.largeTextPaste': 'Large text paste',
'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Attach as file',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Paste inline',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Send anonymous usage reports',
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Send anonymous usage reports',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.',
@@ -2187,5 +2221,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...linearIntegrationI18n.en,
...thirdPartyIntegrationI18n.en,
} as const;
+92 -16
View File
@@ -1,7 +1,11 @@
import { settingsDict } from './en.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
...settingsDict,
...linearIssuePickerI18n.en,
...linearPanelI18n.en,
'terminalView.actions.attachSelection': 'Attach selected output',
'terminalView.actions.restart': 'Restart terminal',
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
@@ -37,6 +41,7 @@ export const dict = {
'common.language.korean': 'Korean',
'common.language.polish': 'Polish',
'common.language.japanese': 'Japanese',
'common.language.turkish': 'Turkish',
'common.revealPath.finder': 'Reveal in Finder',
'common.revealPath.fileExplorer': 'Open in File Explorer',
'common.revealPath.fileManager': 'Open in File Manager',
@@ -129,6 +134,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Switch project',
'mobile.sessions.section.projects': 'Projects',
'mobile.sessions.section.chats': 'Chats',
'mobile.sessions.empty.noProjectsTitle': 'No projects yet',
'mobile.sessions.empty.noProjectsDescription': 'Add a project to start chatting with your code.',
'mobile.sessions.empty.noSessionsTitle': 'No sessions yet',
@@ -383,7 +389,7 @@ export const dict = {
'multirun.launcher.attachments.attach': 'Attach',
'multirun.launcher.attachments.tooltip': 'Same files sent to all runs',
'multirun.launcher.models.label': 'Models',
'multirun.launcher.models.info': 'Select 2-{max} models. Same model can be added multiple times.',
'multirun.launcher.models.info': 'Select 2 or more models. Same model can be added multiple times.',
'multirun.launcher.toast.fileTooLarge': 'File "{fileName}" is too large (max 10MB)',
'multirun.launcher.toast.attachFailed': 'Failed to attach "{fileName}"',
'multirun.launcher.toast.attachedSingle': 'Attached {count} file',
@@ -536,11 +542,33 @@ export const dict = {
'sessions.sidebar.session.menu.unshare': 'Unshare',
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Move to new worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Move to worktree',
'sessions.sidebar.session.menu.newWorktree': 'New worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Session moved to a new worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Failed to move session to a new worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch, transfers uncommitted changes, and moves this session and its sub-sessions there.',
'sessions.sidebar.session.moveToWorktree.main': 'Main worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Refreshing worktrees...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees could not be loaded',
'sessions.sidebar.session.moveToWorktree.current': 'Current worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session moved to worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Failed to move session to worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Shows existing worktrees and the option to create a new one for this session.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch and moves this session and its sub-sessions there. When the source has uncommitted changes, you choose whether to move them.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Available when the session is idle. Stop or wait for the current activity to finish.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'This session is already being moved to a new worktree.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Source has uncommitted changes',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Changed files in this worktree: {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode tracks these changes by directory, not by session.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Move this session and its sub-sessions while leaving every source file unchanged.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfer changes under the session directory. Unstaged and untracked files leave the source after success.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Staged changes remain in the source and are copied to the destination.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'The transfer can fail when the destination uses a different Git base.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Move session only',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Move all source changes',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Cancel',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Source changes could not be verified. No worktree or session was changed.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'The destination could not accept the source changes. The session and source changes were not moved. Retry and choose Move session only.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'The connection dropped before the destination confirmed the move. The session may not have moved, and your uncommitted changes may already be in the destination worktree. Check there before retrying.',
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor',
@@ -1143,6 +1171,11 @@ export const dict = {
'contextPanel.mode.context': 'Context',
'contextPanel.mode.preview': 'Preview',
'contextPanel.mode.browser': 'Browser',
'contextRail.configure.open': 'Configure panels',
'contextRail.configure.dialogTitle': 'Rail panels',
'contextRail.configure.dialogDescription': 'Choose which panels the rail shows. Hidden panels keep their data and stay reachable from the command palette.',
'contextRail.configure.showAll': 'Show all',
'contextRail.configure.noneWarning': 'All panels are hidden.',
'contextRail.aria.rail': 'Panel surfaces',
'contextPanel.editorEmpty.title': 'No file open',
'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.',
@@ -1283,6 +1316,11 @@ export const dict = {
'contextPanel.browser.annotate.submit': 'Attach',
'contextPanel.browser.trustNotice': 'Pages opened here run with full access to OpenChamber — needed for inspect and screenshots. Only open sites you trust: a malicious page could read your data or act on your behalf.',
'contextPanel.tab.closeTabAria': 'Close {label} tab',
'contextPanel.tab.menu.close': 'Close',
'contextPanel.tab.menu.closeOthers': 'Close others',
'contextPanel.tab.menu.closeToLeft': 'Close tabs to the left',
'contextPanel.tab.menu.closeToRight': 'Close tabs to the right',
'contextPanel.tab.menu.closeAll': 'Close all tabs',
'contextPanel.actions.collapsePanel': 'Collapse panel',
'contextPanel.actions.expandPanel': 'Expand panel',
'contextPanel.actions.closePanel': 'Close panel',
@@ -1414,6 +1452,12 @@ export const dict = {
'filesView.editor.disableLineWrap': 'Disable line wrap',
'filesView.editor.enableLineWrap': 'Enable line wrap',
'filesView.editor.findInFile': 'Find in file',
'filesView.preview.find.placeholder': 'Find in preview',
'filesView.preview.find.nextAria': 'Next match',
'filesView.preview.find.previousAria': 'Previous match',
'filesView.preview.find.closeAria': 'Close search',
'filesView.preview.find.noMatches': 'No matches',
'filesView.preview.find.countAria': '{current} of {total}',
'filesView.editor.goToLine': 'Go to line',
'filesView.editor.switchToEditMode': 'Switch to edit mode',
'filesView.editor.switchToPreviewMode': 'Switch to preview mode',
@@ -1672,7 +1716,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.planImported': 'Plan imported',
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Failed to read plan file',
'inlineComment.range.lines': 'Lines {start}-{end}',
'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)',
'inlineComment.input.placeholder': 'Add a comment... ({shortcut} to save)',
'inlineComment.input.placeholderShort': 'Add a comment...',
'inlineComment.actions.cancel': 'Cancel',
'inlineComment.actions.save': 'Save',
@@ -1714,6 +1758,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
'chat.recap.aria': 'Session recap',
'chat.recap.label': 'Recap:',
'chat.sessionError.title': 'OpenCode stopped this reply',
'chat.sessionError.noDetails': 'OpenCode reported no details. Open the status report (Ctrl/Cmd+Shift+L) to see recent errors.',
'chat.sessionError.noReply': 'OpenCode did not start a reply to this message.',
'chat.goal.dialog.titleCreate': 'Set Session Goal',
'chat.goal.dialog.titleManage': 'Session Goal',
'chat.goal.dialog.objectiveLabel': 'Objective',
@@ -1789,6 +1836,7 @@ export const dict = {
'directoryExplorerDialog.actions.openInFinder': 'Open in Finder',
'directoryExplorerDialog.actions.adding': 'Adding...',
'directoryExplorerDialog.actions.addProject': 'Add project',
'directoryExplorerDialog.actions.addSelected': 'Add selected',
'directoryExplorerDialog.actions.addLocalProject': 'Add local project',
'directoryExplorerDialog.actions.cloneRepository': 'Clone repository',
'directoryExplorerDialog.actions.cloneAndAdd': 'Clone & add',
@@ -1806,6 +1854,7 @@ export const dict = {
'directoryExplorerDialog.browse.parentDirectory': 'Parent directory',
'directoryExplorerDialog.browse.addedBadge': 'Added',
'directoryExplorerDialog.browse.quickAdd': 'Add',
'directoryExplorerDialog.browse.selectForAdd': 'Select for add',
'directoryExplorerDialog.footer.navigate': 'Navigate',
'directoryExplorerDialog.footer.select': 'Select',
'directoryExplorerDialog.footer.add': 'Add',
@@ -1814,6 +1863,7 @@ export const dict = {
'directoryExplorerDialog.toast.desktopDeniedAccess': 'Desktop denied directory access.',
'directoryExplorerDialog.toast.failedToOpenDirectory': 'Failed to open directory',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Desktop could not grant file access.',
'directoryExplorerDialog.toast.addedProjects': 'Added {count} project(s)',
'directoryExplorerDialog.toast.failedToAddProject': 'Failed to add project',
'directoryExplorerDialog.toast.cloneUrlRequired': 'Enter a repository URL before cloning.',
'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Please select a valid directory path.',
@@ -1862,22 +1912,18 @@ export const dict = {
'helpDialog.item.focusChatInput': 'Focus Chat Input',
'helpDialog.item.togglePromptNavigator': 'Toggle Prompt Navigator',
'helpDialog.item.abortActiveRun': 'Abort active run (double press)',
'helpDialog.item.toggleRightSidebar': 'Toggle context panel',
'helpDialog.item.openRightSidebarGitTab': 'Open Git surface',
'helpDialog.item.openRightSidebarFilesTab': 'Open Files surface',
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
'helpDialog.item.switchSessionTab': 'Switch Session Tab',
'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)',
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
'helpDialog.item.openSettings': 'Open Settings',
'helpDialog.keyCombiner.or': 'or',
'helpDialog.proTips.title': 'Pro Tips:',
'helpDialog.proTips.commandPalette': 'Use Command Palette ({shortcut}) to quickly access all actions',
'helpDialog.proTips.recentSessions': 'The 5 most recent sessions appear in the Command Palette',
'helpDialog.proTips.themeCycling': 'Theme cycling remembers your preference across sessions',
'helpDialog.proTips.leaderSequences': 'Two-step shortcuts: press the first combo, then the second key — Esc cancels',
'header.actions.rightSidebarWithShortcut': 'Right sidebar ({shortcut})',
'header.actions.toggleRightSidebarAria': 'Toggle right sidebar',
'header.actions.openAppMenu': 'OpenChamber menu',
@@ -1961,8 +2007,6 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'No matching branches',
'session.newWorktree.localBranches': 'Local branches',
'session.newWorktree.remoteBranches': 'Remote branches',
'session.newWorktree.otherLocalBranches': 'Other local branches',
'session.newWorktree.otherRemoteBranches': 'Other remote branches',
'session.newWorktree.branchName': 'Branch Name',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': 'Change',
@@ -2087,7 +2131,6 @@ export const dict = {
'chat.statusRow.tasksTitle': 'Tasks',
'chat.statusRow.modelStatus': '{model} is {status}',
'chat.statusRow.summary.activeLeft': '{active} active · {left} left',
'chat.statusRow.aborted': 'Aborted',
'chat.revertIndicator.redo': 'Redo',
'chat.revertIndicator.redoAria': 'Redo — restore reverted messages',
'chat.revertPopover.title': 'Reverted',
@@ -2165,7 +2208,8 @@ export const dict = {
'chat.btw.promoteAria': 'Keep as a separate session',
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
'chat.container.sessionLoadError.title': 'Session could not be loaded',
'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.',
'chat.container.sessionLoadError.description': 'The conversation could not be fetched — the server may be offline or unreachable. Nothing is lost; retry once it is back.',
'chat.container.sessionLoadError.authDescription': 'Your session expired, so the server refused the request. Log in and the conversation will load.',
'chat.container.sessionLoadError.retry': 'Try again',
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
@@ -2208,10 +2252,8 @@ export const dict = {
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
'chat.textSelection.comment.attach': 'Attach',
'chat.textSelection.actions.newSession': 'New session',
'chat.textSelection.actions.addToNotes': 'Add to notes',
'chat.textSelection.title.addToCurrentChat': 'Add to current chat',
'chat.textSelection.title.newSessionWithSelection': 'Create new session with selection',
'chat.textSelection.title.saveInsightToNotes': 'Save selected text to notes',
'chat.messageBody.actions.revertAria': 'Revert to this message',
'chat.messageBody.actions.revert': 'Revert from here',
@@ -2307,7 +2349,12 @@ export const dict = {
'chat.chatInput.toast.attachmentsTooLarge': 'Attachments are too large to send. Please try reducing the number or size of images.',
'chat.chatInput.toast.sendAttachmentsFailed': 'Failed to send attachments. Try fewer files or smaller images.',
'chat.chatInput.toast.messageSendFailed': 'Message failed to send. Attachments restored.',
'chat.chatInput.toast.noModelSelected': 'Select a provider and model before sending.',
'chat.chatInput.toast.clipboardAttachFailed': 'Failed to attach image from clipboard',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Failed to attach pasted text as a file',
'chat.chatInput.toast.largeTextPaste.title': 'Large text detected',
'chat.chatInput.toast.largeTextPaste.attach': 'Attach as file',
'chat.chatInput.toast.largeTextPaste.inline': 'Paste inline',
'chat.chatInput.toast.addedFileMentions': 'Added {count} file mention(s)',
'chat.chatInput.toast.attachFileFailed': 'Failed to attach file',
'chat.chatInput.toast.attachNamedFailed': 'Failed to attach {name}',
@@ -2356,6 +2403,7 @@ export const dict = {
'chat.toolPart.showRawJson': 'Show raw JSON',
'chat.toolPart.showFormattedJson': 'Show formatted JSON',
'chat.toolPart.showNavigableJson': 'Show navigable JSON',
'chat.toolPart.openFile': 'Open file',
'chat.toolPart.openFileAtFirstChange': 'Open file at first change',
'chat.toolPart.openFileDiff': 'Open file diff',
'chat.toolPart.copyOutput': 'Copy output',
@@ -2487,6 +2535,15 @@ export const dict = {
'commandPalette.item.toggleSidebar': 'Toggle Sidebar',
'commandPalette.item.showContextUsage': 'Show Context Usage',
'commandPalette.item.toggleTerminal': 'Toggle Terminal',
'commandPalette.item.cycleTheme': 'Cycle theme',
'commandPalette.item.showOpenCodeStatus': 'Show OpenCode status',
'commandPalette.item.toggleMemoryDebug': 'Toggle memory debug panel',
'commandPalette.item.pinSession': 'Pin or unpin session',
'commandPalette.item.copySessionId': 'Copy session ID',
'commandPalette.item.openMultiRun': 'Open multi-run launcher',
'commandPalette.item.openArchive': 'Open archived sessions',
'commandPalette.item.openNotes': 'Open notes surface',
'commandPalette.item.openTodos': 'Open todos surface',
'commandPalette.item.openSettings': 'Open Settings...',
'commandPalette.session.untitled': 'Untitled Session',
'openCodeStatusDialog.title': 'OpenCode Status',
@@ -2701,6 +2758,9 @@ export const dict = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey sign-in was canceled.',
'sessionAuth.error.enterPasswordForPasskey': 'Enter your password to add a passkey.',
'sessionAuth.locked.tunnelTitle': 'Tunnel access required',
'sessionAuth.expired.banner': 'Your session expired — log in to continue.',
'sessionAuth.expired.loginAction': 'Log in',
'sessionAuth.expired.sendBlocked': 'Session expired — log in to send messages.',
'sessionAuth.locked.unlockTitle': 'Unlock OpenChamber',
'sessionAuth.locked.tunnelDescription': 'Open this tunnel using the one-time connect link from the desktop app.',
'sessionAuth.locked.passwordDescription': 'This session is password-protected.',
@@ -2983,6 +3043,10 @@ export const dict = {
'updateDialog.status.updating': 'Updating...',
'updateDialog.error.updateFailed': 'Update failed',
'updateDialog.error.takingLonger': 'Update is taking longer than expected. Wait a bit and refresh, or run: openchamber update',
'updateDialog.error.signatureRejected': 'The downloaded update was rejected: its code signature does not match this installation. This usually means the running copy was not installed from an official signed release. Install OpenChamber from an official release, then update again.',
'updateDialog.error.updaterDisabled': 'The updater stopped after a failed install. Quit OpenChamber, open it again, and retry the update.',
'updateDialog.error.restartFailed': 'Could not restart to install the update.',
'updateDialog.error.restartUnavailable': 'Installing the update requires the OpenChamber desktop app.',
'mobileUpdate.toast.available.title': 'OpenChamber update available',
'mobileUpdate.toast.available.description': 'Version {version} is ready for Android.',
'mobileUpdate.toast.actions.download': 'Download',
@@ -3003,6 +3067,7 @@ export const dict = {
'memoryDebugPanel.title': 'Debug Panel',
'memoryDebugPanel.tabs.memory': 'Memory',
'memoryDebugPanel.tabs.streaming': 'Streaming',
'memoryDebugPanel.tabs.requests': 'Requests',
'memoryDebugPanel.section.sessionsInMemory': 'Sessions in Memory',
'memoryDebugPanel.section.uiStreamingMetrics': 'UI Streaming Metrics',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code Bridge Metrics',
@@ -3040,6 +3105,16 @@ export const dict = {
'memoryDebugPanel.streaming.copy.copied': 'Streaming debug JSON copied',
'memoryDebugPanel.streaming.copy.failed': 'Failed to copy JSON',
'memoryDebugPanel.streaming.copy.hint': 'Copy exports both UI and VS Code streaming metrics as JSON',
'memoryDebugPanel.requests.inFlight': 'In flight',
'memoryDebugPanel.requests.peak': 'Peak',
'memoryDebugPanel.requests.duration': 'Duration',
'memoryDebugPanel.requests.totalRequests': 'Total Requests',
'memoryDebugPanel.requests.tracking': 'Tracking',
'memoryDebugPanel.requests.now': 'now',
'memoryDebugPanel.requests.noSamples': 'No requests tracked yet. Keep this panel open to record fetch activity.',
'memoryDebugPanel.requests.chartLabel': 'Fetch requests in flight over time, peak {peak}',
'memoryDebugPanel.requests.windowHint': 'last {seconds}s',
'memoryDebugPanel.requests.percentileChartLabel': 'In-flight request age percentiles (p50, p90, p99, max) over time',
'memoryDebugPanel.common.idle': 'idle',
'memoryDebugPanel.common.live': 'live',
'memoryDebugPanel.common.notAvailable': 'n/a',
@@ -3103,9 +3178,10 @@ export const dict = {
'quota.window.premium': 'Premium Interactions',
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'quota.window.premiumInteractions': 'AI Credits',
'chat.workStatus.ariaLabel': 'Work status',
'chat.workStatus.context.label': 'Context',
'chat.workStatus.cost.breakdown': 'Session {session} · Subagents {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} file changed',
'chat.workStatus.git.changedFilePlural': '{count} files changed',
'chat.workStatus.pr.untitled': 'Untitled pull request',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go',
@@ -1101,7 +1102,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinación ya está usada por otro atajo. ¿Sobrescribir y limpiar esa otra asignación?",
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Pulsa las teclas...",
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura un atajo primero.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Todavía se guarda.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Aun así, puedes guardarlo.",
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir a línea (editor de archivos)",
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos",
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Enfocar entrada",
@@ -1110,18 +1111,20 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer terminal",
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Agregar selección al chat",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar u ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Cambiar pestaña de sesión",
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
"settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Sesión anterior",
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Sesión siguiente",
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renombrar sesión actual",
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprobación automática",
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Cerrar pestaña de sesión",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
"settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atajos de teclado",
"settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar panel de plan de contexto",
"settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar u ocultar menú de servicios",
"settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Cambiar pestaña de servicios",
"settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Cambiar tema",
"settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Cambiar agente",
"settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Siguiente modelo favorito",
@@ -1130,6 +1133,27 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada",
"settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir línea de tiempo de conversación",
"settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar u ocultar navegador de prompts",
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta secuencia comparte un prefijo contextual con {action}. Cuando su contexto está activo, esa acción tiene prioridad.",
"settings.openchamber.keyboardShortcuts.category.session": "Controles de sesión",
"settings.openchamber.keyboardShortcuts.category.models": "Modelos y agentes",
"settings.openchamber.keyboardShortcuts.category.panels": "Paneles y herramientas",
"settings.openchamber.keyboardShortcuts.category.navigation": "Navegación",
"settings.openchamber.keyboardShortcuts.category.application": "Aplicación",
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas, con un máximo de tres teclas cada una. Tras la primera, espere hasta 3 segundos por una segunda combinación. Use Confirmar para aplicar o Cancelar para descartar. Retroceso elimina la última.",
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primera combinación",
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…",
"settings.openchamber.keyboardShortcuts.unassigned": "Sin asignar",
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Esto entra en conflicto con la secuencia usada por {action}. Elija otra combinación.",
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinación ya la usa {action}.",
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinación entra en conflicto con un atajo integrado, que no se puede reemplazar.",
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir selector de proyecto de borrador",
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir selector de árbol de trabajo de borrador",
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sesiones recientes",
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada de voz",
"settings.projects.sidebar.total": "Total {count}",
"settings.projects.sidebar.actions.addProject": "Añadir proyecto",
"settings.projects.page.empty.noProjects": "No hay proyectos disponibles.",
@@ -1815,7 +1839,10 @@ export const settingsDict = {
"settings.voice.page.provider.server": "Servidor",
"settings.voice.page.provider.local": "Local",
"settings.voice.page.tooltip.sttLocal": "Transcripción local en el servidor de OpenChamber. Los modelos se descargan automáticamente; no se necesita clave de API.",
"settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro, inglés). El modelo se descarga automáticamente; no se necesita clave de API.",
"settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro para inglés; los modelos de otros idiomas se descargan en el primer uso). No requiere clave de API.",
"settings.voice.page.field.followTextLanguage": "Ajustar la voz al idioma del texto",
"settings.voice.page.field.followTextLanguageAria": "Ajustar la voz al idioma del texto",
"settings.voice.page.field.followTextLanguageInfo": "Si una respuesta está en otro idioma, se usa una voz para ese idioma: una voz de macOS adecuada o un modelo local que se descarga en el primer uso.",
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglés)",
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeos)",
"settings.voice.page.stt.model.whisperBase": "Whisper base (multilingüe)",
@@ -1909,7 +1936,7 @@ export const settingsDict = {
"settings.openchamber.visual.section.streaming": "Streaming",
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir el contenido nuevo durante el streaming",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automáticamente el contenido nuevo mientras se transmite una respuesta",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Mientras llega una respuesta, la vista se desplaza hacia el contenido más reciente. Desactívalo para mantener la vista quieta y desplazarte manualmente.",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Mientras llega una respuesta, la vista se desplaza hacia el contenido más reciente. Desactívalo para mantener la vista quieta y desplazarte manualmente; enviar un mensaje desde la mitad del chat tampoco moverá la vista.",
"settings.openchamber.visual.section.messageAppearance": "Apariencia de los mensajes",
"settings.openchamber.visual.section.toolsAndFiles": "Herramientas y archivos",
"settings.openchamber.visual.section.composer": "Compositor",
@@ -2039,6 +2066,13 @@ export const settingsDict = {
"settings.openchamber.visual.field.persistDraftMessages": "Conservar borradores de mensajes",
"settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Habilitar ortografía en campos de texto",
"settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Habilitar ortografía en campos de texto",
"settings.openchamber.visual.field.largeTextPaste": "Pegado de texto grande",
"settings.openchamber.visual.field.largeTextPasteHint": "Al pegar más de unos 2000 caracteres o 25 líneas, elige si adjuntar el texto como archivo, pegarlo en línea o preguntar cada vez.",
"settings.openchamber.visual.field.largeTextPasteAria": "Comportamiento del pegado de texto grande",
"settings.openchamber.visual.field.largeTextPasteOptionAria": "Pegado de texto grande: {option}",
"settings.openchamber.visual.option.largeTextPaste.ask.label": "Preguntar cada vez",
"settings.openchamber.visual.option.largeTextPaste.attach.label": "Adjuntar como archivo",
"settings.openchamber.visual.option.largeTextPaste.inline.label": "Pegar en línea",
"settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar informes anónimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar informes anónimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Nos ayuda a entender qué versiones de la aplicación se usan activamente para priorizar mejoras. Solo se recopilan la versión de la aplicación, la plataforma y el entorno de ejecución ; no se recopilan datos personales ni código.",
@@ -2197,5 +2231,6 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
...linearIntegrationI18n.es,
...thirdPartyIntegrationI18n.es,
} as const;
+92 -16
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './es.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n.es,
...linearPanelI18n.es,
'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
@@ -38,6 +42,7 @@ export const dict: Record<I18nKey, string> = {
"common.language.korean": "Coreano",
"common.language.polish": "Polaco",
"common.language.japanese": "Japonés",
"common.language.turkish": "Turco",
"common.revealPath.finder": "Mostrar en Finder",
"common.revealPath.fileExplorer": "Abrir en File Explorer",
"common.revealPath.fileManager": "Abrir en gestor de archivos",
@@ -130,6 +135,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.section.worktrees": "Worktrees",
"mobile.sessions.section.otherProjects": "Cambiar de proyecto",
"mobile.sessions.section.projects": "Proyectos",
"mobile.sessions.section.chats": "Chats",
"mobile.sessions.empty.noProjectsTitle": "Sin proyectos",
"mobile.sessions.empty.noProjectsDescription": "Agrega un proyecto para empezar a chatear con tu código.",
"mobile.sessions.empty.noSessionsTitle": "Sin sesiones",
@@ -384,7 +390,7 @@ export const dict: Record<I18nKey, string> = {
"multirun.launcher.attachments.attach": "Adjuntar",
"multirun.launcher.attachments.tooltip": "Archivos idénticos enviados a todas las ejecuciones",
"multirun.launcher.models.label": "Modelos",
"multirun.launcher.models.info": "Selecciona 2-{max} modelos. El mismo modelo puede añadirse varias veces.",
"multirun.launcher.models.info": "Selecciona 2 o más modelos. El mismo modelo puede añadirse varias veces.",
"multirun.launcher.toast.fileTooLarge": "El archivo \"{fileName}\" es demasiado grande (máximo 10MB)",
"multirun.launcher.toast.attachFailed": "No se pudo adjuntar \"{fileName}\"",
"multirun.launcher.toast.attachedSingle": "Archivo adjuntado ({count})",
@@ -537,11 +543,33 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Dejar de compartir",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover a un worktree nuevo",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover a worktree",
"sessions.sidebar.session.menu.newWorktree": "Nuevo worktree...",
"sessions.sidebar.session.moveToWorktree.success": "Sesión movida a un worktree nuevo",
"sessions.sidebar.session.moveToWorktree.failed": "No se pudo mover la sesión a un worktree nuevo",
"sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual, transfiere los cambios sin confirmar y mueve allí esta sesión y sus subsesiones.",
"sessions.sidebar.session.moveToWorktree.main": "Worktree principal",
"sessions.sidebar.session.moveToWorktree.refreshing": "Actualizando worktrees...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "No se pudieron cargar los worktrees",
"sessions.sidebar.session.moveToWorktree.current": "Worktree actual",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sesión movida al worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "No se pudo mover la sesión al worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Muestra los worktrees existentes y la opción de crear uno nuevo para esta sesión.",
"sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual y mueve allí esta sesión y sus subsesiones. Si la fuente tiene cambios sin confirmar, decides si se transfieren.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponible cuando la sesión está inactiva. Detén la actividad actual o espera a que termine.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sesión ya se está moviendo a un worktree nuevo.",
"sessions.sidebar.session.moveToWorktree.confirm.title": "La fuente tiene cambios sin confirmar",
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Archivos modificados en este worktree: {count}.",
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode rastrea estos cambios por directorio, no por sesión.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Mueve esta sesión y sus subsesiones dejando intacto cada archivo de la fuente.",
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfiere los cambios del directorio de la sesión. Los archivos sin confirmar y sin rastrear salen de la fuente tras el éxito.",
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Los cambios en el índice permanecen en la fuente y se copian al destino.",
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "La transferencia puede fallar si el destino usa una base de Git distinta.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover solo la sesión",
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todos los cambios de la fuente",
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "No se pudieron verificar los cambios de la fuente. No se modificó ningún worktree ni sesión.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "El destino no pudo aceptar los cambios de la fuente. No se movieron la sesión ni los cambios. Reintenta y elige Mover solo la sesión.",
"sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "La conexión se cortó antes de que el destino confirmara el movimiento. Puede que la sesión no se haya movido y que tus cambios sin confirmar ya estén en el worktree de destino. Compruébalo antes de volver a intentarlo.",
"sessions.sidebar.session.menu.runFusion": "Ejecutar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir en el editor",
@@ -1144,6 +1172,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Vista previa",
"contextPanel.mode.browser": "Navegador",
"contextRail.configure.open": "Configurar paneles",
"contextRail.configure.dialogTitle": "Paneles de la barra",
"contextRail.configure.dialogDescription": "Elige qué paneles muestra la barra. Los paneles ocultos conservan sus datos y siguen accesibles desde la paleta de comandos.",
"contextRail.configure.showAll": "Mostrar todos",
"contextRail.configure.noneWarning": "Todos los paneles están ocultos.",
"contextRail.aria.rail": "Superficies del panel",
"contextPanel.editorEmpty.title": "Ningún archivo abierto",
"contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.",
@@ -1284,6 +1317,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.browser.annotate.submit": "Adjuntar",
"contextPanel.browser.trustNotice": "Las páginas que abras aquí se ejecutan con acceso completo a OpenChamber: necesario para la inspección y las capturas. Abre solo sitios de confianza: una página maliciosa podría leer tus datos o actuar en tu nombre.",
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
"contextPanel.tab.menu.close": "Cerrar",
"contextPanel.tab.menu.closeOthers": "Cerrar otras",
"contextPanel.tab.menu.closeToLeft": "Cerrar pestañas a la izquierda",
"contextPanel.tab.menu.closeToRight": "Cerrar pestañas a la derecha",
"contextPanel.tab.menu.closeAll": "Cerrar todas las pestañas",
"contextPanel.actions.collapsePanel": "Colapsar panel",
"contextPanel.actions.expandPanel": "Expandir panel",
"contextPanel.actions.closePanel": "Cerrar panel",
@@ -1380,6 +1418,12 @@ export const dict: Record<I18nKey, string> = {
"filesView.editor.disableLineWrap": "Desactivar ajuste de línea",
"filesView.editor.enableLineWrap": "Activar ajuste de línea",
"filesView.editor.findInFile": "Buscar en el archivo",
"filesView.preview.find.placeholder": "Buscar en la vista previa",
"filesView.preview.find.nextAria": "Siguiente coincidencia",
"filesView.preview.find.previousAria": "Coincidencia anterior",
"filesView.preview.find.closeAria": "Cerrar búsqueda",
"filesView.preview.find.noMatches": "Sin coincidencias",
"filesView.preview.find.countAria": "{current} de {total}",
"filesView.editor.goToLine": "Ir a línea",
"filesView.editor.switchToEditMode": "Cambiar al modo de edición",
"filesView.editor.switchToPreviewMode": "Cambiar al modo de vista previa",
@@ -1650,7 +1694,7 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.toast.planImported": "Plan importado",
"rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "No se pudo leer el archivo del plan",
"inlineComment.range.lines": "Líneas {start}-{end}",
"inlineComment.input.placeholder": "Añadir un comentario... (Cmd+Enter para guardar)",
"inlineComment.input.placeholder": "Añadir un comentario... ({shortcut} para guardar)",
"inlineComment.input.placeholderShort": "Añadir un comentario...",
"inlineComment.actions.cancel": "Cancelar",
"inlineComment.actions.save": "Guardar",
@@ -1692,6 +1736,9 @@ export const dict: Record<I18nKey, string> = {
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
"chat.recap.aria": "Resumen de la sesión",
"chat.recap.label": "Resumen:",
"chat.sessionError.title": "OpenCode detuvo esta respuesta",
"chat.sessionError.noDetails": "OpenCode no informó detalles. Abre el informe de estado (Ctrl/Cmd+Mayús+L) para ver los errores recientes.",
"chat.sessionError.noReply": "OpenCode no comenzó una respuesta a este mensaje.",
"chat.goal.dialog.titleCreate": "Definir objetivo de sesión",
"chat.goal.dialog.titleManage": "Objetivo de sesión",
"chat.goal.dialog.objectiveLabel": "Objetivo",
@@ -1767,6 +1814,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.actions.openInFinder": "Abrir en Finder",
"directoryExplorerDialog.actions.adding": "Añadiendo...",
"directoryExplorerDialog.actions.addProject": "Añadir proyecto",
"directoryExplorerDialog.actions.addSelected": "Añadir seleccionados",
"directoryExplorerDialog.actions.addLocalProject": "Añadir proyecto local",
"directoryExplorerDialog.actions.cloneRepository": "Clonar repositorio",
"directoryExplorerDialog.actions.cloneAndAdd": "Clonar y añadir",
@@ -1784,6 +1832,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.browse.parentDirectory": "Directorio padre",
"directoryExplorerDialog.browse.addedBadge": "Añadido",
"directoryExplorerDialog.browse.quickAdd": "Añadir",
"directoryExplorerDialog.browse.selectForAdd": "Seleccionar para añadir",
"directoryExplorerDialog.footer.navigate": "Navegar",
"directoryExplorerDialog.footer.select": "Seleccionar",
"directoryExplorerDialog.footer.add": "Añadir",
@@ -1792,6 +1841,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.toast.desktopDeniedAccess": "El escritorio denegó el acceso al directorio.",
"directoryExplorerDialog.toast.failedToOpenDirectory": "No se pudo abrir el directorio",
"directoryExplorerDialog.toast.desktopCouldNotGrantAccess": "El escritorio no pudo otorgar acceso al archivo.",
"directoryExplorerDialog.toast.addedProjects": "Se añadieron {count} proyecto(s)",
"directoryExplorerDialog.toast.failedToAddProject": "No se pudo añadir el proyecto",
"directoryExplorerDialog.toast.cloneUrlRequired": "Introduce una URL de repositorio antes de clonar.",
"directoryExplorerDialog.toast.selectValidDirectoryPath": "Por favor selecciona una ruta de directorio válida.",
@@ -1840,22 +1890,18 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.focusChatInput": "Enfocar entrada de chat",
"helpDialog.item.togglePromptNavigator": "Mostrar u ocultar navegador de prompts",
"helpDialog.item.abortActiveRun": "Detener ejecución activa (doble presionar)",
"helpDialog.item.toggleRightSidebar": 'Alternar panel de contexto',
"helpDialog.item.openRightSidebarGitTab": 'Abrir superficie de Git',
"helpDialog.item.openRightSidebarFilesTab": 'Abrir superficie de archivos',
"helpDialog.item.toggleTerminalDock": "Mostrar u ocultar dock de terminal",
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
"helpDialog.item.switchSessionTab": "Cambiar pestaña de sesión",
"helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)",
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
"helpDialog.item.openSettings": "Abrir configuración",
"helpDialog.keyCombiner.or": "o",
"helpDialog.proTips.title": "Consejos:",
"helpDialog.proTips.commandPalette": "Usa la paleta de comandos ({shortcut}) para acceder rápidamente a todas las acciones",
"helpDialog.proTips.recentSessions": "Las cinco sesiones más recientes aparecen en la paleta de comandos",
"helpDialog.proTips.themeCycling": "El ciclo de tema recuerda tu preferencia entre sesiones",
"helpDialog.proTips.leaderSequences": "Atajos en dos pasos: pulsa la combinación y luego la segunda tecla; Esc cancela",
"header.actions.rightSidebarWithShortcut": "Barra lateral derecha ({shortcut})",
"header.actions.toggleRightSidebarAria": "Mostrar u ocultar barra lateral derecha",
"header.actions.openAppMenu": "Menú de OpenChamber",
@@ -1939,8 +1985,6 @@ export const dict: Record<I18nKey, string> = {
"session.newWorktree.noMatchingBranches": "No hay ramas coincidentes",
"session.newWorktree.localBranches": "Ramas locales",
"session.newWorktree.remoteBranches": "Ramas remotas",
"session.newWorktree.otherLocalBranches": "Otras ramas locales",
"session.newWorktree.otherRemoteBranches": "Otras ramas remotas",
"session.newWorktree.branchName": "Nombre de la rama",
"session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature",
"session.newWorktree.actions.change": "Cambiar",
@@ -2065,7 +2109,6 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "Tareas",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes",
"chat.statusRow.aborted": "Interrumpido",
"chat.revertIndicator.redo": "Rehacer",
"chat.revertIndicator.redoAria": "Rehacer — restaurar mensajes revertidos",
"chat.revertPopover.title": "Revertidos",
@@ -2143,7 +2186,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
"chat.container.sessionLoadError.title": "No se pudo cargar la sesión",
"chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.",
"chat.container.sessionLoadError.description": "No se pudo obtener la conversación: puede que el servidor esté apagado o inaccesible. No se perdió nada; reintenta cuando vuelva.",
"chat.container.sessionLoadError.authDescription": "Tu sesión expiró, por lo que el servidor rechazó la solicitud. Inicia sesión y la conversación se cargará.",
"chat.container.sessionLoadError.retry": "Reintentar",
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
@@ -2186,10 +2230,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
"chat.textSelection.comment.attach": "Adjuntar",
"chat.textSelection.actions.newSession": "Nueva sesión",
"chat.textSelection.actions.addToNotes": "Añadir a las notas",
"chat.textSelection.title.addToCurrentChat": "Añadir al chat actual",
"chat.textSelection.title.newSessionWithSelection": "Crear nueva sesión con selección",
"chat.textSelection.title.saveInsightToNotes": "Guardar texto seleccionado en notas",
"chat.messageBody.actions.revertAria": "Volver a este mensaje",
"chat.messageBody.actions.revert": "Volver desde aquí",
@@ -2273,7 +2315,12 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.toast.attachmentsTooLarge": "Los adjuntos son demasiado grandes para enviar. Intenta reducir la cantidad o el tamaño de las imágenes.",
"chat.chatInput.toast.sendAttachmentsFailed": "No se pudieron enviar los adjuntos. Intenta con menos archivos o imágenes más pequeñas.",
"chat.chatInput.toast.messageSendFailed": "El mensaje no se pudo enviar. Los adjuntos se restauraron.",
"chat.chatInput.toast.noModelSelected": "Selecciona un proveedor y un modelo antes de enviar.",
"chat.chatInput.toast.clipboardAttachFailed": "No se pudo adjuntar la imagen desde el portapapeles",
"chat.chatInput.toast.clipboardTextAttachFailed": "No se pudo adjuntar el texto pegado como archivo",
"chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado",
"chat.chatInput.toast.largeTextPaste.attach": "Adjuntar como archivo",
"chat.chatInput.toast.largeTextPaste.inline": "Pegar en línea",
"chat.chatInput.toast.addedFileMentions": "Se añadieron {count} mención(es) de archivo",
"chat.chatInput.toast.attachFileFailed": "No se pudo adjuntar el archivo",
"chat.chatInput.toast.attachNamedFailed": "No se pudo adjuntar {name}",
@@ -2322,6 +2369,7 @@ export const dict: Record<I18nKey, string> = {
"chat.toolPart.showRawJson": "Mostrar JSON sin formato",
"chat.toolPart.showFormattedJson": "Mostrar JSON formateado",
"chat.toolPart.showNavigableJson": "Mostrar JSON navegable",
"chat.toolPart.openFile": "Abrir archivo",
"chat.toolPart.openFileAtFirstChange": "Abrir archivo en el primer cambio",
"chat.toolPart.openFileDiff": "Abrir diferencias del archivo",
"chat.toolPart.copyOutput": "Copiar salida",
@@ -2453,6 +2501,15 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.toggleSidebar": "Mostrar u ocultar barra lateral",
"commandPalette.item.showContextUsage": "Mostrar uso del contexto",
"commandPalette.item.toggleTerminal": "Mostrar u ocultar terminal",
"commandPalette.item.cycleTheme": "Cambiar tema",
"commandPalette.item.showOpenCodeStatus": "Mostrar estado de OpenCode",
"commandPalette.item.toggleMemoryDebug": "Alternar panel de depuración de memoria",
"commandPalette.item.pinSession": "Anclar o desanclar sesión",
"commandPalette.item.copySessionId": "Copiar ID de sesión",
"commandPalette.item.openMultiRun": "Abrir lanzador multi-run",
"commandPalette.item.openArchive": "Abrir sesiones archivadas",
"commandPalette.item.openNotes": "Abrir panel de notas",
"commandPalette.item.openTodos": "Abrir panel de tareas",
"commandPalette.item.openSettings": "Abrir configuración...",
"commandPalette.session.untitled": "Sesión sin título",
"openCodeStatusDialog.title": "Estado de OpenCode",
@@ -2667,6 +2724,9 @@ export const dict: Record<I18nKey, string> = {
"sessionAuth.error.passkeySignInCanceled": "El inicio de sesión con clave de paso se canceló.",
"sessionAuth.error.enterPasswordForPasskey": "Introduce tu contraseña para añadir una clave de paso.",
"sessionAuth.locked.tunnelTitle": "Se requiere acceso por túnel",
"sessionAuth.expired.banner": "Tu sesión expiró: inicia sesión para continuar.",
"sessionAuth.expired.loginAction": "Iniciar sesión",
"sessionAuth.expired.sendBlocked": "Sesión expirada: inicia sesión para enviar mensajes.",
"sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber",
"sessionAuth.locked.tunnelDescription": "Abre este túnel usando el enlace de conexión única desde la aplicación de escritorio.",
"sessionAuth.locked.passwordDescription": "Esta sesión está protegida con contraseña.",
@@ -2949,6 +3009,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Actualizando...",
"updateDialog.error.updateFailed": "No se pudo actualizar",
"updateDialog.error.takingLonger": "La actualización está tardando más de lo esperado. Espera un poco y refresca, o ejecuta: openchamber update",
"updateDialog.error.signatureRejected": "La actualización descargada fue rechazada: su firma de código no coincide con esta instalación. Normalmente significa que la copia en ejecución no se instaló desde una versión oficial firmada. Instala OpenChamber desde una versión oficial y vuelve a actualizar.",
"updateDialog.error.updaterDisabled": "El actualizador se detuvo tras una instalación fallida. Cierra OpenChamber, ábrelo de nuevo y reintenta la actualización.",
"updateDialog.error.restartFailed": "No se pudo reiniciar para instalar la actualización.",
"updateDialog.error.restartUnavailable": "Instalar la actualización requiere la aplicación de escritorio de OpenChamber.",
"mobileUpdate.toast.available.title": "Actualización de OpenChamber disponible",
"mobileUpdate.toast.available.description": "La versión {version} está lista para Android.",
"mobileUpdate.toast.actions.download": "Descargar",
@@ -2969,6 +3033,7 @@ export const dict: Record<I18nKey, string> = {
"memoryDebugPanel.title": "Panel de depuración",
"memoryDebugPanel.tabs.memory": "Memoria",
"memoryDebugPanel.tabs.streaming": "Transmisión",
"memoryDebugPanel.tabs.requests": "Solicitudes",
"memoryDebugPanel.section.sessionsInMemory": "Sesiones en memoria",
"memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI",
"memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas del puente de VS Code",
@@ -3006,6 +3071,16 @@ export const dict: Record<I18nKey, string> = {
"memoryDebugPanel.streaming.copy.copied": "JSON de depuración en streaming copiado",
"memoryDebugPanel.streaming.copy.failed": "No se pudo copiar JSON",
"memoryDebugPanel.streaming.copy.hint": "Copia exportaciones de métricas de UI como de métricas de VS Code en formato JSON",
"memoryDebugPanel.requests.inFlight": "En curso",
"memoryDebugPanel.requests.peak": "Pico",
"memoryDebugPanel.requests.duration": "Duración",
"memoryDebugPanel.requests.totalRequests": "Solicitudes totales",
"memoryDebugPanel.requests.tracking": "Seguimiento",
"memoryDebugPanel.requests.now": "ahora",
"memoryDebugPanel.requests.noSamples": "Aún no se han registrado solicitudes. Mantén este panel abierto para registrar la actividad de fetch.",
"memoryDebugPanel.requests.chartLabel": "Solicitudes fetch en curso a lo largo del tiempo, pico {peak}",
"memoryDebugPanel.requests.windowHint": "últimos {seconds}s",
"memoryDebugPanel.requests.percentileChartLabel": "Percentiles de antigüedad de solicitudes en curso (p50, p90, p99, máx) a lo largo del tiempo",
"memoryDebugPanel.common.idle": "inactivo",
"memoryDebugPanel.common.live": "en vivo",
"memoryDebugPanel.common.notAvailable": "n/a",
@@ -3104,9 +3179,10 @@ export const dict: Record<I18nKey, string> = {
"quota.window.premium": "Premium Interactions",
"quota.window.chat": "Chat Requests",
"quota.window.completions": "Completions",
"quota.window.premiumInteractions": "Premium interactions",
"quota.window.premiumInteractions": "Créditos de IA",
'chat.workStatus.ariaLabel': 'Estado del trabajo',
'chat.workStatus.context.label': 'Contexto',
'chat.workStatus.cost.breakdown': "Sesión {session} · Subagentes {subagents}",
'chat.workStatus.git.changedFileSingle': '{count} archivo modificado',
'chat.workStatus.git.changedFilePlural': '{count} archivos modificados',
'chat.workStatus.pr.untitled': 'Pull request sin título',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Suivi de lutilisation dOpenCode Go',
@@ -1019,7 +1020,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ce combo est déjà utilisé par un autre raccourci. Écraser et effacer cet autre mappage ?',
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Appuyez sur les touches...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capturez d\'abord un raccourci.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Il est toujours sauvegardé.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Vous pouvez tout de même lenregistrer.',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Aller à la ligne (éditeur de fichiers)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Ouvrir la palette de commandes',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Entrée de mise au point',
@@ -1028,18 +1029,20 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Ajouter la sélection au chat',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Basculer la barre latérale',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Basculer longlet de session',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Session précédente',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Session suivante',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Renommer la session actuelle',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Basculer lapprobation automatique',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Fermer longlet de session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Ouvrir les raccourcis clavier',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Basculer le panneau contextuel du plan',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Basculer le menu des services',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Onglet Services vélo',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Thème du cycle',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent de cycle',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Faire avancer le modèle favori',
@@ -1048,6 +1051,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Développer l\'entrée',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Chronologie de la conversation ouverte',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Afficher ou masquer le navigateur de prompts',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Cette séquence partage un préfixe contextuel avec {action}. Lorsque son contexte est actif, cette action est prioritaire.',
'settings.openchamber.keyboardShortcuts.category.session': 'Commandes de session',
'settings.openchamber.keyboardShortcuts.category.models': 'Modèles et agents',
'settings.openchamber.keyboardShortcuts.category.panels': 'Panneaux et outils',
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirmer',
'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum, avec trois touches au plus chacune. Après la première, attendez jusqu’à 3 secondes une seconde combinaison. Utilisez Confirmer pour appliquer ou Annuler pour abandonner. Retour arrière supprime la dernière.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Première combinaison',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
'settings.openchamber.keyboardShortcuts.unassigned': 'Non attribué',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Cela entre en conflit avec la séquence utilisée par {action}. Choisissez une autre combinaison.',
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Cette combinaison est déjà utilisée par {action}.',
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Cette combinaison entre en conflit avec un raccourci intégré qui ne peut pas être remplacé.',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Ouvrir le sélecteur de projet de brouillon',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Ouvrir le sélecteur de worktree de brouillon',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir les sessions récentes',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Saisie vocale',
'settings.projects.sidebar.total': 'Total {count}',
'settings.projects.sidebar.actions.addProject': 'Ajouter un projet',
'settings.projects.page.empty.noProjects': 'Aucun projet disponible.',
@@ -1733,7 +1757,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Serveur',
'settings.voice.page.provider.local': 'Local',
'settings.voice.page.tooltip.sttLocal': 'Transcription locale sur le serveur OpenChamber. Les modèles se téléchargent automatiquement ; aucune clé d\'API requise.',
'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro, anglais). Le modèle se télécharge automatiquement ; aucune clé dAPI requise.',
'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro pour langlais ; les modèles des autres langues sont téléchargés à la première utilisation). Aucune clé API requise.',
'settings.voice.page.field.followTextLanguage': 'Adapter la voix à la langue du texte',
'settings.voice.page.field.followTextLanguageAria': 'Adapter la voix à la langue du texte',
'settings.voice.page.field.followTextLanguageInfo': 'Si une réponse est dans une autre langue, une voix pour cette langue est utilisée : une voix macOS adaptée ou un modèle local téléchargé à la première utilisation.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (anglais)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 langues européennes)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingue)',
@@ -1823,7 +1850,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Suivre le nouveau contenu pendant le streaming',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Suivre automatiquement le nouveau contenu pendant la diffusion dune réponse',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Pendant quune réponse arrive, la vue glisse vers le contenu le plus récent. Désactivez pour garder la vue immobile et défiler manuellement.',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Pendant quune réponse arrive, la vue glisse vers le contenu le plus récent. Désactivez pour garder la vue immobile et défiler manuellement ; envoyer un message depuis le milieu de la conversation laisse alors aussi la vue en place.',
'settings.openchamber.visual.section.messageAppearance': 'Apparence des messages',
'settings.openchamber.visual.section.toolsAndFiles': 'Outils et fichiers',
'settings.openchamber.visual.section.composer': 'Zone de saisie',
@@ -1944,6 +1971,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': 'Conserver les brouillons de messages',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Activer la vérification orthographique dans les saisies de texte',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Activer la vérification orthographique dans les entrées de texte',
'settings.openchamber.visual.field.largeTextPaste': 'Collage de texte volumineux',
'settings.openchamber.visual.field.largeTextPasteHint': 'Lors dun collage de plus denviron 2 000 caractères ou 25 lignes, choisir de joindre le texte comme fichier, de le coller en ligne ou de demander à chaque fois.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Comportement du collage de texte volumineux',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Collage de texte volumineux : {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Demander à chaque fois',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Joindre comme fichier',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Coller en ligne',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Envoyer des rapports d\'utilisation anonymes',
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Envoyer des rapports d\'utilisation anonymes',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Nous aide à comprendre quelles versions de l\'application sont activement utilisées afin que nous puissions prioriser les améliorations. Seules la version de lapplication, la plate-forme et le runtime sont collectés aucune donnée personnelle ni code.',
@@ -2197,5 +2231,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...linearIntegrationI18n.fr,
...thirdPartyIntegrationI18n.fr,
} as const;
+92 -16
View File
@@ -1,7 +1,11 @@
import { settingsDict } from './fr.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
...settingsDict,
...linearIssuePickerI18n.fr,
...linearPanelI18n.fr,
'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée',
'terminalView.actions.restart': 'Redémarrer le terminal',
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
@@ -37,6 +41,7 @@ export const dict = {
'common.language.korean': 'Coréen',
'common.language.polish': 'Polonais',
'common.language.japanese': 'Japonais',
'common.language.turkish': 'Turc',
'common.revealPath.finder': 'Révéler dans le Finder',
'common.revealPath.fileExplorer': 'Ouvrir dans l\'explorateur de fichiers',
'common.revealPath.fileManager': 'Ouvrir dans le gestionnaire de fichiers',
@@ -215,7 +220,7 @@ export const dict = {
'multirun.launcher.attachments.attach': 'Attacher',
'multirun.launcher.attachments.tooltip': 'Mêmes fichiers envoyés à toutes les exécutions',
'multirun.launcher.models.label': 'Modèles',
'multirun.launcher.models.info': 'Sélectionnez les modèles 2-{max}. Le même modèle peut être ajouté plusieurs fois.',
'multirun.launcher.models.info': 'Sélectionnez 2 modèles ou plus. Le même modèle peut être ajouté plusieurs fois.',
'multirun.launcher.toast.fileTooLarge': 'Le fichier "{fileName}" est trop volumineux (max 10 Mo)',
'multirun.launcher.toast.attachFailed': 'Échec de la connexion de "{fileName}"',
'multirun.launcher.toast.attachedSingle': 'Fichier {count} joint',
@@ -367,11 +372,33 @@ export const dict = {
'sessions.sidebar.session.menu.unshare': 'Annuler le partage',
'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Déplacer vers un nouveau worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Déplacer vers un worktree',
'sessions.sidebar.session.menu.newWorktree': 'Nouveau worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Session déplacée vers un nouveau worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Impossible de déplacer la session vers un nouveau worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle, transfère les modifications non validées et y déplace cette session et ses sous-sessions.',
'sessions.sidebar.session.moveToWorktree.main': 'Worktree principal',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Actualisation des worktrees...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Impossible de charger les worktrees',
'sessions.sidebar.session.moveToWorktree.current': 'Worktree actuel',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session déplacée vers le worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Impossible de déplacer la session vers le worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Affiche les worktrees existants et loption den créer un nouveau pour cette session.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle et y déplace cette session et ses sous-sessions. Si la source contient des modifications non validées, vous choisissez de les transférer ou non.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Disponible lorsque la session est inactive. Arrêtez lactivité en cours ou attendez sa fin.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Cette session est déjà en cours de déplacement vers un nouveau worktree.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'La source contient des modifications non validées',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Fichiers modifiés dans ce worktree : {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode suit ces modifications par répertoire, pas par session.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Déplace cette session et ses sous-sessions en laissant chaque fichier source inchangé.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfère les modifications du répertoire de la session. Les fichiers non indexés et non suivis quittent la source après succès.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Les modifications indexées restent dans la source et sont copiées vers la destination.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Le transfert peut échouer si la destination utilise une base Git différente.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Déplacer la session uniquement',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Déplacer toutes les modifications de la source',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Annuler',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Les modifications de la source nont pas pu être vérifiées. Aucun worktree ni session na été modifié.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'La destination na pas pu accepter les modifications de la source. La session et les modifications nont pas été déplacées. Réessayez et choisissez Déplacer la session uniquement.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'La connexion a été perdue avant que la destination ne confirme le déplacement. La session na peut-être pas été déplacée, et vos modifications non validées se trouvent peut-être déjà dans le worktree de destination. Vérifiez-le avant de réessayer.',
'sessions.sidebar.session.menu.runFusion': 'Exécuter la fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral',
'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur',
@@ -963,6 +990,11 @@ export const dict = {
'contextPanel.mode.context': 'Contexte',
'contextPanel.mode.preview': 'Aperçu',
'contextPanel.mode.browser': 'Navigateur',
'contextRail.configure.open': 'Configurer les panneaux',
'contextRail.configure.dialogTitle': 'Panneaux de la barre',
'contextRail.configure.dialogDescription': 'Choisissez les panneaux affichés par la barre. Les panneaux masqués conservent leurs données et restent accessibles via la palette de commandes.',
'contextRail.configure.showAll': 'Tout afficher',
'contextRail.configure.noneWarning': 'Tous les panneaux sont masqués.',
'contextRail.aria.rail': 'Surfaces du panneau',
'contextPanel.editorEmpty.title': 'Aucun fichier ouvert',
'contextPanel.editorEmpty.description': 'Choisissez un fichier dans larborescence pour commencer.',
@@ -1051,6 +1083,11 @@ export const dict = {
'contextPanel.browser.empty': 'Navigateur Internet',
'contextPanel.browser.emptyHint': 'Entrez une adresse ci-dessus pour commencer à naviguer sur le Web',
'contextPanel.tab.closeTabAria': 'Fermer l\'onglet {label}',
'contextPanel.tab.menu.close': 'Fermer',
'contextPanel.tab.menu.closeOthers': 'Fermer les autres',
'contextPanel.tab.menu.closeToLeft': 'Fermer les onglets à gauche',
'contextPanel.tab.menu.closeToRight': 'Fermer les onglets à droite',
'contextPanel.tab.menu.closeAll': 'Fermer tous les onglets',
'contextPanel.actions.collapsePanel': 'Réduire le panneau',
'contextPanel.actions.expandPanel': 'Agrandir le panneau',
'contextPanel.actions.closePanel': 'Fermer le panneau',
@@ -1181,6 +1218,12 @@ export const dict = {
'filesView.editor.disableLineWrap': 'Désactiver le retour à la ligne',
'filesView.editor.enableLineWrap': 'Activer le retour à la ligne',
'filesView.editor.findInFile': 'Rechercher dans le fichier',
'filesView.preview.find.placeholder': 'Rechercher dans l\'aperçu',
'filesView.preview.find.nextAria': 'Correspondance suivante',
'filesView.preview.find.previousAria': 'Correspondance précédente',
'filesView.preview.find.closeAria': 'Fermer la recherche',
'filesView.preview.find.noMatches': 'Aucune correspondance',
'filesView.preview.find.countAria': '{current} sur {total}',
'filesView.editor.goToLine': 'Aller à la ligne',
'filesView.editor.switchToEditMode': 'Passer en mode édition',
'filesView.editor.switchToPreviewMode': 'Passer en mode aperçu',
@@ -1437,7 +1480,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.planImported': 'Forfait importé',
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Échec de la lecture du fichier de plan',
'inlineComment.range.lines': 'Lignes {start}-{end}',
'inlineComment.input.placeholder': 'Ajouter un commentaire... (Cmd+Entrée pour enregistrer)',
'inlineComment.input.placeholder': 'Ajouter un commentaire... ({shortcut} pour enregistrer)',
'inlineComment.actions.cancel': 'Annuler',
'inlineComment.actions.save': 'Sauvegarder',
'inlineComment.actions.comment': 'Commentaire',
@@ -1472,6 +1515,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})',
'chat.recap.aria': 'Récapitulatif de la session',
'chat.recap.label': 'Récap :',
'chat.sessionError.title': 'OpenCode a interrompu cette réponse',
'chat.sessionError.noDetails': 'OpenCode n\'a fourni aucun détail. Ouvrez le rapport d\'état (Ctrl/Cmd+Maj+L) pour voir les erreurs récentes.',
'chat.sessionError.noReply': 'OpenCode n\'a pas commencé de réponse à ce message.',
'chat.goal.dialog.titleCreate': 'Définir un objectif de session',
'chat.goal.dialog.titleManage': 'Objectif de session',
'chat.goal.dialog.objectiveLabel': 'Objectif',
@@ -1547,6 +1593,7 @@ export const dict = {
'directoryExplorerDialog.actions.openInFinder': 'Ouvrir dans le Finder',
'directoryExplorerDialog.actions.adding': 'Ajout...',
'directoryExplorerDialog.actions.addProject': 'Ajouter un projet',
'directoryExplorerDialog.actions.addSelected': 'Ajouter la sélection',
'directoryExplorerDialog.actions.addLocalProject': 'Ajouter un projet local',
'directoryExplorerDialog.actions.cloneRepository': 'Cloner le dépôt',
'directoryExplorerDialog.actions.cloneAndAdd': 'Cloner et ajouter',
@@ -1564,6 +1611,7 @@ export const dict = {
'directoryExplorerDialog.browse.parentDirectory': 'Annuaire parent',
'directoryExplorerDialog.browse.addedBadge': 'Ajouté',
'directoryExplorerDialog.browse.quickAdd': 'Ajouter',
'directoryExplorerDialog.browse.selectForAdd': 'Sélectionner pour ajouter',
'directoryExplorerDialog.footer.navigate': 'Naviguer',
'directoryExplorerDialog.footer.select': 'Sélectionner',
'directoryExplorerDialog.footer.add': 'Ajouter',
@@ -1572,6 +1620,7 @@ export const dict = {
'directoryExplorerDialog.toast.desktopDeniedAccess': 'Le bureau a refusé l\'accès au répertoire.',
'directoryExplorerDialog.toast.failedToOpenDirectory': 'Échec de l\'ouverture du répertoire',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Desktop n\'a pas pu accorder l\'accès aux fichiers.',
'directoryExplorerDialog.toast.addedProjects': '{count} projet(s) ajouté(s)',
'directoryExplorerDialog.toast.failedToAddProject': 'Échec de l\'ajout du projet',
'directoryExplorerDialog.toast.cloneUrlRequired': 'Entrez dans un dépôt URL avant le clonage.',
'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Veuillez sélectionner un chemin de répertoire valide.',
@@ -1620,22 +1669,18 @@ export const dict = {
'helpDialog.item.focusChatInput': 'Concentration sur la saisie du chat',
'helpDialog.item.togglePromptNavigator': 'Afficher ou masquer le navigateur de prompts',
'helpDialog.item.abortActiveRun': 'Abandonner lexécution active (double pression)',
'helpDialog.item.toggleRightSidebar': 'Afficher/masquer le panneau de contexte',
'helpDialog.item.openRightSidebarGitTab': 'Ouvrir la surface Git',
'helpDialog.item.openRightSidebarFilesTab': 'Ouvrir la surface Fichiers',
'helpDialog.item.toggleTerminalDock': 'Basculer la station d\'accueil du terminal',
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
'helpDialog.item.switchSessionTab': 'Basculer longlet de session',
'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)',
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
'helpDialog.keyCombiner.or': 'ou',
'helpDialog.proTips.title': 'Conseils de pro :',
'helpDialog.proTips.commandPalette': 'Utilisez la palette de commandes ({shortcut}) pour accéder rapidement à toutes les actions',
'helpDialog.proTips.recentSessions': 'Les 5 sessions les plus récentes apparaissent dans la palette de commandes',
'helpDialog.proTips.themeCycling': 'Le cyclisme thématique mémorise vos préférences au fil des sessions',
'helpDialog.proTips.leaderSequences': 'Raccourcis en deux temps : appuyez sur la combinaison, puis sur la seconde touche — Échap annule',
'header.actions.rightSidebarWithShortcut': 'Barre latérale droite ({shortcut})',
'header.actions.toggleRightSidebarAria': 'Basculer la barre latérale droite',
'header.actions.openAppMenu': 'Menu de OpenChamber',
@@ -1719,8 +1764,6 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'Aucune branche correspondante',
'session.newWorktree.localBranches': 'Branches locales',
'session.newWorktree.remoteBranches': 'Branches du dépôt distant',
'session.newWorktree.otherLocalBranches': 'Autres branches locales',
'session.newWorktree.otherRemoteBranches': 'Autres branches du remote',
'session.newWorktree.branchName': 'Nom de la branche',
'session.newWorktree.branchNamePlaceholder': 'fonctionnalité/ma-fonctionnalité-géniale',
'session.newWorktree.actions.change': 'Changement',
@@ -1829,7 +1872,6 @@ export const dict = {
'chat.statusRow.tasksTitle': 'Tâches',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} actif · {left} gauche',
'chat.statusRow.aborted': 'Avorté',
'chat.revertIndicator.redo': 'Refaire',
'chat.revertIndicator.redoAria': 'Rétablir : restaurer les messages annulés',
'chat.revertPopover.title': 'Rétabli',
@@ -1896,7 +1938,8 @@ export const dict = {
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.',
'chat.container.sessionLoadError.title': 'Impossible de charger la session',
'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.',
'chat.container.sessionLoadError.description': 'Impossible de récupérer la conversation — le serveur est peut-être hors ligne ou injoignable. Rien n\'est perdu ; réessayez quand il sera de retour.',
'chat.container.sessionLoadError.authDescription': 'Votre session a expiré, le serveur a donc refusé la requête. Connectez-vous et la conversation se chargera.',
'chat.container.sessionLoadError.retry': 'Réessayer',
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Impossible dactualiser les sessions.',
@@ -1935,10 +1978,8 @@ export const dict = {
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
'chat.textSelection.comment.attach': 'Joindre',
'chat.textSelection.actions.newSession': 'Nouvelle session',
'chat.textSelection.actions.addToNotes': 'Ajouter aux notes',
'chat.textSelection.title.addToCurrentChat': 'Ajouter au chat actuel',
'chat.textSelection.title.newSessionWithSelection': 'Créer une nouvelle session avec sélection',
'chat.textSelection.title.saveInsightToNotes': 'Enregistrer le texte sélectionné dans les notes',
'chat.messageBody.actions.revertAria': 'Revenir à ce message',
'chat.messageBody.actions.revert': 'Revenir à partir d\'ici',
@@ -2020,7 +2061,12 @@ export const dict = {
'chat.chatInput.toast.attachmentsTooLarge': 'Les pièces jointes sont trop volumineuses pour être envoyées. Veuillez essayer de réduire le nombre ou la taille des images.',
'chat.chatInput.toast.sendAttachmentsFailed': 'Échec de l\'envoi des pièces jointes. Essayez moins de fichiers ou des images plus petites.',
'chat.chatInput.toast.messageSendFailed': 'Le message n\'a pas pu être envoyé. Pièces jointes restaurées.',
'chat.chatInput.toast.noModelSelected': 'Sélectionnez un fournisseur et un modèle avant d\'envoyer.',
'chat.chatInput.toast.clipboardAttachFailed': 'Échec de la pièce jointe de l\'image du presse-papiers',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Échec de la pièce jointe du texte collé comme fichier',
'chat.chatInput.toast.largeTextPaste.title': 'Texte volumineux détecté',
'chat.chatInput.toast.largeTextPaste.attach': 'Joindre comme fichier',
'chat.chatInput.toast.largeTextPaste.inline': 'Coller en ligne',
'chat.chatInput.toast.addedFileMentions': 'Ajout des mentions du fichier {count}',
'chat.chatInput.toast.attachFileFailed': 'Impossible de joindre le fichier',
'chat.chatInput.toast.attachNamedFailed': 'Échec de la connexion du {name}',
@@ -2191,6 +2237,15 @@ export const dict = {
'commandPalette.item.toggleSidebar': 'Basculer la barre latérale',
'commandPalette.item.showContextUsage': 'Afficher l\'utilisation du contexte',
'commandPalette.item.toggleTerminal': 'Basculer le terminal',
'commandPalette.item.cycleTheme': 'Changer de thème',
'commandPalette.item.showOpenCodeStatus': 'Afficher le statut OpenCode',
'commandPalette.item.toggleMemoryDebug': 'Basculer le panneau de débogage mémoire',
'commandPalette.item.pinSession': 'Épingler ou désépingler la session',
'commandPalette.item.copySessionId': 'Copier l\'ID de session',
'commandPalette.item.openMultiRun': 'Ouvrir le lanceur multi-run',
'commandPalette.item.openArchive': 'Ouvrir les sessions archivées',
'commandPalette.item.openNotes': 'Ouvrir le panneau de notes',
'commandPalette.item.openTodos': 'Ouvrir le panneau de tâches',
'commandPalette.item.openSettings': 'Ouvrez les paramètres...',
'commandPalette.session.untitled': 'Session sans titre',
'openCodeStatusDialog.title': 'Statut OpenCode',
@@ -2405,6 +2460,9 @@ export const dict = {
'sessionAuth.error.passkeySignInCanceled': 'La connexion par mot de passe a été annulée.',
'sessionAuth.error.enterPasswordForPasskey': 'Entrez votre mot de passe pour ajouter un mot de passe.',
'sessionAuth.locked.tunnelTitle': 'Accès au tunnel requis',
'sessionAuth.expired.banner': 'Votre session a expiré — connectez-vous pour continuer.',
'sessionAuth.expired.loginAction': 'Se connecter',
'sessionAuth.expired.sendBlocked': 'Session expirée — connectez-vous pour envoyer des messages.',
'sessionAuth.locked.unlockTitle': 'Débloquez OpenChamber',
'sessionAuth.locked.tunnelDescription': 'Ouvrez ce tunnel à l\'aide du lien de connexion unique depuis l\'application de bureau.',
'sessionAuth.locked.passwordDescription': 'Cette session est protégée par mot de passe.',
@@ -2675,6 +2733,10 @@ export const dict = {
'updateDialog.status.updating': 'Mise à jour...',
'updateDialog.error.updateFailed': 'La mise à jour a échoué',
'updateDialog.error.takingLonger': 'La mise à jour prend plus de temps que prévu. Attendez un peu et actualisez, ou exécutez : openchamber update',
'updateDialog.error.signatureRejected': 'La mise à jour téléchargée a été rejetée : sa signature de code ne correspond pas à cette installation. Cela signifie généralement que la copie en cours na pas été installée depuis une version officielle signée. Installez OpenChamber depuis une version officielle, puis relancez la mise à jour.',
'updateDialog.error.updaterDisabled': 'Le programme de mise à jour sest arrêté après une installation échouée. Quittez OpenChamber, rouvrez-le, puis réessayez la mise à jour.',
'updateDialog.error.restartFailed': 'Impossible de redémarrer pour installer la mise à jour.',
'updateDialog.error.restartUnavailable': 'Linstallation de la mise à jour nécessite lapplication de bureau OpenChamber.',
'mobileUpdate.toast.available.title': 'Mise à jour OpenChamber disponible',
'mobileUpdate.toast.available.description': 'La version {version} est prête pour Android.',
'mobileUpdate.toast.actions.download': 'Télécharger',
@@ -2695,6 +2757,7 @@ export const dict = {
'memoryDebugPanel.title': 'Panneau de débogage',
'memoryDebugPanel.tabs.memory': 'Mémoire',
'memoryDebugPanel.tabs.streaming': 'Streaming',
'memoryDebugPanel.tabs.requests': 'Requêtes',
'memoryDebugPanel.section.sessionsInMemory': 'Sessions en mémoire',
'memoryDebugPanel.section.uiStreamingMetrics': 'Métriques de streaming de l\'interface utilisateur',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'Métriques du pont VS Code',
@@ -2732,6 +2795,16 @@ export const dict = {
'memoryDebugPanel.streaming.copy.copied': 'Débogage en streaming JSON copié',
'memoryDebugPanel.streaming.copy.failed': 'Échec de la copie de JSON',
'memoryDebugPanel.streaming.copy.hint': 'La copie exporte les métriques de streaming de l\'interface utilisateur et de VS Code en tant que JSON.',
'memoryDebugPanel.requests.inFlight': 'En cours',
'memoryDebugPanel.requests.peak': 'Pic',
'memoryDebugPanel.requests.duration': 'Durée',
'memoryDebugPanel.requests.totalRequests': 'Requêtes totales',
'memoryDebugPanel.requests.tracking': 'Suivi',
'memoryDebugPanel.requests.now': 'maintenant',
'memoryDebugPanel.requests.noSamples': 'Aucune requête enregistrée. Gardez ce panneau ouvert pour enregistrer l\'activité fetch.',
'memoryDebugPanel.requests.chartLabel': 'Requêtes fetch en cours dans le temps, pic {peak}',
'memoryDebugPanel.requests.windowHint': '{seconds}s dernières',
'memoryDebugPanel.requests.percentileChartLabel': 'Percentiles d\'âge des requêtes en cours (p50, p90, p99, max) dans le temps',
'memoryDebugPanel.common.idle': 'inactif',
'memoryDebugPanel.common.live': 'en direct',
'memoryDebugPanel.common.notAvailable': 'n / A',
@@ -2795,7 +2868,7 @@ export const dict = {
'quota.window.premium': 'Interactions premium',
'quota.window.chat': 'Requêtes de chat',
'quota.window.completions': 'Complétions',
'quota.window.premiumInteractions': 'Interactions premium',
'quota.window.premiumInteractions': 'Crédits IA',
'layout.mainTab.diagram': 'Diagramme',
'mobile.nav.aria': 'Navigation mobile',
'mobile.connect.welcome.title': 'Se connecter à OpenChamber',
@@ -2874,6 +2947,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Changer de projet',
'mobile.sessions.section.projects': 'Projets',
'mobile.sessions.section.chats': 'Discussions',
'mobile.sessions.empty.noProjectsTitle': 'Aucun projet pour le moment',
'mobile.sessions.empty.noProjectsDescription': 'Ajoutez un projet pour commencer à discuter avec votre code.',
'mobile.sessions.empty.noSessionsTitle': 'Aucune session pour le moment',
@@ -3085,6 +3159,7 @@ export const dict = {
'chat.toolPart.showRawJson': 'Afficher le JSON brut',
'chat.toolPart.showFormattedJson': 'Afficher le JSON formaté',
'chat.toolPart.showNavigableJson': 'Afficher le JSON navigable',
'chat.toolPart.openFile': 'Ouvrir le fichier',
'chat.toolPart.openFileAtFirstChange': 'Ouvrir le fichier à la première modification',
'chat.toolPart.openFileDiff': 'Ouvrir les différences du fichier',
'chat.toolPart.copyOutput': 'Copier la sortie',
@@ -3104,6 +3179,7 @@ export const dict = {
'vscodeLayout.actions.cancel': 'Annuler',
'chat.workStatus.ariaLabel': 'État du travail',
'chat.workStatus.context.label': 'Contexte',
'chat.workStatus.cost.breakdown': 'Session {session} · Sous-agents {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} fichier modifié',
'chat.workStatus.git.changedFilePlural': '{count} fichiers modifiés',
'chat.workStatus.pr.untitled': 'Pull request sans titre',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡',
@@ -1134,7 +1135,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'このキーコンボは別のショートカットで既に使用されています。上書きしてそのマッピングをクリアしますか?',
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性があります。それでも保存されます。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性がありますが、そのまま保存できます。',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '指定行に移動(ファイルエディター)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス',
@@ -1143,18 +1144,20 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '選択範囲をチャットに追加',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'セッションタブを切り替え',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '前のセッション',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '次のセッション',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '現在のセッション名を変更',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '権限の自動承認を切り替え',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'セッションタブを閉じる',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'キーボードショートカットを開く',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '計画コンテキストパネルの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'サービスメニューの切替',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'サービスタブを順に切替',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'テーマを順に切替',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent を順に切替',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'お気に入りモデルを次へ',
@@ -1163,6 +1166,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'モデルセレクターを開く',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '会話タイムラインを開く',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'プロンプトナビゲーターの表示切替',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'このシーケンスは {action} とコンテキスト依存のプレフィックスを共有しています。そのコンテキストが有効な間は、この操作が優先されます。',
'settings.openchamber.keyboardShortcuts.category.session': 'セッション操作',
'settings.openchamber.keyboardShortcuts.category.models': 'モデルとエージェント',
'settings.openchamber.keyboardShortcuts.category.panels': 'パネルとツール',
'settings.openchamber.keyboardShortcuts.category.navigation': 'ナビゲーション',
'settings.openchamber.keyboardShortcuts.category.application': 'アプリケーション',
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力でき、各組み合わせは最大3キーです。最初の組み合わせの後、2つ目の組み合わせを最大3秒待ちます。適用するには確認、破棄するにはキャンセルを選択してください。Backspace で最後の組み合わせを削除します。',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
'settings.openchamber.keyboardShortcuts.unassigned': '未割り当て',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action} のシーケンスと競合しています。別の組み合わせを選択してください。',
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'この組み合わせは {action} で使用されています。',
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'この組み合わせは組み込みショートカットと競合しています。組み込みショートカットは置き換えられません。',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '下書きプロジェクト選択を開く',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '下書きワークツリー選択を開く',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '最近のセッションを開く',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '音声入力',
'settings.projects.sidebar.total': '合計 {count}',
'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加',
'settings.projects.page.empty.noProjects': '利用可能なプロジェクトがありません。',
@@ -1848,7 +1872,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'サーバー',
'settings.voice.page.provider.local': 'ローカル',
'settings.voice.page.tooltip.sttLocal': 'OpenChamber サーバー上でローカルに文字起こしします。モデルは自動でダウンロードされ、API キーは不要です。',
'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(Kokoro、英語)。モデルは自動でダウンロードされ、API キーは不要です。',
'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(英語は Kokoro、他の言語のモデルは初回使用時にダウンロード)。API キーは不要です。',
'settings.voice.page.field.followTextLanguage': 'テキストの言語に合わせて音声を選ぶ',
'settings.voice.page.field.followTextLanguageAria': 'テキストの言語に合わせて音声を選ぶ',
'settings.voice.page.field.followTextLanguageInfo': '返答が別の言語の場合、その言語の音声を使います。対応する macOS の音声、または初回使用時にダウンロードされるローカルモデルです。',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英語)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(ヨーロッパ25言語)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多言語)',
@@ -1942,7 +1969,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': 'ストリーミング',
'settings.openchamber.visual.field.streamingAutoFollow': '応答のストリーミング中に新しい内容を追従',
'settings.openchamber.visual.field.streamingAutoFollowAria': '応答のストリーミング中に新しい内容へ自動スクロールする',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '応答の受信中、ビューは常に最新の内容へスクロールします。オフにするとビューは動かず、手動でスクロールできます。',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '応答の受信中、ビューは常に最新の内容へスクロールします。オフにするとビューは動かず、手動でスクロールできます。その場合、チャットの途中からメッセージを送信してもビューは移動しません。',
'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観',
'settings.openchamber.visual.section.toolsAndFiles': 'ツールとファイル',
'settings.openchamber.visual.section.composer': '入力欄',
@@ -2072,6 +2099,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '下書きメッセージを保持',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'テキスト入力のスペルチェックを有効化',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'テキスト入力のスペルチェックを有効化',
'settings.openchamber.visual.field.largeTextPaste': '大きなテキストの貼り付け',
'settings.openchamber.visual.field.largeTextPasteHint': '約 2,000 文字または 25 行を超えるテキストを貼り付けるとき、ファイルとして添付するか、そのまま貼り付けるか、毎回確認するかを選べます。',
'settings.openchamber.visual.field.largeTextPasteAria': '大きなテキスト貼り付けの動作',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '大きなテキストの貼り付け: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '毎回確認する',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'ファイルとして添付',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'そのまま貼り付け',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '匿名使用状況レポートを送信',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。',
@@ -2197,5 +2231,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー',
...linearIntegrationI18n.ja,
...thirdPartyIntegrationI18n.ja,
} as const;
+92 -16
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './ja.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n.ja,
...linearPanelI18n.ja,
'terminalView.actions.attachSelection': '選択した出力を添付',
'terminalView.actions.restart': 'ターミナルを再起動',
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
@@ -38,6 +42,7 @@ export const dict: Record<I18nKey, string> = {
'common.language.korean': '韓国語',
'common.language.polish': 'ポーランド語',
'common.language.japanese': '日本語',
'common.language.turkish': 'トルコ語',
'common.revealPath.finder': 'Finderで表示',
'common.revealPath.fileExplorer': 'エクスプローラーで開く',
'common.revealPath.fileManager': 'ファイルマネージャーで開く',
@@ -130,6 +135,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': 'ワークツリー',
'mobile.sessions.section.otherProjects': 'プロジェクトを切り替え',
'mobile.sessions.section.projects': 'プロジェクト',
'mobile.sessions.section.chats': 'チャット',
'mobile.sessions.empty.noProjectsTitle': 'まだプロジェクトがありません',
'mobile.sessions.empty.noProjectsDescription': 'プロジェクトを追加してコードとチャットを始めましょう。',
'mobile.sessions.empty.noSessionsTitle': 'まだセッションがありません',
@@ -384,7 +390,7 @@ export const dict: Record<I18nKey, string> = {
'multirun.launcher.attachments.attach': '添付',
'multirun.launcher.attachments.tooltip': '同じファイルをすべての実行に送信',
'multirun.launcher.models.label': 'モデル',
'multirun.launcher.models.info': '2{max}モデルを選択。同じモデルを複数回追加できます。',
'multirun.launcher.models.info': '2つ以上のモデルを選択。同じモデルを複数回追加できます。',
'multirun.launcher.toast.fileTooLarge': 'ファイル「{fileName}」が大きすぎます(最大10MB',
'multirun.launcher.toast.attachFailed': '「{fileName}」の添付に失敗しました',
'multirun.launcher.toast.attachedSingle': '{count}ファイルを添付しました',
@@ -537,11 +543,33 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '共有解除',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート',
'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktreeへ移動',
'sessions.sidebar.session.menu.newWorktree': '新しいworktree...',
'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました',
'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした',
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。',
'sessions.sidebar.session.moveToWorktree.main': 'メインworktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktreeを更新しています...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktreeを読み込めませんでした',
'sessions.sidebar.session.moveToWorktree.current': '現在のworktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'セッションをworktreeへ移動しました',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'セッションをworktreeへ移動できませんでした',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '既存のworktreeと、このセッション用に新しいworktreeを作成するオプションを表示します。',
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、このセッションとサブセッションをそこへ移動します。ソースに未コミットの変更がある場合は、移動するかどうかを選択します。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'ソースに未コミットの変更があります',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'このworktree内の変更されたファイル: {count}。',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCodeはこれらの変更をセッションではなくディレクトリ単位で追跡します。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'ソースファイルを一切変更せずに、このセッションとサブセッションを移動します。',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'セッションディレクトリ配下の変更を転送します。ステージされていない・追跡されていないファイルは成功後にソースを離れます。',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'ステージ済みの変更はソースに残り、宛先へコピーされます。',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '宛先が異なるGitベースを使用している場合、転送に失敗することがあります。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'セッションのみ移動',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'ソースの変更をすべて移動',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'キャンセル',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'ソースの変更を検証できませんでした。worktreeもセッションも変更されませんでした。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '宛先がソースの変更を受け付けられませんでした。セッションもソースの変更も移動されていません。再試行して「セッションのみ移動」を選んでください。',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '移動が宛先で確定する前に接続が切れました。セッションは移動していない可能性があり、コミットしていない変更はすでに移動先のワークツリーにあるかもしれません。再試行する前に確認してください。',
'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行',
'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く',
'sessions.sidebar.session.actions.openInEditor': 'エディターで開く',
@@ -1140,6 +1168,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.context': 'コンテキスト',
'contextPanel.mode.preview': 'プレビュー',
'contextPanel.mode.browser': 'ブラウザ',
'contextRail.configure.open': 'パネルを設定',
'contextRail.configure.dialogTitle': 'レールのパネル',
'contextRail.configure.dialogDescription': 'レールに表示するパネルを選択します。非表示のパネルもデータは保持され、コマンドパレットから引き続き開けます。',
'contextRail.configure.showAll': 'すべて表示',
'contextRail.configure.noneWarning': 'すべてのパネルが非表示です。',
'contextRail.aria.rail': 'パネルサーフェス',
'contextPanel.editorEmpty.title': 'ファイルが開かれていません',
'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。',
@@ -1280,6 +1313,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.browser.annotate.submit': '添付',
'contextPanel.browser.trustNotice': 'ここで開かれたページはOpenChamberへの完全なアクセス権を持ちます — 検査とスクリーンショットに必要です。信頼できるサイトのみを開いてください: 悪意のあるページがデータを読み取ったりあなたの代わりに行動したりする可能性があります。',
'contextPanel.tab.closeTabAria': '{label}タブを閉じる',
'contextPanel.tab.menu.close': '閉じる',
'contextPanel.tab.menu.closeOthers': '他を閉じる',
'contextPanel.tab.menu.closeToLeft': '左のタブを閉じる',
'contextPanel.tab.menu.closeToRight': '右のタブを閉じる',
'contextPanel.tab.menu.closeAll': 'すべてのタブを閉じる',
'contextPanel.actions.collapsePanel': 'パネルを折りたたむ',
'contextPanel.actions.expandPanel': 'パネルを展開',
'contextPanel.actions.closePanel': 'パネルを閉じる',
@@ -1410,6 +1448,12 @@ export const dict: Record<I18nKey, string> = {
'filesView.editor.disableLineWrap': '行の折り返しを無効にする',
'filesView.editor.enableLineWrap': '行の折り返しを有効にする',
'filesView.editor.findInFile': 'ファイル内を検索',
'filesView.preview.find.placeholder': 'プレビュー内を検索',
'filesView.preview.find.nextAria': '次の一致',
'filesView.preview.find.previousAria': '前の一致',
'filesView.preview.find.closeAria': '検索を閉じる',
'filesView.preview.find.noMatches': '一致なし',
'filesView.preview.find.countAria': '{total}件中{current}件目',
'filesView.editor.goToLine': '指定行に移動',
'filesView.editor.switchToEditMode': '編集モードに切り替え',
'filesView.editor.switchToPreviewMode': 'プレビューモードに切り替え',
@@ -1668,7 +1712,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.planImported': '計画をインポートしました',
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '計画ファイルの読み込みに失敗しました',
'inlineComment.range.lines': '{start}行目~{end}行目',
'inlineComment.input.placeholder': 'コメントを追加...Cmd+Enterで保存)',
'inlineComment.input.placeholder': 'コメントを追加...{shortcut}で保存)',
'inlineComment.input.placeholderShort': 'コメントを追加...',
'inlineComment.actions.cancel': 'キャンセル',
'inlineComment.actions.save': '保存',
@@ -1710,6 +1754,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut}',
'chat.recap.aria': 'セッションの要約',
'chat.recap.label': '要約:',
'chat.sessionError.title': 'OpenCode がこの返答を停止しました',
'chat.sessionError.noDetails': 'OpenCode から詳細は報告されませんでした。ステータスレポート(Ctrl/Cmd+Shift+L)で最近のエラーを確認してください。',
'chat.sessionError.noReply': 'OpenCode はこのメッセージへの返答を開始しませんでした。',
'chat.goal.dialog.titleCreate': 'セッションゴールを設定',
'chat.goal.dialog.titleManage': 'セッションゴール',
'chat.goal.dialog.objectiveLabel': '目標',
@@ -1785,6 +1832,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.actions.openInFinder': 'Finderで開く',
'directoryExplorerDialog.actions.adding': '追加中...',
'directoryExplorerDialog.actions.addProject': 'プロジェクトを追加',
'directoryExplorerDialog.actions.addSelected': '選択したものを追加',
'directoryExplorerDialog.actions.addLocalProject': 'ローカルプロジェクトを追加',
'directoryExplorerDialog.actions.cloneRepository': 'リポジトリをクローン',
'directoryExplorerDialog.actions.cloneAndAdd': 'クローンして追加',
@@ -1802,6 +1850,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.parentDirectory': '親ディレクトリ',
'directoryExplorerDialog.browse.addedBadge': '追加済み',
'directoryExplorerDialog.browse.quickAdd': '追加',
'directoryExplorerDialog.browse.selectForAdd': '追加するものを選択',
'directoryExplorerDialog.footer.navigate': '移動',
'directoryExplorerDialog.footer.select': '選択',
'directoryExplorerDialog.footer.add': '追加',
@@ -1810,6 +1859,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': 'デスクトップがディレクトリアクセスを拒否しました。',
'directoryExplorerDialog.toast.failedToOpenDirectory': 'ディレクトリを開けませんでした',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'デスクトップがファイルアクセスを許可できませんでした。',
'directoryExplorerDialog.toast.addedProjects': '{count}件のプロジェクトを追加しました',
'directoryExplorerDialog.toast.failedToAddProject': 'プロジェクトの追加に失敗しました',
'directoryExplorerDialog.toast.cloneUrlRequired': 'クローンする前にリポジトリURLを入力してください。',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '有効なディレクトリパスを選択してください。',
@@ -1858,22 +1908,18 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': 'チャット入力にフォーカス',
'helpDialog.item.togglePromptNavigator': 'プロンプトナビゲーターの表示切替',
'helpDialog.item.abortActiveRun': 'アクティブな実行を中止(ダブルプレス)',
'helpDialog.item.toggleRightSidebar': 'コンテキストパネルの表示切替',
'helpDialog.item.openRightSidebarGitTab': 'Git サーフェスを開く',
'helpDialog.item.openRightSidebarFilesTab': 'ファイルサーフェスを開く',
'helpDialog.item.toggleTerminalDock': 'ターミナルドックの切り替え',
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
'helpDialog.item.switchSessionTab': 'セッションタブを切り替え',
'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)',
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
'helpDialog.item.openSettings': '設定を開く',
'helpDialog.keyCombiner.or': 'または',
'helpDialog.proTips.title': 'プロのヒント:',
'helpDialog.proTips.commandPalette': 'コマンドパレット({shortcut})を使うとすべての操作にすばやくアクセスできます',
'helpDialog.proTips.recentSessions': '最近の5つのセッションがコマンドパレットに表示されます',
'helpDialog.proTips.themeCycling': 'テーマの切り替えはセッション間で設定が記憶されます',
'helpDialog.proTips.leaderSequences': '2段階ショートカット:組み合わせを押してから2つ目のキーを押します(Escで取消)',
'header.actions.rightSidebarWithShortcut': '右サイドバー({shortcut}',
'header.actions.toggleRightSidebarAria': '右サイドバーの切り替え',
'header.actions.openAppMenu': 'OpenChamberメニュー',
@@ -1957,8 +2003,6 @@ export const dict: Record<I18nKey, string> = {
'session.newWorktree.noMatchingBranches': '一致するブランチがありません',
'session.newWorktree.localBranches': 'ローカルブランチ',
'session.newWorktree.remoteBranches': 'リモートブランチ',
'session.newWorktree.otherLocalBranches': 'その他のローカルブランチ',
'session.newWorktree.otherRemoteBranches': 'その他のリモートブランチ',
'session.newWorktree.branchName': 'ブランチ名',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '変更',
@@ -2083,7 +2127,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': 'タスク',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り',
'chat.statusRow.aborted': '中止されました',
'chat.revertIndicator.redo': 'やり直し',
'chat.revertIndicator.redoAria': 'やり直し — 元に戻したメッセージを復元',
'chat.revertPopover.title': '元に戻しました',
@@ -2161,7 +2204,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。',
'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした',
'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。',
'chat.container.sessionLoadError.description': '会話を取得できませんでした。サーバーが停止中か到達できない可能性があります。データは失われていません。復旧後に再試行してください。',
'chat.container.sessionLoadError.authDescription': 'セッションの有効期限が切れたため、サーバーがリクエストを拒否しました。ログインすると会話が読み込まれます。',
'chat.container.sessionLoadError.retry': '再試行',
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
@@ -2204,10 +2248,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
'chat.textSelection.comment.attach': '添付',
'chat.textSelection.actions.newSession': '新しいセッション',
'chat.textSelection.actions.addToNotes': 'メモに追加',
'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加',
'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成',
'chat.textSelection.title.saveInsightToNotes': '選択テキストをメモに保存',
'chat.messageBody.actions.revertAria': 'このメッセージに戻す',
'chat.messageBody.actions.revert': 'ここから元に戻す',
@@ -2303,7 +2345,12 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.attachmentsTooLarge': '添付ファイルが大きすぎて送信できません。画像の数またはサイズを減らしてください。',
'chat.chatInput.toast.sendAttachmentsFailed': '添付ファイルの送信に失敗しました。ファイルを減らすかサイズを小さくしてください。',
'chat.chatInput.toast.messageSendFailed': 'メッセージの送信に失敗しました。添付ファイルは復元されました。',
'chat.chatInput.toast.noModelSelected': '送信する前にプロバイダーとモデルを選択してください。',
'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました',
'chat.chatInput.toast.clipboardTextAttachFailed': '貼り付けたテキストのファイル添付に失敗しました',
'chat.chatInput.toast.largeTextPaste.title': '大きなテキストを検出',
'chat.chatInput.toast.largeTextPaste.attach': 'ファイルとして添付',
'chat.chatInput.toast.largeTextPaste.inline': 'そのまま貼り付け',
'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました',
'chat.chatInput.toast.attachFileFailed': 'ファイルの添付に失敗しました',
'gitView.commit.aiHighlights.insertAria': '挿入のariaラベル',
@@ -2355,6 +2402,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.showRawJson': '生JSONを表示',
'chat.toolPart.showFormattedJson': '整形JSONを表示',
'chat.toolPart.showNavigableJson': 'ナビゲーション可能なJSONを表示',
'chat.toolPart.openFile': 'ファイルを開く',
'chat.toolPart.openFileAtFirstChange': '最初の変更箇所でファイルを開く',
'chat.toolPart.openFileDiff': 'ファイル差分を開く',
'chat.toolPart.copyOutput': '出力をコピー',
@@ -2486,6 +2534,15 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.toggleSidebar': 'サイドバーの切り替え',
'commandPalette.item.showContextUsage': 'コンテキスト使用量を表示',
'commandPalette.item.toggleTerminal': 'ターミナルの切り替え',
'commandPalette.item.cycleTheme': 'テーマを順に切替',
'commandPalette.item.showOpenCodeStatus': 'OpenCode のステータスを表示',
'commandPalette.item.toggleMemoryDebug': 'メモリデバッグパネルの切替',
'commandPalette.item.pinSession': 'セッションをピン留め/解除',
'commandPalette.item.copySessionId': 'セッションIDをコピー',
'commandPalette.item.openMultiRun': 'マルチラン起動画面を開く',
'commandPalette.item.openArchive': 'アーカイブ済みセッションを開く',
'commandPalette.item.openNotes': 'ノートパネルを開く',
'commandPalette.item.openTodos': 'ToDoパネルを開く',
'commandPalette.item.openSettings': '設定を開く...',
'commandPalette.session.untitled': '無題のセッション',
'openCodeStatusDialog.title': 'OpenCodeステータス',
@@ -2700,6 +2757,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': 'パスキーサインインがキャンセルされました。',
'sessionAuth.error.enterPasswordForPasskey': 'パスキーを追加するためにパスワードを入力してください。',
'sessionAuth.locked.tunnelTitle': 'トンネルアクセスが必要',
'sessionAuth.expired.banner': 'セッションの有効期限が切れました。続行するにはログインしてください。',
'sessionAuth.expired.loginAction': 'ログイン',
'sessionAuth.expired.sendBlocked': 'セッションが切れています。メッセージを送るにはログインしてください。',
'sessionAuth.locked.unlockTitle': 'OpenChamberのロックを解除',
'sessionAuth.locked.tunnelDescription': 'デスクトップアプリのワンタイム接続リンクを使用してこのトンネルを開きます。',
'sessionAuth.locked.passwordDescription': 'このセッションはパスワードで保護されています。',
@@ -2979,6 +3039,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新に失敗しました',
'updateDialog.error.takingLonger': '更新に予想以上に時間がかかっています。しばらく待ってから更新するか、次を実行: openchamber update',
'updateDialog.error.signatureRejected': 'ダウンロードした更新は拒否されました。コード署名がこのインストールと一致しません。通常は、実行中のコピーが公式の署名済みリリースからインストールされていないことを意味します。公式リリースから OpenChamber をインストールし直してから、もう一度更新してください。',
'updateDialog.error.updaterDisabled': 'インストールに失敗したため、アップデーターが停止しました。OpenChamber を終了して開き直し、更新をやり直してください。',
'updateDialog.error.restartFailed': '更新をインストールするための再起動に失敗しました。',
'updateDialog.error.restartUnavailable': '更新のインストールには OpenChamber デスクトップアプリが必要です。',
'mobileUpdate.toast.available.title': 'OpenChamberの更新があります',
'mobileUpdate.toast.available.description': 'バージョン{version}をAndroidで利用できます。',
'mobileUpdate.toast.actions.download': 'ダウンロード',
@@ -2999,6 +3063,7 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.title': 'デバッグパネル',
'memoryDebugPanel.tabs.memory': 'メモリ',
'memoryDebugPanel.tabs.streaming': 'ストリーミング',
'memoryDebugPanel.tabs.requests': 'リクエスト',
'memoryDebugPanel.section.sessionsInMemory': 'メモリ内のセッション',
'memoryDebugPanel.section.uiStreamingMetrics': 'UIストリーミングメトリクス',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Codeブリッジメトリクス',
@@ -3036,6 +3101,16 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.streaming.copy.copied': 'ストリーミングデバッグJSONをコピーしました',
'memoryDebugPanel.streaming.copy.failed': 'JSONのコピーに失敗しました',
'memoryDebugPanel.streaming.copy.hint': 'UIとVS Codeの両方のストリーミングメトリクスをJSONとしてエクスポートします',
'memoryDebugPanel.requests.inFlight': '実行中',
'memoryDebugPanel.requests.peak': 'ピーク',
'memoryDebugPanel.requests.duration': '期間',
'memoryDebugPanel.requests.totalRequests': '合計リクエスト',
'memoryDebugPanel.requests.tracking': 'トラッキング',
'memoryDebugPanel.requests.now': '現在',
'memoryDebugPanel.requests.noSamples': 'まだリクエストが記録されていません。fetchアクティビティを記録するには、このパネルを開いたままにしてください。',
'memoryDebugPanel.requests.chartLabel': '経時的な実行中fetchリクエスト、ピーク {peak}',
'memoryDebugPanel.requests.windowHint': '過去 {seconds}秒',
'memoryDebugPanel.requests.percentileChartLabel': '経時的な実行中リクエストの経過時間パーセンタイル(p50、p90、p99、最大)',
'memoryDebugPanel.common.idle': '待機中',
'memoryDebugPanel.common.live': 'ライブ',
'memoryDebugPanel.common.notAvailable': 'N/A',
@@ -3102,10 +3177,11 @@ export const dict: Record<I18nKey, string> = {
'onboarding.localSetup.actions.checkAndContinue': 'インストール完了、確認して続行',
'onboarding.localSetup.status.autoContinue': '検出され次第自動的に続行します。',
'updateDialog.changelog.title': '新機能',
'quota.window.premiumInteractions': 'プレミアムインタラクション',
'quota.window.premiumInteractions': 'AIクレジット',
'chat.workStatus.ariaLabel': '作業状況',
'chat.workStatus.context.label': 'コンテキスト',
'chat.workStatus.cost.breakdown': 'セッション {session} · サブエージェント {subagents}',
'chat.workStatus.git.changedFileSingle': '{count} 件のファイルを変更',
'chat.workStatus.git.changedFilePlural': '{count} 件のファイルを変更',
'chat.workStatus.pr.untitled': 'タイトルなしのプルリクエスト',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적',
@@ -1101,7 +1102,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.overwritePrompt': '이 조합은 이미 다른 단축키에서 사용 중입니다. 덮어쓰고 기존 매핑을 지울까요?',
'settings.openchamber.keyboardShortcuts.field.pressKeys': '키를 누르세요...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '먼저 단축키를 입력하세요.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있습니다. 그래도 저장니다.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있지만 그래도 저장할 수 있습니다.',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '줄로 이동(파일 편집기)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '명령 팔레트 열기',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '입력에 포커스',
@@ -1110,18 +1111,20 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '터미널 확장 토글',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '선택 내용을 채팅에 추가',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '사이드바 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '세션 탭 전환',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '이전 세션',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '다음 세션',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '현재 세션 이름 바꾸기',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '권한 자동 승인 전환',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '세션 탭 닫기',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
'settings.openchamber.keyboardShortcuts.action.open_help.label': '키보드 단축키 열기',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '계획 컨텍스트 패널 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '서비스 메뉴 토글',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '서비스 탭 순환',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '테마 순환',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '에이전트 순환',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '즐겨찾기 모델 앞으로 순환',
@@ -1130,6 +1133,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.expand_input.label': '입력 확장',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '대화 타임라인 열기',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '프롬프트 탐색기 표시/숨기기',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '이 시퀀스는 {action}과 컨텍스트 접두사를 공유합니다. 해당 컨텍스트가 활성화된 동안에는 그 동작이 우선합니다.',
'settings.openchamber.keyboardShortcuts.category.session': '세션 제어',
'settings.openchamber.keyboardShortcuts.category.models': '모델 및 에이전트',
'settings.openchamber.keyboardShortcuts.category.panels': '패널 및 도구',
'settings.openchamber.keyboardShortcuts.category.navigation': '탐색',
'settings.openchamber.keyboardShortcuts.category.application': '애플리케이션',
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
'settings.openchamber.keyboardShortcuts.actions.confirm': '확인',
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. 각 조합에는 최대 세 개의 키를 사용할 수 있습니다. 첫 번째 조합 뒤에는 두 번째 조합을 위해 최대 3초 동안 기다립니다. 적용하려면 확인을, 취소하려면 취소를 선택하세요. Backspace로 마지막 조합을 삭제합니다.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
'settings.openchamber.keyboardShortcuts.unassigned': '할당되지 않음',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action}에서 사용하는 시퀀스와 충돌합니다. 다른 조합을 선택하세요.',
'settings.openchamber.keyboardShortcuts.error.exactConflict': '이 조합은 이미 {action}에서 사용합니다.',
'settings.openchamber.keyboardShortcuts.error.internalConflict': '이 조합은 바꿀 수 없는 기본 제공 단축키와 충돌합니다.',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '초안 프로젝트 선택기 열기',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '초안 워크트리 선택기 열기',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '최근 세션 열기',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '음성 입력',
'settings.projects.sidebar.total': '총 {count}개',
'settings.projects.sidebar.actions.addProject': '프로젝트 추가',
'settings.projects.page.empty.noProjects': '사용 가능한 프로젝트가 없습니다.',
@@ -1815,7 +1839,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': '서버',
'settings.voice.page.provider.local': '로컬',
'settings.voice.page.tooltip.sttLocal': 'OpenChamber 서버에서 로컬로 변환합니다. 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.',
'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(Kokoro, 영어). 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.',
'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(영어는 Kokoro, 다른 언어 모델은 처음 사용할 때 다운로드). API 키가 필요 없습니다.',
'settings.voice.page.field.followTextLanguage': '텍스트 언어에 맞는 음성 사용',
'settings.voice.page.field.followTextLanguageAria': '텍스트 언어에 맞는 음성 사용',
'settings.voice.page.field.followTextLanguageInfo': '응답이 다른 언어이면 해당 언어의 음성을 사용합니다. 일치하는 macOS 음성 또는 처음 사용할 때 다운로드되는 로컬 모델입니다.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (영어)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (유럽 25개 언어)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (다국어)',
@@ -1909,7 +1936,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': '스트리밍',
'settings.openchamber.visual.field.streamingAutoFollow': '스트리밍 중 새 내용 따라가기',
'settings.openchamber.visual.field.streamingAutoFollowAria': '응답 스트리밍 중 새 내용으로 자동 스크롤',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '응답이 스트리밍되는 동안 화면이 최신 내용으로 계속 이동합니다. 끄면 화면이 고정되어 직접 스크롤할 수 있습니다.',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '응답이 스트리밍되는 동안 화면이 최신 내용으로 계속 이동합니다. 끄면 화면이 고정되어 직접 스크롤할 수 있으며, 채팅 중간에서 메시지를 보내도 화면이 이동하지 않습니다.',
'settings.openchamber.visual.section.messageAppearance': '메시지 모양',
'settings.openchamber.visual.section.toolsAndFiles': '도구 및 파일',
'settings.openchamber.visual.section.composer': '입력창',
@@ -2039,6 +2066,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '초안 메시지 유지',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '텍스트 입력에서 맞춤법 검사 활성화',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '텍스트 입력에서 맞춤법 검사 활성화',
'settings.openchamber.visual.field.largeTextPaste': '긴 텍스트 붙여넣기',
'settings.openchamber.visual.field.largeTextPasteHint': '약 2,000자 또는 25줄을 넘는 텍스트를 붙여넣을 때 파일로 첨부할지, 본문에 붙여넣을지, 매번 물어볼지 선택합니다.',
'settings.openchamber.visual.field.largeTextPasteAria': '긴 텍스트 붙여넣기 동작',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '긴 텍스트 붙여넣기: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '매번 묻기',
'settings.openchamber.visual.option.largeTextPaste.attach.label': '파일로 첨부',
'settings.openchamber.visual.option.largeTextPaste.inline.label': '본문에 붙여넣기',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '익명 사용량 보고서 보내기',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.',
@@ -2197,5 +2231,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...linearIntegrationI18n.ko,
...thirdPartyIntegrationI18n.ko,
} as const;
+92 -16
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './ko.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n.ko,
...linearPanelI18n.ko,
'terminalView.actions.attachSelection': '선택한 출력 첨부',
'terminalView.actions.restart': '터미널 다시 시작',
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
@@ -38,6 +42,7 @@ export const dict: Record<I18nKey, string> = {
'common.language.korean': '한국어',
'common.language.polish': '폴란드어',
'common.language.japanese': '일본어',
'common.language.turkish': '터키어',
'common.revealPath.finder': 'Finder에서 보기',
'common.revealPath.fileExplorer': 'File Explorer에서 열기',
'common.revealPath.fileManager': '파일 관리자에서 열기',
@@ -130,6 +135,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '워크트리',
'mobile.sessions.section.otherProjects': '프로젝트 전환',
'mobile.sessions.section.projects': '프로젝트',
'mobile.sessions.section.chats': '채팅',
'mobile.sessions.empty.noProjectsTitle': '프로젝트 없음',
'mobile.sessions.empty.noProjectsDescription': '코드와 채팅을 시작하려면 프로젝트를 추가하세요.',
'mobile.sessions.empty.noSessionsTitle': '세션 없음',
@@ -384,7 +390,7 @@ export const dict: Record<I18nKey, string> = {
'multirun.launcher.attachments.attach': '첨부',
'multirun.launcher.attachments.tooltip': '같은 파일을 모든 실행에 보냅니다',
'multirun.launcher.models.label': '모델',
'multirun.launcher.models.info': '모델을 2~{max}개 선택하세요. 같은 모델을 여러 번 추가할 수 있습니다.',
'multirun.launcher.models.info': '모델을 2개 이상 선택하세요. 같은 모델을 여러 번 추가할 수 있습니다.',
'multirun.launcher.toast.fileTooLarge': '파일 "{fileName}"이 너무 큽니다(최대 10MB)',
'multirun.launcher.toast.attachFailed': '"{fileName}" 첨부 실패',
'multirun.launcher.toast.attachedSingle': '파일 {count}개 첨부됨',
@@ -537,11 +543,33 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '공유 해제',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktree로 이동',
'sessions.sidebar.session.menu.newWorktree': '새 worktree...',
'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다',
'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다',
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.',
'sessions.sidebar.session.moveToWorktree.main': '메인 worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktree 새로 고침 중...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktree를 불러오지 못했습니다',
'sessions.sidebar.session.moveToWorktree.current': '현재 worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '세션을 worktree로 이동했습니다',
'sessions.sidebar.session.moveToWorktree.existingFailed': '세션을 worktree로 이동하지 못했습니다',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '기존 worktree와 이 세션용 새 worktree를 만드는 옵션을 표시합니다.',
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들어 이 세션과 하위 세션을 그곳으로 이동합니다. 원본에 커밋되지 않은 변경 사항이 있으면 이동 여부를 선택합니다.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.',
'sessions.sidebar.session.moveToWorktree.confirm.title': '원본에 커밋되지 않은 변경 사항이 있습니다',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '이 worktree에서 변경된 파일: {count}개.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode는 이 변경 사항을 세션이 아닌 디렉터리 기준으로 추적합니다.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '원본 파일은 그대로 둔 채 이 세션과 하위 세션을 이동합니다.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '세션 디렉터리 아래의 변경 사항을 전송합니다. 스테이지되지 않거나 추적되지 않은 파일은 성공 후 원본을 떠납니다.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '스테이지된 변경 사항은 원본에 남고 목적지로 복사됩니다.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '목적지가 다른 Git 베이스를 사용하면 전송이 실패할 수 있습니다.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '세션만 이동',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '원본 변경 사항 모두 이동',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '취소',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '원본 변경 사항을 확인하지 못했습니다. worktree와 세션 모두 변경되지 않았습니다.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '목적지가 원본 변경 사항을 받아들이지 못했습니다. 세션과 원본 변경 사항이 이동되지 않았습니다. 다시 시도해 세션만 이동을 선택하세요.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '목적지가 이동을 확인하기 전에 연결이 끊겼습니다. 세션이 이동하지 않았을 수 있고, 커밋하지 않은 변경 사항이 이미 대상 워크트리에 있을 수 있습니다. 다시 시도하기 전에 확인하세요.',
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
@@ -1144,6 +1172,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.context': '컨텍스트',
'contextPanel.mode.preview': '미리보기',
'contextPanel.mode.browser': '브라우저',
'contextRail.configure.open': '패널 구성',
'contextRail.configure.dialogTitle': '레일 패널',
'contextRail.configure.dialogDescription': '레일에 표시할 패널을 선택하세요. 숨긴 패널의 데이터는 유지되며 명령 팔레트에서 계속 열 수 있습니다.',
'contextRail.configure.showAll': '모두 표시',
'contextRail.configure.noneWarning': '모든 패널이 숨겨져 있습니다.',
'contextRail.aria.rail': '패널 서피스',
'contextPanel.editorEmpty.title': '열린 파일 없음',
'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.',
@@ -1333,6 +1366,11 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.openPreviewAria': '미리보기 열기',
'chat.messageBody.actions.openPreview': '미리보기 열기',
'contextPanel.tab.closeTabAria': '{label} 탭 닫기',
'contextPanel.tab.menu.close': '닫기',
'contextPanel.tab.menu.closeOthers': '다른 탭 닫기',
'contextPanel.tab.menu.closeToLeft': '왼쪽 탭 닫기',
'contextPanel.tab.menu.closeToRight': '오른쪽 탭 닫기',
'contextPanel.tab.menu.closeAll': '모든 탭 닫기',
'contextPanel.actions.collapsePanel': '접기 패널',
'contextPanel.actions.expandPanel': '펼치기 패널',
'contextPanel.actions.closePanel': '패널 닫기',
@@ -1416,6 +1454,12 @@ export const dict: Record<I18nKey, string> = {
'filesView.editor.disableLineWrap': '줄 바꿈 끄기',
'filesView.editor.enableLineWrap': '줄 바꿈 켜기',
'filesView.editor.findInFile': '파일에서 찾기',
'filesView.preview.find.placeholder': '미리보기에서 찾기',
'filesView.preview.find.nextAria': '다음 일치 항목',
'filesView.preview.find.previousAria': '이전 일치 항목',
'filesView.preview.find.closeAria': '검색 닫기',
'filesView.preview.find.noMatches': '일치 항목 없음',
'filesView.preview.find.countAria': '{total}개 중 {current}번째',
'filesView.editor.goToLine': '줄로 이동',
'filesView.editor.switchToEditMode': '편집 모드로 전환',
'filesView.editor.switchToPreviewMode': '미리보기 모드로 전환',
@@ -1674,7 +1718,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.planImported': '플랜 가져옴',
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '플랜 파일 읽기 실패',
'inlineComment.range.lines': '줄 {start}-{end}',
'inlineComment.input.placeholder': '댓글 추가… (Cmd+Enter로 저장)',
'inlineComment.input.placeholder': '댓글 추가… ({shortcut}로 저장)',
'inlineComment.input.placeholderShort': '댓글 추가…',
'inlineComment.actions.cancel': '취소',
'inlineComment.actions.save': '저장',
@@ -1716,6 +1760,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})',
'chat.recap.aria': '세션 요약',
'chat.recap.label': '요약:',
'chat.sessionError.title': 'OpenCode가 이 응답을 중단했습니다',
'chat.sessionError.noDetails': 'OpenCode가 세부 정보를 보고하지 않았습니다. 상태 보고서(Ctrl/Cmd+Shift+L)에서 최근 오류를 확인하세요.',
'chat.sessionError.noReply': 'OpenCode가 이 메시지에 대한 응답을 시작하지 않았습니다.',
'chat.goal.dialog.titleCreate': '세션 목표 설정',
'chat.goal.dialog.titleManage': '세션 목표',
'chat.goal.dialog.objectiveLabel': '목표',
@@ -1791,6 +1838,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.actions.openInFinder': 'Finder에서 열기',
'directoryExplorerDialog.actions.adding': '추가 중…',
'directoryExplorerDialog.actions.addProject': '프로젝트 추가',
'directoryExplorerDialog.actions.addSelected': '선택 항목 추가',
'directoryExplorerDialog.actions.addLocalProject': '로컬 프로젝트 추가',
'directoryExplorerDialog.actions.cloneRepository': '저장소 복제',
'directoryExplorerDialog.actions.cloneAndAdd': '복제하고 추가',
@@ -1808,6 +1856,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.parentDirectory': '상위 디렉터리',
'directoryExplorerDialog.browse.addedBadge': '추가됨',
'directoryExplorerDialog.browse.quickAdd': '추가',
'directoryExplorerDialog.browse.selectForAdd': '추가할 항목 선택',
'directoryExplorerDialog.footer.navigate': '탐색',
'directoryExplorerDialog.footer.select': '선택',
'directoryExplorerDialog.footer.add': '추가',
@@ -1816,6 +1865,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': '데스크톱에서 디렉터리 접근이 거부되었습니다.',
'directoryExplorerDialog.toast.failedToOpenDirectory': '디렉터리를 열지 못했습니다',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '데스크톱에서 파일 접근 권한을 부여하지 못했습니다.',
'directoryExplorerDialog.toast.addedProjects': '프로젝트 {count}개 추가됨',
'directoryExplorerDialog.toast.failedToAddProject': '프로젝트 추가 실패',
'directoryExplorerDialog.toast.cloneUrlRequired': '복제하기 전에 저장소 URL을 입력하세요.',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '유효한 디렉터리 경로를 선택하세요.',
@@ -1864,22 +1914,18 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': '채팅 입력창으로 포커스 이동',
'helpDialog.item.togglePromptNavigator': '프롬프트 탐색기 표시/숨기기',
'helpDialog.item.abortActiveRun': '활성 실행 중단(두 번 누르기)',
'helpDialog.item.toggleRightSidebar': '컨텍스트 패널 표시 전환',
'helpDialog.item.openRightSidebarGitTab': 'Git 서피스 열기',
'helpDialog.item.openRightSidebarFilesTab': '파일 서피스 열기',
'helpDialog.item.toggleTerminalDock': '터미널 독 전환',
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
'helpDialog.item.switchSessionTab': '세션 탭 전환',
'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)',
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
'helpDialog.item.openSettings': '설정 열기',
'helpDialog.keyCombiner.or': '또는',
'helpDialog.proTips.title': '팁:',
'helpDialog.proTips.commandPalette': '명령 팔레트({shortcut})로 모든 작업에 빠르게 접근하세요',
'helpDialog.proTips.recentSessions': '최근 세션 5개가 명령 팔레트에 표시됩니다',
'helpDialog.proTips.themeCycling': '테마 순환은 세션 간에도 선호 설정을 기억합니다',
'helpDialog.proTips.leaderSequences': '2단계 단축키: 조합을 누른 뒤 두 번째 키를 누르세요 (Esc로 취소)',
'header.actions.rightSidebarWithShortcut': '오른쪽 사이드바 ({shortcut})',
'header.actions.toggleRightSidebarAria': '오른쪽 사이드바 토글',
'header.actions.openAppMenu': 'OpenChamber 메뉴',
@@ -1963,8 +2009,6 @@ export const dict: Record<I18nKey, string> = {
'session.newWorktree.noMatchingBranches': '일치하는 브랜치가 없습니다',
'session.newWorktree.localBranches': '로컬 브랜치',
'session.newWorktree.remoteBranches': '리모트 브랜치',
'session.newWorktree.otherLocalBranches': '기타 로컬 브랜치',
'session.newWorktree.otherRemoteBranches': '기타 리모트 브랜치',
'session.newWorktree.branchName': '브랜치 이름',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '변경',
@@ -2089,7 +2133,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '작업',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음',
'chat.statusRow.aborted': '중단됨',
'chat.revertIndicator.redo': '다시 실행',
'chat.revertIndicator.redoAria': '다시 실행 — 되돌린 메시지 복원',
'chat.revertPopover.title': '되돌림',
@@ -2167,7 +2210,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다',
'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.',
'chat.container.sessionLoadError.description': '대화를 가져오지 못했습니다. 서버가 꺼져 있거나 연결할 수 없는 상태일 수 있습니다. 데이터는 사라지지 않았으니 복구되면 다시 시도하세요.',
'chat.container.sessionLoadError.authDescription': '세션이 만료되어 서버가 요청을 거부했습니다. 로그인하면 대화가 로드됩니다.',
'chat.container.sessionLoadError.retry': '다시 시도',
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
@@ -2210,10 +2254,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
'chat.textSelection.comment.attach': '첨부',
'chat.textSelection.actions.newSession': '새 세션',
'chat.textSelection.actions.addToNotes': '메모에 추가',
'chat.textSelection.title.addToCurrentChat': '현재 채팅에 추가',
'chat.textSelection.title.newSessionWithSelection': '선택한 내용으로 새 세션 생성',
'chat.textSelection.title.saveInsightToNotes': '선택한 텍스트를 메모에 저장',
'chat.messageBody.actions.revertAria': '이 메시지로 되돌리기',
'chat.messageBody.actions.revert': '여기부터 되돌리기',
@@ -2307,7 +2349,12 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.attachmentsTooLarge': '첨부 파일이 너무 커서 보낼 수 없습니다. 이미지 수나 크기를 줄여 보세요.',
'chat.chatInput.toast.sendAttachmentsFailed': '첨부 파일 전송 실패. 파일 수나 이미지 크기를 줄여 보세요.',
'chat.chatInput.toast.messageSendFailed': '메시지 전송에 실패했습니다. 첨부 파일을 복원했습니다.',
'chat.chatInput.toast.noModelSelected': '전송하기 전에 제공업체와 모델을 선택하세요.',
'chat.chatInput.toast.clipboardAttachFailed': '클립보드 이미지 첨부 실패',
'chat.chatInput.toast.clipboardTextAttachFailed': '붙여넣은 텍스트를 파일로 첨부하지 못했습니다',
'chat.chatInput.toast.largeTextPaste.title': '긴 텍스트 감지됨',
'chat.chatInput.toast.largeTextPaste.attach': '파일로 첨부',
'chat.chatInput.toast.largeTextPaste.inline': '본문에 붙여넣기',
'chat.chatInput.toast.addedFileMentions': '파일 멘션 {count}개 추가됨',
'chat.chatInput.toast.attachFileFailed': '첨부 파일 실패',
'chat.chatInput.toast.attachNamedFailed': '첨부 {name} 실패',
@@ -2356,6 +2403,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.showRawJson': '원시 JSON 표시',
'chat.toolPart.showFormattedJson': '형식화된 JSON 표시',
'chat.toolPart.showNavigableJson': '탐색 가능한 JSON 표시',
'chat.toolPart.openFile': '파일 열기',
'chat.toolPart.openFileAtFirstChange': '첫 번째 변경 위치에서 파일 열기',
'chat.toolPart.openFileDiff': '파일 diff 열기',
'chat.toolPart.copyOutput': '출력 복사',
@@ -2487,6 +2535,15 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.toggleSidebar': '토글 사이드바',
'commandPalette.item.showContextUsage': '컨텍스트 사용량 표시',
'commandPalette.item.toggleTerminal': '토글 터미널',
'commandPalette.item.cycleTheme': '테마 순환',
'commandPalette.item.showOpenCodeStatus': 'OpenCode 상태 표시',
'commandPalette.item.toggleMemoryDebug': '메모리 디버그 패널 토글',
'commandPalette.item.pinSession': '세션 고정 또는 고정 해제',
'commandPalette.item.copySessionId': '세션 ID 복사',
'commandPalette.item.openMultiRun': '멀티 런 런처 열기',
'commandPalette.item.openArchive': '보관된 세션 열기',
'commandPalette.item.openNotes': '노트 패널 열기',
'commandPalette.item.openTodos': '할 일 패널 열기',
'commandPalette.item.openSettings': '설정... 열기',
'commandPalette.session.untitled': '제목 없는 세션',
'openCodeStatusDialog.title': 'OpenCode 상태',
@@ -2701,6 +2758,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': '패스키 로그인이 취소되었습니다.',
'sessionAuth.error.enterPasswordForPasskey': '패스키를 추가하려면 비밀번호를 입력하세요.',
'sessionAuth.locked.tunnelTitle': '터널 접근 필요',
'sessionAuth.expired.banner': '세션이 만료되었습니다. 계속하려면 로그인하세요.',
'sessionAuth.expired.loginAction': '로그인',
'sessionAuth.expired.sendBlocked': '세션이 만료되었습니다. 메시지를 보내려면 로그인하세요.',
'sessionAuth.locked.unlockTitle': 'OpenChamber 잠금 해제',
'sessionAuth.locked.tunnelDescription': '데스크톱 앱의 일회용 연결 링크로 이 터널을 여세요.',
'sessionAuth.locked.passwordDescription': '이 세션은 비밀번호로 보호됩니다.',
@@ -2983,6 +3043,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '업데이트 중…',
'updateDialog.error.updateFailed': '업데이트 실패',
'updateDialog.error.takingLonger': '업데이트가 예상보다 오래 걸립니다. 잠시 기다린 뒤 새로고침하거나 `openchamber update`를 실행하세요.',
'updateDialog.error.signatureRejected': '다운로드한 업데이트가 거부되었습니다. 코드 서명이 이 설치본과 일치하지 않습니다. 보통 실행 중인 복사본이 공식 서명 릴리스에서 설치되지 않았다는 뜻입니다. 공식 릴리스에서 OpenChamber를 설치한 뒤 다시 업데이트하세요.',
'updateDialog.error.updaterDisabled': '설치에 실패하여 업데이터가 중지되었습니다. OpenChamber를 종료했다가 다시 열고 업데이트를 재시도하세요.',
'updateDialog.error.restartFailed': '업데이트를 설치하기 위한 재시작에 실패했습니다.',
'updateDialog.error.restartUnavailable': '업데이트 설치에는 OpenChamber 데스크톱 앱이 필요합니다.',
'mobileUpdate.toast.available.title': 'OpenChamber 업데이트 사용 가능',
'mobileUpdate.toast.available.description': 'Android용 버전 {version}이 준비되었습니다.',
'mobileUpdate.toast.actions.download': '다운로드',
@@ -3003,6 +3067,7 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.title': '디버그 패널',
'memoryDebugPanel.tabs.memory': '메모리',
'memoryDebugPanel.tabs.streaming': '스트리밍',
'memoryDebugPanel.tabs.requests': '요청',
'memoryDebugPanel.section.sessionsInMemory': '메모리 내 세션',
'memoryDebugPanel.section.uiStreamingMetrics': 'UI 스트리밍 지표',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 브리지 지표',
@@ -3040,6 +3105,16 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.streaming.copy.copied': '스트리밍 디버그 JSON 복사 완료',
'memoryDebugPanel.streaming.copy.failed': 'JSON 복사 실패',
'memoryDebugPanel.streaming.copy.hint': 'UI와 VS Code 스트리밍 메트릭을 JSON으로 복사합니다',
'memoryDebugPanel.requests.inFlight': '진행 중',
'memoryDebugPanel.requests.peak': '최대',
'memoryDebugPanel.requests.duration': '지속 시간',
'memoryDebugPanel.requests.totalRequests': '전체 요청',
'memoryDebugPanel.requests.tracking': '추적 중',
'memoryDebugPanel.requests.now': '현재',
'memoryDebugPanel.requests.noSamples': '아직 기록된 요청이 없습니다. fetch 활동을 기록하려면 이 패널을 열어 두세요.',
'memoryDebugPanel.requests.chartLabel': '시간에 따른 진행 중인 fetch 요청, 최대 {peak}',
'memoryDebugPanel.requests.windowHint': '최근 {seconds}초',
'memoryDebugPanel.requests.percentileChartLabel': '진행 중 요청 수명 백분위수(p50, p90, p99, max)의 시간별 변화',
'memoryDebugPanel.common.idle': '유휴',
'memoryDebugPanel.common.live': '실시간',
'memoryDebugPanel.common.notAvailable': 'n/a',
@@ -3103,9 +3178,10 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premium': 'Premium Interactions',
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'quota.window.premiumInteractions': 'AI 크레딧',
'chat.workStatus.ariaLabel': '작업 상태',
'chat.workStatus.context.label': '컨텍스트',
'chat.workStatus.cost.breakdown': '세션 {session} · 서브 에이전트 {subagents}',
'chat.workStatus.git.changedFileSingle': '파일 {count}개 변경됨',
'chat.workStatus.git.changedFilePlural': '파일 {count}개 변경됨',
'chat.workStatus.pr.untitled': '제목 없는 풀 리퀘스트',
@@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test';
import { linearIntegrationI18n } from './linear-integration.i18n';
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
const requiredKeys = [
'settings.integrations.firstParty.title',
'settings.integrations.firstParty.info',
'settings.integrations.linear.title',
'settings.integrations.linear.description',
'settings.integrations.linear.info',
'settings.integrations.linear.status.notConnected',
'settings.integrations.linear.status.connected',
'settings.integrations.linear.status.waiting',
'settings.integrations.linear.actions.connect',
'settings.integrations.linear.actions.disconnect',
'settings.integrations.linear.actions.addWorkspace',
'settings.integrations.linear.actions.switchTo',
'settings.integrations.linear.label.otherWorkspaces',
'settings.integrations.linear.flow.title',
'settings.integrations.linear.flow.description',
'settings.integrations.linear.flow.waiting',
'settings.integrations.linear.toast.connected',
'settings.integrations.linear.toast.disconnected',
'settings.integrations.linear.toast.workspaceSwitched',
'settings.integrations.linear.toast.workspaceSwitchFailed',
'settings.integrations.linear.toast.startConnectFailed',
'settings.integrations.linear.toast.disconnectFailed',
'settings.integrations.linear.toast.authorizationFailed',
'settings.integrations.linear.avatarAlt.withName',
'settings.integrations.linear.avatarAlt.fallback',
'settings.integrations.linear.label.unknownUser',
'settings.integrations.linear.mapping.defaultProject',
'settings.integrations.linear.mapping.defaultProject.info',
'settings.integrations.linear.mapping.defaultProject.placeholder',
'settings.integrations.linear.mapping.defaultProject.aria',
'settings.integrations.linear.mapping.teams',
'settings.integrations.linear.mapping.teams.info',
'settings.integrations.linear.mapping.teams.useDefault',
'settings.integrations.linear.mapping.teams.aria',
'settings.integrations.linear.mapping.emptyProjects',
'settings.integrations.linear.mapping.emptyTeams',
'settings.integrations.linear.mapping.loadFailed',
'settings.integrations.linear.sessionComments.label',
'settings.integrations.linear.sessionComments.info',
'settings.integrations.linear.sessionComments.aria',
'settings.integrations.linear.sessionComments.loadFailed',
'settings.magicPrompts.sidebar.group.linear',
'settings.magicPrompts.sidebar.item.linearIssueReview',
'settings.magicPrompts.page.group.linearIssueReview.title',
'settings.magicPrompts.page.group.linearIssueReview.description',
] as const;
describe('linear integration translations', () => {
test('provides every required key in every supported locale', () => {
const english = linearIntegrationI18n.en;
for (const locale of locales) {
for (const key of requiredKeys) {
const value = linearIntegrationI18n[locale][key];
expect(value).toBeTruthy();
if (
locale !== 'en'
&& key !== 'settings.integrations.linear.title'
&& key !== 'settings.magicPrompts.sidebar.group.linear'
) {
expect(value).not.toBe(english[key]);
}
}
}
});
});
@@ -0,0 +1,567 @@
/** Linear first-party integration settings strings — merged into each locale's settings dictionary. */
export const linearIntegrationI18n = {
en: {
'settings.integrations.firstParty.title': 'Built-in integrations',
'settings.integrations.firstParty.info': 'Sign-ins for services that ship with OpenChamber. The login stays on this computer so web, desktop, and a paired phone share it.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Connect Linear workspaces on this OpenChamber server.',
'settings.integrations.linear.info': 'Connect one or more Linear workspaces. OpenChamber stores the logins on this computer so web, desktop, and a paired phone share them.',
'settings.integrations.linear.status.notConnected': 'Not connected',
'settings.integrations.linear.status.connected': 'Connected',
'settings.integrations.linear.status.waiting': 'Waiting',
'settings.integrations.linear.actions.connect': 'Connect',
'settings.integrations.linear.actions.disconnect': 'Disconnect',
'settings.integrations.linear.actions.addWorkspace': 'Add workspace',
'settings.integrations.linear.actions.switchTo': 'Switch to',
'settings.integrations.linear.label.otherWorkspaces': 'Other workspaces',
'settings.integrations.linear.flow.title': 'Waiting for Linear',
'settings.integrations.linear.flow.description': 'Finish signing in in the browser tab that just opened.',
'settings.integrations.linear.flow.waiting': 'Waiting for authorization…',
'settings.integrations.linear.toast.connected': 'Linear connected',
'settings.integrations.linear.toast.disconnected': 'Linear disconnected',
'settings.integrations.linear.toast.workspaceSwitched': 'Switched Linear workspace',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace',
'settings.integrations.linear.toast.startConnectFailed': 'Could not start Linear sign-in',
'settings.integrations.linear.toast.disconnectFailed': 'Could not disconnect Linear',
'settings.integrations.linear.toast.authorizationFailed': 'Linear authorization timed out. Click Connect to try again.',
'settings.integrations.linear.avatarAlt.withName': 'Linear avatar for {name}',
'settings.integrations.linear.avatarAlt.fallback': 'Linear avatar',
'settings.integrations.linear.label.unknownUser': 'Unknown user',
'settings.integrations.linear.mapping.defaultProject': 'Default project',
'settings.integrations.linear.mapping.defaultProject.info': 'New sessions from Linear issues use this project unless the issue\'s team has its own mapping.',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'None',
'settings.integrations.linear.mapping.defaultProject.aria': 'Default project for Linear issues',
'settings.integrations.linear.mapping.teams': 'Team projects',
'settings.integrations.linear.mapping.teams.info': 'Optional. An issue from a mapped team opens in that project instead of the default.',
'settings.integrations.linear.mapping.teams.useDefault': 'Use default',
'settings.integrations.linear.mapping.teams.aria': 'Project for Linear team {team}',
'settings.integrations.linear.mapping.emptyProjects': 'Add a project first, then map Linear teams to it.',
'settings.integrations.linear.mapping.emptyTeams': 'This Linear workspace has no teams.',
'settings.integrations.linear.mapping.loadFailed': 'Could not load Linear project mapping.',
'settings.integrations.linear.sessionComments.label': 'Session comments',
'settings.integrations.linear.sessionComments.info': 'Adds a comment to the issue when a session starts, finishes, or fails. Comments are only posted when this server has a public address, so the link opens the session for everyone on the issue.',
'settings.integrations.linear.sessionComments.aria': 'Post session status comments to Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Could not load Linear comment settings.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue Review',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue Review',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts used when starting a session from a Linear issue: visible user message + hidden instructions.',
},
de: {
'settings.integrations.firstParty.title': 'Eingebaute Integrationen',
'settings.integrations.firstParty.info': 'Anmeldungen für Dienste, die mit OpenChamber mitgeliefert werden. Die Anmeldung bleibt auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Verbinde Linear-Workspaces mit diesem OpenChamber-Server.',
'settings.integrations.linear.info': 'Verbinde einen oder mehrere Linear-Workspaces. OpenChamber speichert die Anmeldungen auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.',
'settings.integrations.linear.status.notConnected': 'Nicht verbunden',
'settings.integrations.linear.status.connected': 'Verbunden',
'settings.integrations.linear.status.waiting': 'Warten',
'settings.integrations.linear.actions.connect': 'Verbinden',
'settings.integrations.linear.actions.disconnect': 'Trennen',
'settings.integrations.linear.actions.addWorkspace': 'Workspace hinzufügen',
'settings.integrations.linear.actions.switchTo': 'Wechseln zu',
'settings.integrations.linear.label.otherWorkspaces': 'Andere Workspaces',
'settings.integrations.linear.flow.title': 'Warte auf Linear',
'settings.integrations.linear.flow.description': 'Schließe die Anmeldung im gerade geöffneten Browser-Tab ab.',
'settings.integrations.linear.flow.waiting': 'Warte auf die Autorisierung…',
'settings.integrations.linear.toast.connected': 'Linear verbunden',
'settings.integrations.linear.toast.disconnected': 'Linear getrennt',
'settings.integrations.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden',
'settings.integrations.linear.toast.startConnectFailed': 'Linear-Anmeldung konnte nicht gestartet werden',
'settings.integrations.linear.toast.disconnectFailed': 'Linear konnte nicht getrennt werden',
'settings.integrations.linear.toast.authorizationFailed': 'Die Linear-Autorisierung ist abgelaufen. Klicke auf Verbinden, um es erneut zu versuchen.',
'settings.integrations.linear.avatarAlt.withName': 'Linear-Avatar für {name}',
'settings.integrations.linear.avatarAlt.fallback': 'Linear-Avatar',
'settings.integrations.linear.label.unknownUser': 'Unbekannter Benutzer',
'settings.integrations.linear.mapping.defaultProject': 'Standardprojekt',
'settings.integrations.linear.mapping.defaultProject.info': 'Neue Sitzungen aus Linear-Issues nutzen dieses Projekt, sofern das Team des Issues keine eigene Zuordnung hat.',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Keines',
'settings.integrations.linear.mapping.defaultProject.aria': 'Standardprojekt für Linear-Issues',
'settings.integrations.linear.mapping.teams': 'Team-Projekte',
'settings.integrations.linear.mapping.teams.info': 'Optional. Ein Issue eines zugeordneten Teams öffnet sich in diesem Projekt statt im Standard.',
'settings.integrations.linear.mapping.teams.useDefault': 'Standard verwenden',
'settings.integrations.linear.mapping.teams.aria': 'Projekt für Linear-Team {team}',
'settings.integrations.linear.mapping.emptyProjects': 'Füge zuerst ein Projekt hinzu und ordne dann Linear-Teams zu.',
'settings.integrations.linear.mapping.emptyTeams': 'Dieser Linear-Workspace hat keine Teams.',
'settings.integrations.linear.mapping.loadFailed': 'Linear-Projektzuordnung konnte nicht geladen werden.',
'settings.integrations.linear.sessionComments.label': 'Sitzungskommentare',
'settings.integrations.linear.sessionComments.info': 'Kommentiert das Issue, wenn eine Sitzung startet, endet oder fehlschlägt. Kommentare werden nur gepostet, wenn dieser Server eine öffentliche Adresse hat, damit der Link die Sitzung für alle Beteiligten öffnet.',
'settings.integrations.linear.sessionComments.aria': 'Statuskommentare zu Sitzungen in Linear posten',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear-Kommentareinstellungen konnten nicht geladen werden.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue-Review',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue-Review',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Eingabeaufforderungen beim Start einer Sitzung aus einem Linear-Issue: sichtbare Benutzernachricht + versteckte Anweisungen.',
},
fr: {
'settings.integrations.firstParty.title': 'Intégrations natives',
'settings.integrations.firstParty.info': 'Connexions aux services fournis avec OpenChamber. La connexion reste sur cet ordinateur pour que le web, le bureau et un téléphone apparié la partagent.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Connectez des espaces Linear à ce serveur OpenChamber.',
'settings.integrations.linear.info': 'Connectez un ou plusieurs espaces Linear. OpenChamber enregistre les connexions sur cet ordinateur pour que le web, le bureau et un téléphone apparié les partagent.',
'settings.integrations.linear.status.notConnected': 'Non connecté',
'settings.integrations.linear.status.connected': 'Connecté',
'settings.integrations.linear.status.waiting': 'En attente',
'settings.integrations.linear.actions.connect': 'Connecter',
'settings.integrations.linear.actions.disconnect': 'Déconnecter',
'settings.integrations.linear.actions.addWorkspace': 'Ajouter un workspace',
'settings.integrations.linear.actions.switchTo': 'Basculer vers',
'settings.integrations.linear.label.otherWorkspaces': 'Autres workspaces',
'settings.integrations.linear.flow.title': 'En attente de Linear',
'settings.integrations.linear.flow.description': 'Terminez la connexion dans longlet du navigateur qui vient de souvrir.',
'settings.integrations.linear.flow.waiting': 'En attente de lautorisation…',
'settings.integrations.linear.toast.connected': 'Linear connecté',
'settings.integrations.linear.toast.disconnected': 'Linear déconnecté',
'settings.integrations.linear.toast.workspaceSwitched': 'Workspace Linear modifié',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear',
'settings.integrations.linear.toast.startConnectFailed': 'Impossible de démarrer la connexion Linear',
'settings.integrations.linear.toast.disconnectFailed': 'Impossible de déconnecter Linear',
'settings.integrations.linear.toast.authorizationFailed': 'Lautorisation Linear a expiré. Cliquez sur Connecter pour réessayer.',
'settings.integrations.linear.avatarAlt.withName': 'Avatar Linear de {name}',
'settings.integrations.linear.avatarAlt.fallback': 'Avatar Linear',
'settings.integrations.linear.label.unknownUser': 'Utilisateur inconnu',
'settings.integrations.linear.mapping.defaultProject': 'Projet par défaut',
'settings.integrations.linear.mapping.defaultProject.info': 'Les nouvelles sessions depuis des tickets Linear utilisent ce projet, sauf si l’équipe du ticket a sa propre association.',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Aucun',
'settings.integrations.linear.mapping.defaultProject.aria': 'Projet par défaut pour les tickets Linear',
'settings.integrations.linear.mapping.teams': 'Projets par équipe',
'settings.integrations.linear.mapping.teams.info': 'Facultatif. Un ticket dune équipe associée souvre dans ce projet plutôt que dans le projet par défaut.',
'settings.integrations.linear.mapping.teams.useDefault': 'Utiliser le défaut',
'settings.integrations.linear.mapping.teams.aria': 'Projet pour l’équipe Linear {team}',
'settings.integrations.linear.mapping.emptyProjects': 'Ajoutez dabord un projet, puis associez les équipes Linear.',
'settings.integrations.linear.mapping.emptyTeams': 'Cet espace Linear na aucune équipe.',
'settings.integrations.linear.mapping.loadFailed': 'Impossible de charger lassociation des projets Linear.',
'settings.integrations.linear.sessionComments.label': 'Commentaires de session',
'settings.integrations.linear.sessionComments.info': 'Ajoute un commentaire au ticket quand une session démarre, se termine ou échoue. Les commentaires ne sont publiés que si ce serveur a une adresse publique, afin que le lien ouvre la session pour tout le monde.',
'settings.integrations.linear.sessionComments.aria': 'Publier les commentaires d’état de session dans Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Impossible de charger les réglages de commentaires Linear.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revue dissue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revue dissue',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts utilisés au démarrage dune session depuis un ticket Linear : message utilisateur visible + instructions masquées.',
},
es: {
'settings.integrations.firstParty.title': 'Integraciones nativas',
'settings.integrations.firstParty.info': 'Inicios de sesión de los servicios incluidos en OpenChamber. El inicio de sesión se guarda en este ordenador para que la web, el escritorio y un teléfono emparejado lo compartan.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Conecta espacios de Linear a este servidor de OpenChamber.',
'settings.integrations.linear.info': 'Conecta uno o más espacios de Linear. OpenChamber guarda los inicios de sesión en este ordenador para que la web, el escritorio y un teléfono emparejado los compartan.',
'settings.integrations.linear.status.notConnected': 'No conectado',
'settings.integrations.linear.status.connected': 'Conectado',
'settings.integrations.linear.status.waiting': 'Esperando',
'settings.integrations.linear.actions.connect': 'Conectar',
'settings.integrations.linear.actions.disconnect': 'Desconectar',
'settings.integrations.linear.actions.addWorkspace': 'Añadir workspace',
'settings.integrations.linear.actions.switchTo': 'Cambiar a',
'settings.integrations.linear.label.otherWorkspaces': 'Otros workspaces',
'settings.integrations.linear.flow.title': 'Esperando a Linear',
'settings.integrations.linear.flow.description': 'Termina de iniciar sesión en la pestaña del navegador que acaba de abrirse.',
'settings.integrations.linear.flow.waiting': 'Esperando la autorización…',
'settings.integrations.linear.toast.connected': 'Linear conectado',
'settings.integrations.linear.toast.disconnected': 'Linear desconectado',
'settings.integrations.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear',
'settings.integrations.linear.toast.startConnectFailed': 'No se pudo iniciar la conexión con Linear',
'settings.integrations.linear.toast.disconnectFailed': 'No se pudo desconectar Linear',
'settings.integrations.linear.toast.authorizationFailed': 'La autorización de Linear ha caducado. Haz clic en Conectar para intentarlo de nuevo.',
'settings.integrations.linear.avatarAlt.withName': 'Avatar de Linear de {name}',
'settings.integrations.linear.avatarAlt.fallback': 'Avatar de Linear',
'settings.integrations.linear.label.unknownUser': 'Usuario desconocido',
'settings.integrations.linear.mapping.defaultProject': 'Proyecto predeterminado',
'settings.integrations.linear.mapping.defaultProject.info': 'Las sesiones nuevas desde issues de Linear usan este proyecto, salvo que el equipo del issue tenga su propia asignación.',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Ninguno',
'settings.integrations.linear.mapping.defaultProject.aria': 'Proyecto predeterminado para issues de Linear',
'settings.integrations.linear.mapping.teams': 'Proyectos por equipo',
'settings.integrations.linear.mapping.teams.info': 'Opcional. Un issue de un equipo asignado se abre en ese proyecto en lugar del predeterminado.',
'settings.integrations.linear.mapping.teams.useDefault': 'Usar el predeterminado',
'settings.integrations.linear.mapping.teams.aria': 'Proyecto para el equipo de Linear {team}',
'settings.integrations.linear.mapping.emptyProjects': 'Añade primero un proyecto y luego asigna equipos de Linear.',
'settings.integrations.linear.mapping.emptyTeams': 'Este espacio de Linear no tiene equipos.',
'settings.integrations.linear.mapping.loadFailed': 'No se pudo cargar la asignación de proyectos de Linear.',
'settings.integrations.linear.sessionComments.label': 'Comentarios de sesión',
'settings.integrations.linear.sessionComments.info': 'Añade un comentario a la incidencia cuando una sesión empieza, termina o falla. Los comentarios solo se publican si este servidor tiene una dirección pública, para que el enlace abra la sesión a todos.',
'settings.integrations.linear.sessionComments.aria': 'Publicar comentarios de estado de sesión en Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'No se pudieron cargar los ajustes de comentarios de Linear.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisión de issue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisión de issue',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados al iniciar una sesión desde un issue de Linear: mensaje visible del usuario e instrucciones ocultas.',
},
ja: {
'settings.integrations.firstParty.title': '標準連携',
'settings.integrations.firstParty.info': 'OpenChamber に同梱されているサービスのログインです。このコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'この OpenChamber サーバーに Linear ワークスペースを接続します。複数接続できます。',
'settings.integrations.linear.info': 'Linear ワークスペースを1つ以上接続します。ログインはこのコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。',
'settings.integrations.linear.status.notConnected': '未接続',
'settings.integrations.linear.status.connected': '接続済み',
'settings.integrations.linear.status.waiting': '待機中',
'settings.integrations.linear.actions.connect': '接続',
'settings.integrations.linear.actions.disconnect': '切断',
'settings.integrations.linear.actions.addWorkspace': 'ワークスペースを追加',
'settings.integrations.linear.actions.switchTo': '切り替える',
'settings.integrations.linear.label.otherWorkspaces': '他のワークスペース',
'settings.integrations.linear.flow.title': 'Linear を待っています',
'settings.integrations.linear.flow.description': '開いたブラウザタブでサインインを完了してください。',
'settings.integrations.linear.flow.waiting': '認可を待っています…',
'settings.integrations.linear.toast.connected': 'Linear に接続しました',
'settings.integrations.linear.toast.disconnected': 'Linear を切断しました',
'settings.integrations.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした',
'settings.integrations.linear.toast.startConnectFailed': 'Linear のサインインを開始できませんでした',
'settings.integrations.linear.toast.disconnectFailed': 'Linear を切断できませんでした',
'settings.integrations.linear.toast.authorizationFailed': 'Linear の認可がタイムアウトしました。接続をもう一度押してください。',
'settings.integrations.linear.avatarAlt.withName': '{name} の Linear アバター',
'settings.integrations.linear.avatarAlt.fallback': 'Linear アバター',
'settings.integrations.linear.label.unknownUser': '不明なユーザー',
'settings.integrations.linear.mapping.defaultProject': 'デフォルトのプロジェクト',
'settings.integrations.linear.mapping.defaultProject.info': 'Linear Issueから作る新しいセッションはこのプロジェクトを使います。チームに個別の割り当てがある場合はそちらを使います。',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'なし',
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issueのデフォルトプロジェクト',
'settings.integrations.linear.mapping.teams': 'チームのプロジェクト',
'settings.integrations.linear.mapping.teams.info': '任意。割り当てたチームのIssueは、デフォルトではなくそのプロジェクトで開きます。',
'settings.integrations.linear.mapping.teams.useDefault': 'デフォルトを使う',
'settings.integrations.linear.mapping.teams.aria': 'Linearチーム {team} のプロジェクト',
'settings.integrations.linear.mapping.emptyProjects': '先にプロジェクトを追加してから、Linearチームを割り当ててください。',
'settings.integrations.linear.mapping.emptyTeams': 'このLinearワークスペースにはチームがありません。',
'settings.integrations.linear.mapping.loadFailed': 'Linearのプロジェクト割り当てを読み込めませんでした。',
'settings.integrations.linear.sessionComments.label': 'セッションのコメント',
'settings.integrations.linear.sessionComments.info': 'セッションの開始・完了・失敗時にイシューへコメントします。リンクを誰でも開けるよう、このサーバーが公開アドレスを持つ場合のみ投稿します。',
'settings.integrations.linear.sessionComments.aria': 'セッション状態のコメントを Linear に投稿',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear のコメント設定を読み込めませんでした。',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue レビュー',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue レビュー',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear の Issue からセッションを開始するときに使うプロンプト: 表示ユーザーメッセージ + 非表示の指示。',
},
ko: {
'settings.integrations.firstParty.title': '기본 제공 통합',
'settings.integrations.firstParty.info': 'OpenChamber에 포함된 서비스 로그인입니다. 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': '이 OpenChamber 서버에 Linear 워크스페이스를 연결하세요. 여러 개를 연결할 수 있습니다.',
'settings.integrations.linear.info': 'Linear 워크스페이스를 하나 이상 연결하세요. 로그인은 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.',
'settings.integrations.linear.status.notConnected': '연결되지 않음',
'settings.integrations.linear.status.connected': '연결됨',
'settings.integrations.linear.status.waiting': '대기 중',
'settings.integrations.linear.actions.connect': '연결',
'settings.integrations.linear.actions.disconnect': '연결 해제',
'settings.integrations.linear.actions.addWorkspace': '워크스페이스 추가',
'settings.integrations.linear.actions.switchTo': '전환',
'settings.integrations.linear.label.otherWorkspaces': '다른 워크스페이스',
'settings.integrations.linear.flow.title': 'Linear 대기 중',
'settings.integrations.linear.flow.description': '방금 열린 브라우저 탭에서 로그인을 완료하세요.',
'settings.integrations.linear.flow.waiting': '권한 부여를 기다리는 중…',
'settings.integrations.linear.toast.connected': 'Linear가 연결됨',
'settings.integrations.linear.toast.disconnected': 'Linear 연결이 해제됨',
'settings.integrations.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다',
'settings.integrations.linear.toast.startConnectFailed': 'Linear 로그인을 시작하지 못했습니다',
'settings.integrations.linear.toast.disconnectFailed': 'Linear 연결을 해제하지 못했습니다',
'settings.integrations.linear.toast.authorizationFailed': 'Linear 권한 부여가 시간 초과되었습니다. 연결을 다시 누르세요.',
'settings.integrations.linear.avatarAlt.withName': '{name}의 Linear 아바타',
'settings.integrations.linear.avatarAlt.fallback': 'Linear 아바타',
'settings.integrations.linear.label.unknownUser': '알 수 없는 사용자',
'settings.integrations.linear.mapping.defaultProject': '기본 프로젝트',
'settings.integrations.linear.mapping.defaultProject.info': 'Linear 이슈에서 만드는 새 세션은 이 프로젝트를 사용합니다. 해당 팀에 별도 연결이 있으면 그쪽을 씁니다.',
'settings.integrations.linear.mapping.defaultProject.placeholder': '없음',
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear 이슈의 기본 프로젝트',
'settings.integrations.linear.mapping.teams': '팀 프로젝트',
'settings.integrations.linear.mapping.teams.info': '선택 사항입니다. 연결한 팀의 이슈는 기본값 대신 그 프로젝트에서 열립니다.',
'settings.integrations.linear.mapping.teams.useDefault': '기본값 사용',
'settings.integrations.linear.mapping.teams.aria': 'Linear 팀 {team}의 프로젝트',
'settings.integrations.linear.mapping.emptyProjects': '먼저 프로젝트를 추가한 다음 Linear 팀을 연결하세요.',
'settings.integrations.linear.mapping.emptyTeams': '이 Linear 워크스페이스에는 팀이 없습니다.',
'settings.integrations.linear.mapping.loadFailed': 'Linear 프로젝트 연결을 불러오지 못했습니다.',
'settings.integrations.linear.sessionComments.label': '세션 댓글',
'settings.integrations.linear.sessionComments.info': '세션이 시작, 완료, 실패할 때 이슈에 댓글을 남깁니다. 링크를 모두가 열 수 있도록 이 서버에 공개 주소가 있을 때만 게시합니다.',
'settings.integrations.linear.sessionComments.aria': '세션 상태 댓글을 Linear에 게시',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear 댓글 설정을 불러오지 못했습니다.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': '이슈 리뷰',
'settings.magicPrompts.page.group.linearIssueReview.title': '이슈 리뷰',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear 이슈로 세션을 시작할 때 쓰는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침.',
},
pl: {
'settings.integrations.firstParty.title': 'Wbudowane integracje',
'settings.integrations.firstParty.info': 'Logowania do usług dostarczanych z OpenChamber. Zapisujemy je na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Połącz przestrzenie Linear z tym serwerem OpenChamber.',
'settings.integrations.linear.info': 'Połącz jedną lub kilka przestrzeni Linear. OpenChamber zapisuje logowania na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.',
'settings.integrations.linear.status.notConnected': 'Nie połączono',
'settings.integrations.linear.status.connected': 'Połączono',
'settings.integrations.linear.status.waiting': 'Oczekiwanie',
'settings.integrations.linear.actions.connect': 'Połącz',
'settings.integrations.linear.actions.disconnect': 'Rozłącz',
'settings.integrations.linear.actions.addWorkspace': 'Dodaj workspace',
'settings.integrations.linear.actions.switchTo': 'Przełącz na',
'settings.integrations.linear.label.otherWorkspaces': 'Inne przestrzenie',
'settings.integrations.linear.flow.title': 'Oczekiwanie na Linear',
'settings.integrations.linear.flow.description': 'Dokończ logowanie w karcie przeglądarki, która właśnie się otworzyła.',
'settings.integrations.linear.flow.waiting': 'Oczekiwanie na autoryzację…',
'settings.integrations.linear.toast.connected': 'Połączono z Linear',
'settings.integrations.linear.toast.disconnected': 'Rozłączono Linear',
'settings.integrations.linear.toast.workspaceSwitched': 'Przełączono workspace Linear',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear',
'settings.integrations.linear.toast.startConnectFailed': 'Nie udało się rozpocząć logowania do Linear',
'settings.integrations.linear.toast.disconnectFailed': 'Nie udało się rozłączyć Linear',
'settings.integrations.linear.toast.authorizationFailed': 'Autoryzacja Linear wygasła. Kliknij Połącz, aby spróbować ponownie.',
'settings.integrations.linear.avatarAlt.withName': 'Awatar Linear użytkownika {name}',
'settings.integrations.linear.avatarAlt.fallback': 'Awatar Linear',
'settings.integrations.linear.label.unknownUser': 'Nieznany użytkownik',
'settings.integrations.linear.mapping.defaultProject': 'Domyślny projekt',
'settings.integrations.linear.mapping.defaultProject.info': 'Nowe sesje ze zgłoszeń Linear używają tego projektu, chyba że zespół zgłoszenia ma własne przypisanie.',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Brak',
'settings.integrations.linear.mapping.defaultProject.aria': 'Domyślny projekt dla zgłoszeń Linear',
'settings.integrations.linear.mapping.teams': 'Projekty zespołów',
'settings.integrations.linear.mapping.teams.info': 'Opcjonalnie. Zgłoszenie z przypisanego zespołu otworzy się w tym projekcie zamiast w domyślnym.',
'settings.integrations.linear.mapping.teams.useDefault': 'Użyj domyślnego',
'settings.integrations.linear.mapping.teams.aria': 'Projekt dla zespołu Linear {team}',
'settings.integrations.linear.mapping.emptyProjects': 'Najpierw dodaj projekt, a potem przypisz zespoły Linear.',
'settings.integrations.linear.mapping.emptyTeams': 'Ten obszar Linear nie ma zespołów.',
'settings.integrations.linear.mapping.loadFailed': 'Nie udało się wczytać przypisania projektów Linear.',
'settings.integrations.linear.sessionComments.label': 'Komentarze o sesji',
'settings.integrations.linear.sessionComments.info': 'Dodaje komentarz do zgłoszenia, gdy sesja się zaczyna, kończy lub kończy błędem. Komentarze pojawiają się tylko wtedy, gdy ten serwer ma publiczny adres, żeby link otwierał sesję każdemu.',
'settings.integrations.linear.sessionComments.aria': 'Publikuj komentarze o stanie sesji w Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Nie udało się wczytać ustawień komentarzy Linear.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Przegląd zgłoszenia',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Przegląd zgłoszenia',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompty używane przy starcie sesji ze zgłoszenia Linear: widoczna wiadomość użytkownika i ukryte instrukcje.',
},
'pt-BR': {
'settings.integrations.firstParty.title': 'Integrações nativas',
'settings.integrations.firstParty.info': 'Logins dos serviços inclusos no OpenChamber. O login fica neste computador para que a web, o app desktop e um celular emparelhado o compartilhem.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Conecte espaços do Linear a este servidor OpenChamber.',
'settings.integrations.linear.info': 'Conecte um ou mais espaços do Linear. O OpenChamber guarda os logins neste computador para que a web, o app desktop e um celular emparelhado os compartilhem.',
'settings.integrations.linear.status.notConnected': 'Não conectado',
'settings.integrations.linear.status.connected': 'Conectado',
'settings.integrations.linear.status.waiting': 'Aguardando',
'settings.integrations.linear.actions.connect': 'Conectar',
'settings.integrations.linear.actions.disconnect': 'Desconectar',
'settings.integrations.linear.actions.addWorkspace': 'Adicionar workspace',
'settings.integrations.linear.actions.switchTo': 'Alternar para',
'settings.integrations.linear.label.otherWorkspaces': 'Outros workspaces',
'settings.integrations.linear.flow.title': 'Aguardando o Linear',
'settings.integrations.linear.flow.description': 'Conclua o login na aba do navegador que acabou de abrir.',
'settings.integrations.linear.flow.waiting': 'Aguardando autorização…',
'settings.integrations.linear.toast.connected': 'Linear conectado',
'settings.integrations.linear.toast.disconnected': 'Linear desconectado',
'settings.integrations.linear.toast.workspaceSwitched': 'Workspace do Linear alterado',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear',
'settings.integrations.linear.toast.startConnectFailed': 'Não foi possível iniciar o login no Linear',
'settings.integrations.linear.toast.disconnectFailed': 'Não foi possível desconectar o Linear',
'settings.integrations.linear.toast.authorizationFailed': 'A autorização do Linear expirou. Clique em Conectar para tentar de novo.',
'settings.integrations.linear.avatarAlt.withName': 'Avatar do Linear de {name}',
'settings.integrations.linear.avatarAlt.fallback': 'Avatar do Linear',
'settings.integrations.linear.label.unknownUser': 'Usuário desconhecido',
'settings.integrations.linear.mapping.defaultProject': 'Projeto padrão',
'settings.integrations.linear.mapping.defaultProject.info': 'Novas sessões a partir de issues do Linear usam este projeto, a menos que a equipe da issue tenha o próprio mapeamento.',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Nenhum',
'settings.integrations.linear.mapping.defaultProject.aria': 'Projeto padrão para issues do Linear',
'settings.integrations.linear.mapping.teams': 'Projetos por equipe',
'settings.integrations.linear.mapping.teams.info': 'Opcional. Uma issue de uma equipe mapeada abre nesse projeto em vez do padrão.',
'settings.integrations.linear.mapping.teams.useDefault': 'Usar o padrão',
'settings.integrations.linear.mapping.teams.aria': 'Projeto para a equipe do Linear {team}',
'settings.integrations.linear.mapping.emptyProjects': 'Adicione um projeto primeiro e depois mapeie as equipes do Linear.',
'settings.integrations.linear.mapping.emptyTeams': 'Este espaço do Linear não tem equipes.',
'settings.integrations.linear.mapping.loadFailed': 'Não foi possível carregar o mapeamento de projetos do Linear.',
'settings.integrations.linear.sessionComments.label': 'Comentários de sessão',
'settings.integrations.linear.sessionComments.info': 'Comenta na issue quando uma sessão começa, termina ou falha. Os comentários só são publicados se este servidor tiver um endereço público, para que o link abra a sessão para todos.',
'settings.integrations.linear.sessionComments.aria': 'Publicar comentários de status de sessão no Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Não foi possível carregar as configurações de comentários do Linear.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisão de issue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisão de issue',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados ao iniciar uma sessão a partir de uma issue do Linear: mensagem visível do usuário e instruções ocultas.',
},
uk: {
'settings.integrations.firstParty.title': 'Вбудовані інтеграції',
'settings.integrations.firstParty.info': 'Входи до сервісів, що входять до OpenChamber. Логін лишається на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються одним обліковим записом.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Підключіть Linear workspace до цього сервера OpenChamber. Можна кілька.',
'settings.integrations.linear.info': 'Підключіть один або кілька Linear workspace. OpenChamber зберігає входи на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються ними.',
'settings.integrations.linear.status.notConnected': 'Не підключено',
'settings.integrations.linear.status.connected': 'Підключено',
'settings.integrations.linear.status.waiting': 'Очікування',
'settings.integrations.linear.actions.connect': 'Підключити',
'settings.integrations.linear.actions.disconnect': 'Відключити',
'settings.integrations.linear.actions.addWorkspace': 'Додати workspace',
'settings.integrations.linear.actions.switchTo': 'Перемкнути на',
'settings.integrations.linear.label.otherWorkspaces': 'Інші workspace',
'settings.integrations.linear.flow.title': 'Очікування Linear',
'settings.integrations.linear.flow.description': 'Завершіть вхід у вкладці браузера, яка щойно відкрилась.',
'settings.integrations.linear.flow.waiting': 'Очікування авторизації…',
'settings.integrations.linear.toast.connected': 'Linear підключено',
'settings.integrations.linear.toast.disconnected': 'Linear відключено',
'settings.integrations.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace',
'settings.integrations.linear.toast.startConnectFailed': 'Не вдалося почати вхід у Linear',
'settings.integrations.linear.toast.disconnectFailed': 'Не вдалося відключити Linear',
'settings.integrations.linear.toast.authorizationFailed': 'Авторизація Linear завершилась за часом. Натисніть Підключити ще раз.',
'settings.integrations.linear.avatarAlt.withName': 'Аватар Linear для {name}',
'settings.integrations.linear.avatarAlt.fallback': 'Аватар Linear',
'settings.integrations.linear.label.unknownUser': 'Невідомий користувач',
'settings.integrations.linear.mapping.defaultProject': 'Проєкт за замовчуванням',
'settings.integrations.linear.mapping.defaultProject.info': 'Нові сесії з Linear issue використовують цей проєкт, якщо в команди issue немає власної прив’язки.',
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Немає',
'settings.integrations.linear.mapping.defaultProject.aria': 'Проєкт за замовчуванням для Linear issue',
'settings.integrations.linear.mapping.teams': 'Проєкти команд',
'settings.integrations.linear.mapping.teams.info': 'Не обов’язково. Issue з прив’язаної команди відкриється в цьому проєкті, а не в типовому.',
'settings.integrations.linear.mapping.teams.useDefault': 'Використати типовий',
'settings.integrations.linear.mapping.teams.aria': 'Проєкт для команди Linear {team}',
'settings.integrations.linear.mapping.emptyProjects': 'Спочатку додайте проєкт, потім прив’яжіть команди Linear.',
'settings.integrations.linear.mapping.emptyTeams': 'У цьому робочому просторі Linear немає команд.',
'settings.integrations.linear.mapping.loadFailed': 'Не вдалося завантажити прив’язку проєктів Linear.',
'settings.integrations.linear.sessionComments.label': 'Коментарі про сесію',
'settings.integrations.linear.sessionComments.info': 'Додає коментар до тікета, коли сесія починається, завершується або падає. Коментарі публікуються, лише якщо цей сервер має публічну адресу, щоб посилання відкривало сесію для всіх.',
'settings.integrations.linear.sessionComments.aria': 'Публікувати коментарі про стан сесії в Linear',
'settings.integrations.linear.sessionComments.loadFailed': 'Не вдалося завантажити налаштування коментарів Linear.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Огляд issue',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Огляд issue',
'settings.magicPrompts.page.group.linearIssueReview.description': 'Промпти для старту сесії з Linear issue: видиме повідомлення користувача та приховані інструкції.',
},
'zh-CN': {
'settings.integrations.firstParty.title': '内置集成',
'settings.integrations.firstParty.info': 'OpenChamber 自带服务的登录。登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它。',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': '将 Linear 工作区连接到此 OpenChamber 服务器。可以连接多个。',
'settings.integrations.linear.info': '连接一个或多个 Linear 工作区。OpenChamber 把登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它们。',
'settings.integrations.linear.status.notConnected': '未连接',
'settings.integrations.linear.status.connected': '已连接',
'settings.integrations.linear.status.waiting': '等待中',
'settings.integrations.linear.actions.connect': '连接',
'settings.integrations.linear.actions.disconnect': '断开',
'settings.integrations.linear.actions.addWorkspace': '添加工作区',
'settings.integrations.linear.actions.switchTo': '切换到',
'settings.integrations.linear.label.otherWorkspaces': '其他工作区',
'settings.integrations.linear.flow.title': '正在等待 Linear',
'settings.integrations.linear.flow.description': '请在刚打开的浏览器标签页中完成登录。',
'settings.integrations.linear.flow.waiting': '正在等待授权…',
'settings.integrations.linear.toast.connected': '已连接 Linear',
'settings.integrations.linear.toast.disconnected': '已断开 Linear',
'settings.integrations.linear.toast.workspaceSwitched': '已切换 Linear 工作区',
'settings.integrations.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区',
'settings.integrations.linear.toast.startConnectFailed': '无法开始 Linear 登录',
'settings.integrations.linear.toast.disconnectFailed': '无法断开 Linear',
'settings.integrations.linear.toast.authorizationFailed': 'Linear 授权已超时。请再次点击连接。',
'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 头像',
'settings.integrations.linear.avatarAlt.fallback': 'Linear 头像',
'settings.integrations.linear.label.unknownUser': '未知用户',
'settings.integrations.linear.mapping.defaultProject': '默认项目',
'settings.integrations.linear.mapping.defaultProject.info': '从 Linear Issue 新建的会话会使用此项目,除非该 Issue 所属团队有单独映射。',
'settings.integrations.linear.mapping.defaultProject.placeholder': '无',
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的默认项目',
'settings.integrations.linear.mapping.teams': '团队项目',
'settings.integrations.linear.mapping.teams.info': '可选。来自已映射团队的 Issue 会在该项目中打开,而不是默认项目。',
'settings.integrations.linear.mapping.teams.useDefault': '使用默认',
'settings.integrations.linear.mapping.teams.aria': 'Linear 团队 {team} 的项目',
'settings.integrations.linear.mapping.emptyProjects': '请先添加一个项目,再映射 Linear 团队。',
'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作区没有团队。',
'settings.integrations.linear.mapping.loadFailed': '无法加载 Linear 项目映射。',
'settings.integrations.linear.sessionComments.label': '会话评论',
'settings.integrations.linear.sessionComments.info': '会话开始、完成或失败时在议题下留言。仅当此服务器拥有公网地址时才发布,这样链接才能让所有人打开该会话。',
'settings.integrations.linear.sessionComments.aria': '将会话状态评论发布到 Linear',
'settings.integrations.linear.sessionComments.loadFailed': '无法加载 Linear 评论设置。',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 审查',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 审查',
'settings.magicPrompts.page.group.linearIssueReview.description': '从 Linear Issue 开始会话时使用的提示词:可见用户消息 + 隐藏指令。',
},
'zh-TW': {
'settings.integrations.firstParty.title': '內建整合',
'settings.integrations.firstParty.info': 'OpenChamber 內建服務的登入。登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它。',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': '將 Linear 工作區連線到此 OpenChamber 伺服器。可以連線多個。',
'settings.integrations.linear.info': '連接一個或多個 Linear 工作區。OpenChamber 把登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它們。',
'settings.integrations.linear.status.notConnected': '未連線',
'settings.integrations.linear.status.connected': '已連線',
'settings.integrations.linear.status.waiting': '等待中',
'settings.integrations.linear.actions.connect': '連線',
'settings.integrations.linear.actions.disconnect': '中斷連線',
'settings.integrations.linear.actions.addWorkspace': '新增工作區',
'settings.integrations.linear.actions.switchTo': '切換到',
'settings.integrations.linear.label.otherWorkspaces': '其他工作區',
'settings.integrations.linear.flow.title': '正在等待 Linear',
'settings.integrations.linear.flow.description': '請在剛開啟的瀏覽器分頁中完成登入。',
'settings.integrations.linear.flow.waiting': '正在等待授權…',
'settings.integrations.linear.toast.connected': '已連線 Linear',
'settings.integrations.linear.toast.disconnected': '已中斷 Linear',
'settings.integrations.linear.toast.workspaceSwitched': '已切換 Linear 工作區',
'settings.integrations.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區',
'settings.integrations.linear.toast.startConnectFailed': '無法開始 Linear 登入',
'settings.integrations.linear.toast.disconnectFailed': '無法中斷 Linear',
'settings.integrations.linear.toast.authorizationFailed': 'Linear 授權已逾時。請再次按連線。',
'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 頭像',
'settings.integrations.linear.avatarAlt.fallback': 'Linear 頭像',
'settings.integrations.linear.label.unknownUser': '未知使用者',
'settings.integrations.linear.mapping.defaultProject': '預設專案',
'settings.integrations.linear.mapping.defaultProject.info': '從 Linear Issue 新增的會話會使用此專案,除非該 Issue 所屬團隊有單獨對應。',
'settings.integrations.linear.mapping.defaultProject.placeholder': '無',
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的預設專案',
'settings.integrations.linear.mapping.teams': '團隊專案',
'settings.integrations.linear.mapping.teams.info': '選用。來自已對應團隊的 Issue 會在該專案中開啟,而不是預設專案。',
'settings.integrations.linear.mapping.teams.useDefault': '使用預設',
'settings.integrations.linear.mapping.teams.aria': 'Linear 團隊 {team} 的專案',
'settings.integrations.linear.mapping.emptyProjects': '請先新增一個專案,再對應 Linear 團隊。',
'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作區沒有團隊。',
'settings.integrations.linear.mapping.loadFailed': '無法載入 Linear 專案對應。',
'settings.integrations.linear.sessionComments.label': '工作階段留言',
'settings.integrations.linear.sessionComments.info': '工作階段開始、完成或失敗時在議題留言。僅在這台伺服器有公開位址時才發布,這樣連結才能讓所有人開啟該工作階段。',
'settings.integrations.linear.sessionComments.aria': '將工作階段狀態留言發布到 Linear',
'settings.integrations.linear.sessionComments.loadFailed': '無法載入 Linear 留言設定。',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 審查',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 審查',
'settings.magicPrompts.page.group.linearIssueReview.description': '從 Linear Issue 開始會話時使用的提示詞:可見使用者訊息 + 隱藏指令。',
},
tr: {
'settings.integrations.firstParty.title': 'Yerleşik entegrasyonlar',
'settings.integrations.firstParty.info': 'OpenChamber ile gelen hizmetlerin oturumları. Giriş bu bilgisayarda kalır; web, masaüstü ve eşlenen telefon paylaşır.',
'settings.integrations.linear.title': 'Linear',
'settings.integrations.linear.description': 'Linear çalışma alanlarını bu OpenChamber sunucusuna bağla.',
'settings.integrations.linear.info': 'Bir veya daha fazla Linear çalışma alanı bağla. OpenChamber girişleri bu bilgisayarda tutar; web, masaüstü ve eşlenen telefon paylaşır.',
'settings.integrations.linear.status.notConnected': 'Bağlı değil',
'settings.integrations.linear.status.connected': 'Bağlı',
'settings.integrations.linear.status.waiting': 'Bekleniyor',
'settings.integrations.linear.actions.connect': 'Bağlan',
'settings.integrations.linear.actions.disconnect': 'Bağlantıyı kes',
'settings.integrations.linear.actions.addWorkspace': 'Çalışma alanı ekle',
'settings.integrations.linear.actions.switchTo': 'Şuna geç',
'settings.integrations.linear.label.otherWorkspaces': 'Diğer çalışma alanları',
'settings.integrations.linear.flow.title': 'Linear bekleniyor',
'settings.integrations.linear.flow.description': 'Az önce açılan tarayıcı sekmesinde girişi bitir.',
'settings.integrations.linear.flow.waiting': 'Yetkilendirme bekleniyor…',
'settings.integrations.linear.toast.connected': 'Linear bağlandı',
'settings.integrations.linear.toast.disconnected': 'Linear bağlantısı kesildi',
'settings.integrations.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi',
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi',
'settings.integrations.linear.toast.startConnectFailed': 'Linear girişi başlatılamadı',
'settings.integrations.linear.toast.disconnectFailed': 'Linear bağlantısı kesilemedi',
'settings.integrations.linear.toast.authorizationFailed': "Linear yetkilendirmesi zaman aşımına uğradı. Yeniden bağlanmak için Bağlan'a bas.",
'settings.integrations.linear.avatarAlt.withName': '{name} için Linear avatarı',
'settings.integrations.linear.avatarAlt.fallback': 'Linear avatarı',
'settings.integrations.linear.label.unknownUser': 'Bilinmeyen kullanıcı',
'settings.integrations.linear.mapping.defaultProject': 'Varsayılan proje',
'settings.integrations.linear.mapping.defaultProject.info': "Linear issue'larından yeni session'lar, ekibin kendi eşlemesi yoksa bu projeyi kullanır.",
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Yok',
'settings.integrations.linear.mapping.defaultProject.aria': "Linear issue'ları için varsayılan proje",
'settings.integrations.linear.mapping.teams': 'Ekip projeleri',
'settings.integrations.linear.mapping.teams.info': 'İsteğe bağlı. Eşlenen bir ekipten gelen issue varsayılan yerine o projede açılır.',
'settings.integrations.linear.mapping.teams.useDefault': 'Varsayılanı kullan',
'settings.integrations.linear.mapping.teams.aria': 'Linear ekibi {team} için proje',
'settings.integrations.linear.mapping.emptyProjects': 'Önce bir proje ekle, sonra Linear ekiplerini ona eşle.',
'settings.integrations.linear.mapping.emptyTeams': 'Bu Linear çalışma alanında ekip yok.',
'settings.integrations.linear.mapping.loadFailed': 'Linear proje eşlemesi yüklenemedi.',
'settings.integrations.linear.sessionComments.label': 'Oturum yorumları',
'settings.integrations.linear.sessionComments.info': 'Bir oturum başladığında, bittiğinde veya başarısız olduğunda göreve yorum ekler. Bağlantının herkeste açılabilmesi için yorumlar yalnızca bu sunucunun genel bir adresi varsa gönderilir.',
'settings.integrations.linear.sessionComments.aria': 'Oturum durumu yorumlarını Lineara gönder',
'settings.integrations.linear.sessionComments.loadFailed': 'Linear yorum ayarları yüklenemedi.',
'settings.magicPrompts.sidebar.group.linear': 'Linear',
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue incelemesi',
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue incelemesi',
'settings.magicPrompts.page.group.linearIssueReview.description': "Linear issue'dan session başlatırken kullanılan prompt'lar: görünen kullanıcı mesajı + gizli talimatlar.",
},
} as const;
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
const requiredKeys = [
'chat.chatInput.actions.linkLinearIssue',
'chat.chatInput.linked.linearIssue.openInBrowserAria',
'chat.chatInput.linked.linearIssue.removeAria',
'session.linearIssuePicker.title',
'session.linearIssuePicker.description',
'session.linearIssuePicker.searchPlaceholder',
'session.linearIssuePicker.empty.notConnected',
'session.linearIssuePicker.empty.runtimeUnavailable',
'session.linearIssuePicker.empty.noIssuesFound',
'session.linearIssuePicker.empty.noOpenIssuesFound',
'session.linearIssuePicker.loading.issues',
'session.linearIssuePicker.loading.more',
'session.linearIssuePicker.actions.openSettings',
'session.linearIssuePicker.actions.useIssue',
'session.linearIssuePicker.actions.loadMore',
'session.linearIssuePicker.actions.openInLinearAria',
'session.linearIssuePicker.toast.loadMoreFailed',
'session.linearIssuePicker.toast.loadIssueDetailsFailed',
'session.linearIssuePicker.error.notConnected',
'session.linearIssuePicker.error.runtimeUnavailable',
'session.linearIssuePicker.error.issueNotFound',
'chat.chatInput.actions.newSessionFromLinearIssue',
'session.linearIssuePicker.title.createSession',
'session.linearIssuePicker.description.createSession',
'session.linearIssuePicker.error.noMappedProject',
'session.linearIssuePicker.error.noModelSelected',
'session.linearIssuePicker.toast.sendContextFailed',
'session.linearIssuePicker.toast.sessionCreated',
'session.linearIssuePicker.toast.startSessionFailed',
'session.linearIssuePicker.actions.sectionTitle',
'session.linearIssuePicker.actions.toggleWorktreeAria',
'session.linearIssuePicker.actions.createInWorktree',
'session.linearIssuePicker.actions.refresh',
'chat.workStatus.linkedIssues.openLinear',
'session.newWorktree.actions.startFromLinearIssue',
'session.newWorktree.fromLinearIssue',
'session.newWorktree.error.sendLinearContextFailed',
] as const;
describe('linear issue picker translations', () => {
test('provides every required key in every supported locale', () => {
const english = linearIssuePickerI18n.en;
for (const locale of locales) {
for (const key of requiredKeys) {
const value = linearIssuePickerI18n[locale][key];
expect(value).toBeTruthy();
if (locale !== 'en') {
expect(value).not.toBe(english[key]);
}
}
}
});
});
@@ -0,0 +1,471 @@
/** Linear issue picker / composer strings — merged into each locale's main dictionary. */
export const linearIssuePickerI18n = {
en: {
'chat.chatInput.actions.linkLinearIssue': 'Link Linear Issue',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Open issue in Linear',
'chat.chatInput.linked.linearIssue.removeAria': 'Remove linked Linear issue',
'session.linearIssuePicker.title': 'Link Linear Issue',
'session.linearIssuePicker.description': 'Select an issue from your connected Linear workspace.',
'session.linearIssuePicker.searchPlaceholder': 'Search by title, identifier, or Linear URL',
'session.linearIssuePicker.empty.notConnected': 'Linear is not connected. Connect it in Settings → Integrations.',
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear is not available in this app.',
'session.linearIssuePicker.empty.noIssuesFound': 'No issues found',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'No open issues found',
'session.linearIssuePicker.loading.issues': 'Loading issues...',
'session.linearIssuePicker.loading.more': 'Loading...',
'session.linearIssuePicker.actions.openSettings': 'Open settings',
'session.linearIssuePicker.actions.useIssue': 'Use {identifier}',
'session.linearIssuePicker.actions.loadMore': 'Load more',
'session.linearIssuePicker.actions.openInLinearAria': 'Open in Linear',
'session.linearIssuePicker.toast.loadMoreFailed': 'Failed to load more issues',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details',
'session.linearIssuePicker.error.notConnected': 'Linear not connected',
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear is not available in this app',
'session.linearIssuePicker.error.issueNotFound': 'Issue not found',
'chat.chatInput.actions.newSessionFromLinearIssue': 'New Session From Linear Issue',
'session.linearIssuePicker.title.createSession': 'New Session From Linear Issue',
'session.linearIssuePicker.description.createSession': 'Creates a session in the project mapped to this Linear team, with the issue as the first prompt.',
'session.linearIssuePicker.error.noMappedProject': 'Map this Linear team to a project in Settings → Integrations',
'session.linearIssuePicker.error.noModelSelected': 'No model selected',
'session.linearIssuePicker.toast.sendContextFailed': 'Failed to send issue context',
'session.linearIssuePicker.toast.sessionCreated': 'Session created from issue',
'session.linearIssuePicker.toast.startSessionFailed': 'Failed to start session',
'session.linearIssuePicker.actions.sectionTitle': 'Actions',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree',
'session.linearIssuePicker.actions.createInWorktree': 'Create in worktree',
'session.linearIssuePicker.actions.refresh': 'Refresh',
'chat.workStatus.linkedIssues.openLinear': 'Open {identifier} in Linear',
'session.newWorktree.actions.startFromLinearIssue': 'Start from Linear Issue',
'session.newWorktree.fromLinearIssue': 'From {identifier}: {title}',
'session.newWorktree.error.sendLinearContextFailed': 'Failed to send Linear context',
},
de: {
'chat.chatInput.actions.linkLinearIssue': 'Linear-Issue verknüpfen',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Issue in Linear öffnen',
'chat.chatInput.linked.linearIssue.removeAria': 'Verknüpftes Linear-Issue entfernen',
'session.linearIssuePicker.title': 'Linear-Issue verknüpfen',
'session.linearIssuePicker.description': 'Wähle ein Issue aus deinem verbundenen Linear-Workspace.',
'session.linearIssuePicker.searchPlaceholder': 'Nach Titel, Kennung oder Linear-URL suchen',
'session.linearIssuePicker.empty.notConnected': 'Linear ist nicht verbunden. Verbinde es unter Einstellungen → Integrationen.',
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar.',
'session.linearIssuePicker.empty.noIssuesFound': 'Keine Issues gefunden',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Keine offenen Issues gefunden',
'session.linearIssuePicker.loading.issues': 'Issues werden geladen...',
'session.linearIssuePicker.loading.more': 'Wird geladen...',
'session.linearIssuePicker.actions.openSettings': 'Einstellungen öffnen',
'session.linearIssuePicker.actions.useIssue': '{identifier} verwenden',
'session.linearIssuePicker.actions.loadMore': 'Mehr laden',
'session.linearIssuePicker.actions.openInLinearAria': 'In Linear öffnen',
'session.linearIssuePicker.toast.loadMoreFailed': 'Weitere Issues konnten nicht geladen werden',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue-Details konnten nicht geladen werden',
'session.linearIssuePicker.error.notConnected': 'Linear nicht verbunden',
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar',
'session.linearIssuePicker.error.issueNotFound': 'Issue nicht gefunden',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Neue Sitzung aus Linear-Issue',
'session.linearIssuePicker.title.createSession': 'Neue Sitzung aus Linear-Issue',
'session.linearIssuePicker.description.createSession': 'Erstellt eine Sitzung im diesem Linear-Team zugeordneten Projekt, mit dem Issue als erstem Prompt.',
'session.linearIssuePicker.error.noMappedProject': 'Ordne dieses Linear-Team in Einstellungen → Integrationen einem Projekt zu',
'session.linearIssuePicker.error.noModelSelected': 'Kein Modell ausgewählt',
'session.linearIssuePicker.toast.sendContextFailed': 'Issue-Kontext konnte nicht gesendet werden',
'session.linearIssuePicker.toast.sessionCreated': 'Sitzung aus Issue erstellt',
'session.linearIssuePicker.toast.startSessionFailed': 'Sitzung konnte nicht gestartet werden',
'session.linearIssuePicker.actions.sectionTitle': 'Aktionen',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Worktree umschalten',
'session.linearIssuePicker.actions.createInWorktree': 'In Worktree erstellen',
'session.linearIssuePicker.actions.refresh': 'Aktualisieren',
'chat.workStatus.linkedIssues.openLinear': '{identifier} in Linear öffnen',
'session.newWorktree.actions.startFromLinearIssue': 'Von Linear-Issue starten',
'session.newWorktree.fromLinearIssue': 'Von {identifier}: {title}',
'session.newWorktree.error.sendLinearContextFailed': 'Linear-Kontext konnte nicht gesendet werden',
},
fr: {
'chat.chatInput.actions.linkLinearIssue': 'Lier un ticket Linear',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Ouvrir le ticket dans Linear',
'chat.chatInput.linked.linearIssue.removeAria': 'Retirer le ticket Linear lié',
'session.linearIssuePicker.title': 'Lier un ticket Linear',
'session.linearIssuePicker.description': 'Choisissez un ticket dans votre espace Linear connecté.',
'session.linearIssuePicker.searchPlaceholder': 'Rechercher par titre, identifiant ou URL Linear',
'session.linearIssuePicker.empty.notConnected': 'Linear nest pas connecté. Connectez-le dans Paramètres → Intégrations.',
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear nest pas disponible dans cette application.',
'session.linearIssuePicker.empty.noIssuesFound': 'Aucun ticket trouvé',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Aucun ticket ouvert trouvé',
'session.linearIssuePicker.loading.issues': 'Chargement des tickets...',
'session.linearIssuePicker.loading.more': 'Chargement...',
'session.linearIssuePicker.actions.openSettings': 'Ouvrir les paramètres',
'session.linearIssuePicker.actions.useIssue': 'Utiliser {identifier}',
'session.linearIssuePicker.actions.loadMore': 'Charger plus',
'session.linearIssuePicker.actions.openInLinearAria': 'Ouvrir dans Linear',
'session.linearIssuePicker.toast.loadMoreFailed': 'Impossible de charger dautres tickets',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Impossible de charger les détails du ticket',
'session.linearIssuePicker.error.notConnected': 'Linear non connecté',
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear nest pas disponible dans cette application',
'session.linearIssuePicker.error.issueNotFound': 'Ticket introuvable',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nouvelle session depuis un ticket Linear',
'session.linearIssuePicker.title.createSession': 'Nouvelle session depuis un ticket Linear',
'session.linearIssuePicker.description.createSession': 'Crée une session dans le projet associé à cette équipe Linear, avec le ticket comme premier message.',
'session.linearIssuePicker.error.noMappedProject': 'Associez cette équipe Linear à un projet dans Paramètres → Intégrations',
'session.linearIssuePicker.error.noModelSelected': 'Aucun modèle sélectionné',
'session.linearIssuePicker.toast.sendContextFailed': 'Impossible denvoyer le contexte du ticket',
'session.linearIssuePicker.toast.sessionCreated': 'Session créée depuis le ticket',
'session.linearIssuePicker.toast.startSessionFailed': 'Impossible de démarrer la session',
'session.linearIssuePicker.actions.sectionTitle': 'Actions disponibles',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activer ou désactiver le worktree',
'session.linearIssuePicker.actions.createInWorktree': 'Créer dans un worktree',
'session.linearIssuePicker.actions.refresh': 'Actualiser',
'chat.workStatus.linkedIssues.openLinear': 'Ouvrir {identifier} dans Linear',
'session.newWorktree.actions.startFromLinearIssue': 'Démarrer depuis un ticket Linear',
'session.newWorktree.fromLinearIssue': 'Depuis {identifier} : {title}',
'session.newWorktree.error.sendLinearContextFailed': 'Impossible denvoyer le contexte Linear',
},
es: {
'chat.chatInput.actions.linkLinearIssue': 'Vincular issue de Linear',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue en Linear',
'chat.chatInput.linked.linearIssue.removeAria': 'Quitar issue de Linear vinculado',
'session.linearIssuePicker.title': 'Vincular issue de Linear',
'session.linearIssuePicker.description': 'Elige un issue del espacio de Linear conectado.',
'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador o URL de Linear',
'session.linearIssuePicker.empty.notConnected': 'Linear no está conectado. Conéctalo en Ajustes → Integraciones.',
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear no está disponible en esta aplicación.',
'session.linearIssuePicker.empty.noIssuesFound': 'No se encontraron issues',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'No se encontraron issues abiertos',
'session.linearIssuePicker.loading.issues': 'Cargando issues...',
'session.linearIssuePicker.loading.more': 'Cargando...',
'session.linearIssuePicker.actions.openSettings': 'Abrir ajustes',
'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}',
'session.linearIssuePicker.actions.loadMore': 'Cargar más',
'session.linearIssuePicker.actions.openInLinearAria': 'Abrir en Linear',
'session.linearIssuePicker.toast.loadMoreFailed': 'No se pudieron cargar más issues',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'No se pudieron cargar los detalles del issue',
'session.linearIssuePicker.error.notConnected': 'Linear no conectado',
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear no está disponible en esta aplicación',
'session.linearIssuePicker.error.issueNotFound': 'Issue no encontrado',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nueva sesión desde un issue de Linear',
'session.linearIssuePicker.title.createSession': 'Nueva sesión desde un issue de Linear',
'session.linearIssuePicker.description.createSession': 'Crea una sesión en el proyecto asignado a este equipo de Linear, con el issue como primer mensaje.',
'session.linearIssuePicker.error.noMappedProject': 'Asigna este equipo de Linear a un proyecto en Ajustes → Integraciones',
'session.linearIssuePicker.error.noModelSelected': 'Ningún modelo seleccionado',
'session.linearIssuePicker.toast.sendContextFailed': 'No se pudo enviar el contexto del issue',
'session.linearIssuePicker.toast.sessionCreated': 'Sesión creada desde el issue',
'session.linearIssuePicker.toast.startSessionFailed': 'No se pudo iniciar la sesión',
'session.linearIssuePicker.actions.sectionTitle': 'Acciones',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activar o desactivar worktree',
'session.linearIssuePicker.actions.createInWorktree': 'Crear en worktree',
'session.linearIssuePicker.actions.refresh': 'Actualizar',
'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} en Linear',
'session.newWorktree.actions.startFromLinearIssue': 'Empezar desde un issue de Linear',
'session.newWorktree.fromLinearIssue': 'Desde {identifier}: {title}',
'session.newWorktree.error.sendLinearContextFailed': 'No se pudo enviar el contexto de Linear',
},
ja: {
'chat.chatInput.actions.linkLinearIssue': 'Linear Issueをリンク',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'LinearでIssueを開く',
'chat.chatInput.linked.linearIssue.removeAria': 'リンクしたLinear Issueを削除',
'session.linearIssuePicker.title': 'Linear Issueをリンク',
'session.linearIssuePicker.description': '接続中のLinearワークスペースからIssueを選びます。',
'session.linearIssuePicker.searchPlaceholder': 'タイトル、識別子、またはLinearのURLで検索',
'session.linearIssuePicker.empty.notConnected': 'Linearは未接続です。設定 → 連携 で接続してください。',
'session.linearIssuePicker.empty.runtimeUnavailable': 'このアプリではLinearを利用できません。',
'session.linearIssuePicker.empty.noIssuesFound': 'Issueが見つかりません',
'session.linearIssuePicker.empty.noOpenIssuesFound': '未完了のIssueはありません',
'session.linearIssuePicker.loading.issues': 'Issueを読み込み中...',
'session.linearIssuePicker.loading.more': '読み込み中...',
'session.linearIssuePicker.actions.openSettings': '設定を開く',
'session.linearIssuePicker.actions.useIssue': '{identifier} を使う',
'session.linearIssuePicker.actions.loadMore': 'さらに読み込む',
'session.linearIssuePicker.actions.openInLinearAria': 'Linearで開く',
'session.linearIssuePicker.toast.loadMoreFailed': 'これ以上のIssueを読み込めませんでした',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issueの詳細を読み込めませんでした',
'session.linearIssuePicker.error.notConnected': 'Linear未接続',
'session.linearIssuePicker.error.runtimeUnavailable': 'このアプリではLinearを利用できません',
'session.linearIssuePicker.error.issueNotFound': 'Issueが見つかりません',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear Issueから新しいセッション',
'session.linearIssuePicker.title.createSession': 'Linear Issueから新しいセッション',
'session.linearIssuePicker.description.createSession': 'このLinearチームに割り当てたプロジェクトでセッションを作り、Issueを最初のプロンプトにします。',
'session.linearIssuePicker.error.noMappedProject': '設定 → 連携 でこのLinearチームをプロジェクトに割り当ててください',
'session.linearIssuePicker.error.noModelSelected': 'モデルが選択されていません',
'session.linearIssuePicker.toast.sendContextFailed': 'Issueのコンテキストを送信できませんでした',
'session.linearIssuePicker.toast.sessionCreated': 'Issueからセッションを作成しました',
'session.linearIssuePicker.toast.startSessionFailed': 'セッションを開始できませんでした',
'session.linearIssuePicker.actions.sectionTitle': '操作',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'ワークツリーを切り替え',
'session.linearIssuePicker.actions.createInWorktree': 'ワークツリーで作成',
'session.linearIssuePicker.actions.refresh': '更新',
'chat.workStatus.linkedIssues.openLinear': 'Linearで {identifier} を開く',
'session.newWorktree.actions.startFromLinearIssue': 'Linear Issueから開始',
'session.newWorktree.fromLinearIssue': '{identifier}: {title}から',
'session.newWorktree.error.sendLinearContextFailed': 'Linearのコンテキストを送信できませんでした',
},
'pt-BR': {
'chat.chatInput.actions.linkLinearIssue': 'Vincular issue do Linear',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue no Linear',
'chat.chatInput.linked.linearIssue.removeAria': 'Remover issue do Linear vinculada',
'session.linearIssuePicker.title': 'Vincular issue do Linear',
'session.linearIssuePicker.description': 'Selecione uma issue do espaço Linear conectado.',
'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador ou URL do Linear',
'session.linearIssuePicker.empty.notConnected': 'O Linear não está conectado. Conecte em Configurações → Integrações.',
'session.linearIssuePicker.empty.runtimeUnavailable': 'O Linear não está disponível neste app.',
'session.linearIssuePicker.empty.noIssuesFound': 'Nenhuma issue encontrada',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nenhuma issue aberta encontrada',
'session.linearIssuePicker.loading.issues': 'Carregando issues...',
'session.linearIssuePicker.loading.more': 'Carregando...',
'session.linearIssuePicker.actions.openSettings': 'Abrir configurações',
'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}',
'session.linearIssuePicker.actions.loadMore': 'Carregar mais',
'session.linearIssuePicker.actions.openInLinearAria': 'Abrir no Linear',
'session.linearIssuePicker.toast.loadMoreFailed': 'Não foi possível carregar mais issues',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Não foi possível carregar os detalhes da issue',
'session.linearIssuePicker.error.notConnected': 'Linear não conectado',
'session.linearIssuePicker.error.runtimeUnavailable': 'O Linear não está disponível neste app',
'session.linearIssuePicker.error.issueNotFound': 'Issue não encontrada',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nova sessão a partir de uma issue do Linear',
'session.linearIssuePicker.title.createSession': 'Nova sessão a partir de uma issue do Linear',
'session.linearIssuePicker.description.createSession': 'Cria uma sessão no projeto associado a esta equipe do Linear, com a issue como o primeiro prompt.',
'session.linearIssuePicker.error.noMappedProject': 'Associe esta equipe do Linear a um projeto em Configurações → Integrações',
'session.linearIssuePicker.error.noModelSelected': 'Nenhum modelo selecionado',
'session.linearIssuePicker.toast.sendContextFailed': 'Não foi possível enviar o contexto da issue',
'session.linearIssuePicker.toast.sessionCreated': 'Sessão criada a partir da issue',
'session.linearIssuePicker.toast.startSessionFailed': 'Não foi possível iniciar a sessão',
'session.linearIssuePicker.actions.sectionTitle': 'Ações',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Ativar ou desativar worktree',
'session.linearIssuePicker.actions.createInWorktree': 'Criar em worktree',
'session.linearIssuePicker.actions.refresh': 'Atualizar',
'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} no Linear',
'session.newWorktree.actions.startFromLinearIssue': 'Começar a partir de uma issue do Linear',
'session.newWorktree.fromLinearIssue': 'De {identifier}: {title}',
'session.newWorktree.error.sendLinearContextFailed': 'Não foi possível enviar o contexto do Linear',
},
uk: {
'chat.chatInput.actions.linkLinearIssue': 'Прив’язати Linear issue',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Відкрити issue в Linear',
'chat.chatInput.linked.linearIssue.removeAria': 'Прибрати прив’язаний Linear issue',
'session.linearIssuePicker.title': 'Прив’язати Linear issue',
'session.linearIssuePicker.description': 'Оберіть issue з підключеного робочого простору Linear.',
'session.linearIssuePicker.searchPlaceholder': 'Пошук за назвою, ідентифікатором або URL Linear',
'session.linearIssuePicker.empty.notConnected': 'Linear не підключено. Підключіть його в Налаштуваннях → Інтеграції.',
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear недоступний у цьому застосунку.',
'session.linearIssuePicker.empty.noIssuesFound': 'Issue не знайдено',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Відкритих issue немає',
'session.linearIssuePicker.loading.issues': 'Завантаження issue...',
'session.linearIssuePicker.loading.more': 'Завантаження...',
'session.linearIssuePicker.actions.openSettings': 'Відкрити налаштування',
'session.linearIssuePicker.actions.useIssue': 'Використати {identifier}',
'session.linearIssuePicker.actions.loadMore': 'Завантажити ще',
'session.linearIssuePicker.actions.openInLinearAria': 'Відкрити в Linear',
'session.linearIssuePicker.toast.loadMoreFailed': 'Не вдалося завантажити більше issue',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Не вдалося завантажити деталі issue',
'session.linearIssuePicker.error.notConnected': 'Linear не підключено',
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear недоступний у цьому застосунку',
'session.linearIssuePicker.error.issueNotFound': 'Issue не знайдено',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Нова сесія з Linear issue',
'session.linearIssuePicker.title.createSession': 'Нова сесія з Linear issue',
'session.linearIssuePicker.description.createSession': 'Створює сесію в проєкті, прив’язаному до цієї команди Linear, з issue як першим запитом.',
'session.linearIssuePicker.error.noMappedProject': 'Прив’яжіть цю команду Linear до проєкту в Налаштуваннях → Інтеграції',
'session.linearIssuePicker.error.noModelSelected': 'Модель не вибрано',
'session.linearIssuePicker.toast.sendContextFailed': 'Не вдалося надіслати контекст issue',
'session.linearIssuePicker.toast.sessionCreated': 'Сесію створено з issue',
'session.linearIssuePicker.toast.startSessionFailed': 'Не вдалося почати сесію',
'session.linearIssuePicker.actions.sectionTitle': 'Дії',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Перемкнути worktree',
'session.linearIssuePicker.actions.createInWorktree': 'Створити у worktree',
'session.linearIssuePicker.actions.refresh': 'Оновити',
'chat.workStatus.linkedIssues.openLinear': 'Відкрити {identifier} у Linear',
'session.newWorktree.actions.startFromLinearIssue': 'Почати з Linear issue',
'session.newWorktree.fromLinearIssue': 'З {identifier}: {title}',
'session.newWorktree.error.sendLinearContextFailed': 'Не вдалося надіслати контекст Linear',
},
ko: {
'chat.chatInput.actions.linkLinearIssue': 'Linear 이슈 연결',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Linear에서 이슈 열기',
'chat.chatInput.linked.linearIssue.removeAria': '연결된 Linear 이슈 제거',
'session.linearIssuePicker.title': 'Linear 이슈 연결',
'session.linearIssuePicker.description': '연결된 Linear 워크스페이스에서 이슈를 선택하세요.',
'session.linearIssuePicker.searchPlaceholder': '제목, 식별자 또는 Linear URL로 검색',
'session.linearIssuePicker.empty.notConnected': 'Linear가 연결되어 있지 않습니다. 설정 → 연동에서 연결하세요.',
'session.linearIssuePicker.empty.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다.',
'session.linearIssuePicker.empty.noIssuesFound': '이슈를 찾을 수 없습니다',
'session.linearIssuePicker.empty.noOpenIssuesFound': '열린 이슈가 없습니다',
'session.linearIssuePicker.loading.issues': '이슈를 불러오는 중...',
'session.linearIssuePicker.loading.more': '불러오는 중...',
'session.linearIssuePicker.actions.openSettings': '설정 열기',
'session.linearIssuePicker.actions.useIssue': '{identifier} 사용',
'session.linearIssuePicker.actions.loadMore': '더 보기',
'session.linearIssuePicker.actions.openInLinearAria': 'Linear에서 열기',
'session.linearIssuePicker.toast.loadMoreFailed': '이슈를 더 불러오지 못했습니다',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': '이슈 세부 정보를 불러오지 못했습니다',
'session.linearIssuePicker.error.notConnected': 'Linear가 연결되지 않음',
'session.linearIssuePicker.error.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다',
'session.linearIssuePicker.error.issueNotFound': '이슈를 찾을 수 없습니다',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear 이슈로 새 세션 만들기',
'session.linearIssuePicker.title.createSession': 'Linear 이슈로 새 세션 만들기',
'session.linearIssuePicker.description.createSession': '이 Linear 팀에 연결한 프로젝트에서 세션을 만들고, 이슈를 첫 프롬프트로 넣습니다.',
'session.linearIssuePicker.error.noMappedProject': '설정 → 연동에서 이 Linear 팀을 프로젝트에 연결하세요',
'session.linearIssuePicker.error.noModelSelected': '모델이 선택되지 않았습니다',
'session.linearIssuePicker.toast.sendContextFailed': '이슈 컨텍스트를 보내지 못했습니다',
'session.linearIssuePicker.toast.sessionCreated': '이슈에서 세션을 만들었습니다',
'session.linearIssuePicker.toast.startSessionFailed': '세션을 시작하지 못했습니다',
'session.linearIssuePicker.actions.sectionTitle': '작업',
'session.linearIssuePicker.actions.toggleWorktreeAria': '워크트리 전환',
'session.linearIssuePicker.actions.createInWorktree': '워크트리에서 만들기',
'session.linearIssuePicker.actions.refresh': '새로고침',
'chat.workStatus.linkedIssues.openLinear': 'Linear에서 {identifier} 열기',
'session.newWorktree.actions.startFromLinearIssue': 'Linear 이슈에서 시작',
'session.newWorktree.fromLinearIssue': '{identifier}: {title}에서',
'session.newWorktree.error.sendLinearContextFailed': 'Linear 컨텍스트를 보내지 못했습니다',
},
pl: {
'chat.chatInput.actions.linkLinearIssue': 'Powiąż zgłoszenie Linear',
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Otwórz zgłoszenie w Linear',
'chat.chatInput.linked.linearIssue.removeAria': 'Usuń powiązane zgłoszenie Linear',
'session.linearIssuePicker.title': 'Powiąż zgłoszenie Linear',
'session.linearIssuePicker.description': 'Wybierz zgłoszenie z połączonego obszaru Linear.',
'session.linearIssuePicker.searchPlaceholder': 'Szukaj po tytule, identyfikatorze lub adresie URL Linear',
'session.linearIssuePicker.empty.notConnected': 'Linear nie jest połączony. Połącz go w Ustawieniach → Integracje.',
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji.',
'session.linearIssuePicker.empty.noIssuesFound': 'Nie znaleziono zgłoszeń',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nie znaleziono otwartych zgłoszeń',
'session.linearIssuePicker.loading.issues': 'Ładowanie zgłoszeń...',
'session.linearIssuePicker.loading.more': 'Ładowanie...',
'session.linearIssuePicker.actions.openSettings': 'Otwórz ustawienia',
'session.linearIssuePicker.actions.useIssue': 'Użyj {identifier}',
'session.linearIssuePicker.actions.loadMore': 'Załaduj więcej',
'session.linearIssuePicker.actions.openInLinearAria': 'Otwórz w Linear',
'session.linearIssuePicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych zgłoszeń',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Nie udało się załadować szczegółów zgłoszenia',
'session.linearIssuePicker.error.notConnected': 'Linear niepołączony',
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji',
'session.linearIssuePicker.error.issueNotFound': 'Nie znaleziono zgłoszenia',
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nowa sesja ze zgłoszenia Linear',
'session.linearIssuePicker.title.createSession': 'Nowa sesja ze zgłoszenia Linear',
'session.linearIssuePicker.description.createSession': 'Tworzy sesję w projekcie przypisanym do tego zespołu Linear, ze zgłoszeniem jako pierwszym poleceniem.',
'session.linearIssuePicker.error.noMappedProject': 'Przypisz ten zespół Linear do projektu w Ustawieniach → Integracje',
'session.linearIssuePicker.error.noModelSelected': 'Nie wybrano modelu',
'session.linearIssuePicker.toast.sendContextFailed': 'Nie udało się wysłać kontekstu zgłoszenia',
'session.linearIssuePicker.toast.sessionCreated': 'Utworzono sesję ze zgłoszenia',
'session.linearIssuePicker.toast.startSessionFailed': 'Nie udało się rozpocząć sesji',
'session.linearIssuePicker.actions.sectionTitle': 'Czynności',
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Przełącz worktree',
'session.linearIssuePicker.actions.createInWorktree': 'Utwórz w worktree',
'session.linearIssuePicker.actions.refresh': 'Odśwież',
'chat.workStatus.linkedIssues.openLinear': 'Otwórz {identifier} w Linear',
'session.newWorktree.actions.startFromLinearIssue': 'Zacznij od zgłoszenia Linear',
'session.newWorktree.fromLinearIssue': 'Z {identifier}: {title}',
'session.newWorktree.error.sendLinearContextFailed': 'Nie udało się wysłać kontekstu Linear',
},
'zh-CN': {
'chat.chatInput.actions.linkLinearIssue': '关联 Linear Issue',
'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中打开 Issue',
'chat.chatInput.linked.linearIssue.removeAria': '移除已关联的 Linear Issue',
'session.linearIssuePicker.title': '关联 Linear Issue',
'session.linearIssuePicker.description': '从已连接的 Linear 工作区选择一个 Issue。',
'session.linearIssuePicker.searchPlaceholder': '按标题、标识符或 Linear 链接搜索',
'session.linearIssuePicker.empty.notConnected': '尚未连接 Linear。请到设置 → 集成 中连接。',
'session.linearIssuePicker.empty.runtimeUnavailable': '此应用中无法使用 Linear。',
'session.linearIssuePicker.empty.noIssuesFound': '未找到 Issue',
'session.linearIssuePicker.empty.noOpenIssuesFound': '没有未完成的 Issue',
'session.linearIssuePicker.loading.issues': '正在加载 Issue...',
'session.linearIssuePicker.loading.more': '正在加载...',
'session.linearIssuePicker.actions.openSettings': '打开设置',
'session.linearIssuePicker.actions.useIssue': '使用 {identifier}',
'session.linearIssuePicker.actions.loadMore': '加载更多',
'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中打开',
'session.linearIssuePicker.toast.loadMoreFailed': '无法加载更多 Issue',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': '无法加载 Issue 详情',
'session.linearIssuePicker.error.notConnected': '未连接 Linear',
'session.linearIssuePicker.error.runtimeUnavailable': '此应用中无法使用 Linear',
'session.linearIssuePicker.error.issueNotFound': '未找到 Issue',
'chat.chatInput.actions.newSessionFromLinearIssue': '从 Linear Issue 新建会话',
'session.linearIssuePicker.title.createSession': '从 Linear Issue 新建会话',
'session.linearIssuePicker.description.createSession': '在映射到此 Linear 团队的项目中创建会话,并以该 Issue 作为第一条提示。',
'session.linearIssuePicker.error.noMappedProject': '请在设置 → 集成 中将此 Linear 团队映射到一个项目',
'session.linearIssuePicker.error.noModelSelected': '未选择模型',
'session.linearIssuePicker.toast.sendContextFailed': '无法发送 Issue 上下文',
'session.linearIssuePicker.toast.sessionCreated': '已从 Issue 创建会话',
'session.linearIssuePicker.toast.startSessionFailed': '无法开始会话',
'session.linearIssuePicker.actions.sectionTitle': '操作',
'session.linearIssuePicker.actions.toggleWorktreeAria': '切换 worktree',
'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中创建',
'session.linearIssuePicker.actions.refresh': '刷新',
'chat.workStatus.linkedIssues.openLinear': '在 Linear 中打开 {identifier}',
'session.newWorktree.actions.startFromLinearIssue': '从 Linear Issue 开始',
'session.newWorktree.fromLinearIssue': '来自 {identifier}{title}',
'session.newWorktree.error.sendLinearContextFailed': '无法发送 Linear 上下文',
},
'zh-TW': {
'chat.chatInput.actions.linkLinearIssue': '關聯 Linear Issue',
'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中開啟 Issue',
'chat.chatInput.linked.linearIssue.removeAria': '移除已關聯的 Linear Issue',
'session.linearIssuePicker.title': '關聯 Linear Issue',
'session.linearIssuePicker.description': '從已連線的 Linear 工作區選擇一個 Issue。',
'session.linearIssuePicker.searchPlaceholder': '依標題、識別碼或 Linear 網址搜尋',
'session.linearIssuePicker.empty.notConnected': '尚未連線 Linear。請到設定 → 整合 中連線。',
'session.linearIssuePicker.empty.runtimeUnavailable': '此應用程式無法使用 Linear。',
'session.linearIssuePicker.empty.noIssuesFound': '找不到 Issue',
'session.linearIssuePicker.empty.noOpenIssuesFound': '沒有未完成的 Issue',
'session.linearIssuePicker.loading.issues': '正在載入 Issue...',
'session.linearIssuePicker.loading.more': '正在載入...',
'session.linearIssuePicker.actions.openSettings': '開啟設定',
'session.linearIssuePicker.actions.useIssue': '使用 {identifier}',
'session.linearIssuePicker.actions.loadMore': '載入更多',
'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中開啟',
'session.linearIssuePicker.toast.loadMoreFailed': '無法載入更多 Issue',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': '無法載入 Issue 詳細資料',
'session.linearIssuePicker.error.notConnected': '未連線 Linear',
'session.linearIssuePicker.error.runtimeUnavailable': '此應用程式無法使用 Linear',
'session.linearIssuePicker.error.issueNotFound': '找不到 Issue',
'chat.chatInput.actions.newSessionFromLinearIssue': '從 Linear Issue 新增會話',
'session.linearIssuePicker.title.createSession': '從 Linear Issue 新增會話',
'session.linearIssuePicker.description.createSession': '在對應到此 Linear 團隊的專案中建立會話,並以該 Issue 作為第一則提示。',
'session.linearIssuePicker.error.noMappedProject': '請在設定 → 整合 中將此 Linear 團隊對應到一個專案',
'session.linearIssuePicker.error.noModelSelected': '尚未選擇模型',
'session.linearIssuePicker.toast.sendContextFailed': '無法傳送 Issue 內容',
'session.linearIssuePicker.toast.sessionCreated': '已從 Issue 建立會話',
'session.linearIssuePicker.toast.startSessionFailed': '無法開始會話',
'session.linearIssuePicker.actions.sectionTitle': '操作',
'session.linearIssuePicker.actions.toggleWorktreeAria': '切換 worktree',
'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中建立',
'session.linearIssuePicker.actions.refresh': '重新整理',
'chat.workStatus.linkedIssues.openLinear': '在 Linear 中開啟 {identifier}',
'session.newWorktree.actions.startFromLinearIssue': '從 Linear Issue 開始',
'session.newWorktree.fromLinearIssue': '來自 {identifier}{title}',
'session.newWorktree.error.sendLinearContextFailed': '無法傳送 Linear 內容',
},
tr: {
'chat.chatInput.actions.linkLinearIssue': 'Linear Issue bağla',
'chat.chatInput.linked.linearIssue.openInBrowserAria': "Issue'u Linear'da aç",
'chat.chatInput.linked.linearIssue.removeAria': "Bağlı Linear issue'u kaldır",
'session.linearIssuePicker.title': 'Linear Issue bağla',
'session.linearIssuePicker.description': 'Bağlı Linear çalışma alanından bir issue seç.',
'session.linearIssuePicker.searchPlaceholder': "Başlığa, tanımlayıcıya veya Linear URL'sine göre ara",
'session.linearIssuePicker.empty.notConnected': "Linear bağlı değil. Ayarlar → Entegrasyonlar'dan bağla.",
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor.',
'session.linearIssuePicker.empty.noIssuesFound': 'Issue bulunamadı',
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Açık issue bulunamadı',
'session.linearIssuePicker.loading.issues': "Issue'lar yükleniyor...",
'session.linearIssuePicker.loading.more': 'Yükleniyor...',
'session.linearIssuePicker.actions.openSettings': 'Ayarları aç',
'session.linearIssuePicker.actions.useIssue': '{identifier} kullan',
'session.linearIssuePicker.actions.loadMore': 'Daha fazla yükle',
'session.linearIssuePicker.actions.openInLinearAria': "Linear'da aç",
'session.linearIssuePicker.toast.loadMoreFailed': 'Daha fazla issue yüklenemedi',
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue ayrıntıları yüklenemedi',
'session.linearIssuePicker.error.notConnected': 'Linear bağlı değil',
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor',
'session.linearIssuePicker.error.issueNotFound': 'Issue bulunamadı',
'chat.chatInput.actions.newSessionFromLinearIssue': "Linear Issue'dan yeni session",
'session.linearIssuePicker.title.createSession': "Linear Issue'dan yeni session",
'session.linearIssuePicker.description.createSession': 'Bu Linear ekibine eşlenen projede bir session oluşturur; ilk prompt issue olur.',
'session.linearIssuePicker.error.noMappedProject': "Bu Linear ekibini Ayarlar → Entegrasyonlar'da bir projeye eşle",
'session.linearIssuePicker.error.noModelSelected': 'Model seçilmedi',
'session.linearIssuePicker.toast.sendContextFailed': 'Issue bağlamı gönderilemedi',
'session.linearIssuePicker.toast.sessionCreated': "Issue'dan session oluşturuldu",
'session.linearIssuePicker.toast.startSessionFailed': 'Session başlatılamadı',
'session.linearIssuePicker.actions.sectionTitle': 'İşlemler',
'session.linearIssuePicker.actions.toggleWorktreeAria': "Worktree'yi aç veya kapat",
'session.linearIssuePicker.actions.createInWorktree': "Worktree'de oluştur",
'session.linearIssuePicker.actions.refresh': 'Yenile',
'chat.workStatus.linkedIssues.openLinear': "{identifier} issue'unu Linear'da aç",
'session.newWorktree.actions.startFromLinearIssue': "Linear Issue'dan başla",
'session.newWorktree.fromLinearIssue': '{identifier}: {title}',
'session.newWorktree.error.sendLinearContextFailed': 'Linear bağlamı gönderilemedi',
},
} as const;
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test';
import { linearPanelI18n } from './linear-panel.i18n';
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
const requiredKeys = [
'contextPanel.mode.linear',
'contextRail.surface.linear.description',
'contextPanel.linear.actions.backToList',
'contextPanel.linear.actions.startSession',
'contextPanel.linear.actions.closeIssue',
'contextPanel.linear.actions.closeSearch',
'contextPanel.linear.label.status',
'contextPanel.linear.label.team',
'contextPanel.linear.label.assignee',
'contextPanel.linear.label.unassigned',
'contextPanel.linear.label.priority',
'contextPanel.linear.label.labels',
'contextPanel.linear.priority.none',
'contextPanel.linear.priority.urgent',
'contextPanel.linear.priority.high',
'contextPanel.linear.priority.medium',
'contextPanel.linear.priority.low',
'contextPanel.linear.label.comments',
'contextPanel.linear.label.statusAria',
'contextPanel.linear.label.workspace',
'contextPanel.linear.label.workspaceAria',
'contextPanel.linear.filter.statusAria',
'contextPanel.linear.filter.assigneeAria',
'contextPanel.linear.filter.teamAria',
'contextPanel.linear.filter.priorityAria',
'contextPanel.linear.filter.searchAria',
'contextPanel.linear.filter.clear',
'contextPanel.linear.filter.clearAria',
'contextPanel.linear.filter.status.all',
'contextPanel.linear.filter.status.backlog',
'contextPanel.linear.filter.status.todo',
'contextPanel.linear.filter.status.started',
'contextPanel.linear.filter.status.inReview',
'contextPanel.linear.filter.status.completed',
'contextPanel.linear.filter.status.canceled',
'contextPanel.linear.filter.status.duplicate',
'contextPanel.linear.filter.assignee.any',
'contextPanel.linear.filter.assignee.me',
'contextPanel.linear.filter.team.all',
'contextPanel.linear.filter.priority.all',
'contextPanel.linear.empty.noDescription',
'contextPanel.linear.empty.noComments',
'contextPanel.linear.empty.noMatchingIssues',
'contextPanel.linear.loading.issue',
'contextPanel.linear.toast.statusUpdated',
'contextPanel.linear.toast.statusUpdateFailed',
'contextPanel.linear.toast.closeFailed',
'contextPanel.linear.toast.workspaceSwitched',
'contextPanel.linear.toast.workspaceSwitchFailed',
'contextPanel.linear.error.noCompletedState',
] as const;
const matchingEnglishAllowed = new Set<string>([
'contextPanel.mode.linear',
'contextPanel.linear.label.status',
'contextPanel.linear.label.team',
]);
describe('linear panel translations', () => {
test('provides every required key in every supported locale', () => {
const english = linearPanelI18n.en;
for (const locale of locales) {
for (const key of requiredKeys) {
const value = linearPanelI18n[locale][key];
expect(value).toBeTruthy();
if (locale !== 'en' && !matchingEnglishAllowed.has(key)) {
expect(value).not.toBe(english[key]);
}
}
}
});
});
@@ -0,0 +1,627 @@
/** Linear context-rail panel strings — merged into each locale's main dictionary. */
export const linearPanelI18n = {
en: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Browse Linear issues, change status, and start a session',
'contextPanel.linear.actions.backToList': 'Back to issues',
'contextPanel.linear.actions.startSession': 'Start session',
'contextPanel.linear.actions.closeIssue': 'Close issue',
'contextPanel.linear.actions.closeSearch': 'Close search',
'contextPanel.linear.label.status': 'Status',
'contextPanel.linear.label.team': 'Team',
'contextPanel.linear.label.assignee': 'Assignee',
'contextPanel.linear.label.unassigned': 'Unassigned',
'contextPanel.linear.label.priority': 'Priority',
'contextPanel.linear.label.labels': 'Labels',
'contextPanel.linear.priority.none': 'No priority',
'contextPanel.linear.priority.urgent': 'Urgent',
'contextPanel.linear.priority.high': 'High',
'contextPanel.linear.priority.medium': 'Medium',
'contextPanel.linear.priority.low': 'Low',
'contextPanel.linear.label.comments': 'Comments',
'contextPanel.linear.label.statusAria': 'Linear issue status',
'contextPanel.linear.label.workspace': 'Workspace',
'contextPanel.linear.label.workspaceAria': 'Linear workspace',
'contextPanel.linear.filter.statusAria': 'Filter issues by status',
'contextPanel.linear.filter.assigneeAria': 'Filter issues by assignee',
'contextPanel.linear.filter.teamAria': 'Filter issues by team',
'contextPanel.linear.filter.priorityAria': 'Filter issues by priority',
'contextPanel.linear.filter.searchAria': 'Search issues',
'contextPanel.linear.filter.clear': 'Clear',
'contextPanel.linear.filter.clearAria': 'Clear issue filters',
'contextPanel.linear.filter.status.all': 'All',
'contextPanel.linear.filter.status.backlog': 'Backlog',
'contextPanel.linear.filter.status.todo': 'To Do',
'contextPanel.linear.filter.status.started': 'In Progress',
'contextPanel.linear.filter.status.inReview': 'In Review',
'contextPanel.linear.filter.status.completed': 'Done',
'contextPanel.linear.filter.status.canceled': 'Canceled',
'contextPanel.linear.filter.status.duplicate': 'Duplicate',
'contextPanel.linear.filter.assignee.any': 'Anyone',
'contextPanel.linear.filter.assignee.me': 'Assigned to me',
'contextPanel.linear.filter.team.all': 'All teams',
'contextPanel.linear.filter.priority.all': 'All priorities',
'contextPanel.linear.empty.noDescription': 'No description',
'contextPanel.linear.empty.noComments': 'No comments',
'contextPanel.linear.empty.noMatchingIssues': 'No issues match these filters',
'contextPanel.linear.loading.issue': 'Loading issue…',
'contextPanel.linear.toast.statusUpdated': 'Issue status updated',
'contextPanel.linear.toast.statusUpdateFailed': 'Could not update issue status',
'contextPanel.linear.toast.closeFailed': 'Could not close issue',
'contextPanel.linear.toast.workspaceSwitched': 'Switched Linear workspace',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace',
'contextPanel.linear.error.noCompletedState': 'This team has no completed status',
},
de: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Linear-Issues durchsuchen, Status ändern und eine Sitzung starten',
'contextPanel.linear.actions.backToList': 'Zurück zu den Issues',
'contextPanel.linear.actions.startSession': 'Sitzung starten',
'contextPanel.linear.actions.closeIssue': 'Issue schließen',
'contextPanel.linear.actions.closeSearch': 'Suche schließen',
'contextPanel.linear.label.status': 'Status',
'contextPanel.linear.label.team': 'Team',
'contextPanel.linear.label.assignee': 'Zugewiesen',
'contextPanel.linear.label.unassigned': 'Nicht zugewiesen',
'contextPanel.linear.label.priority': 'Priorität',
'contextPanel.linear.label.labels': 'Kennzeichnungen',
'contextPanel.linear.priority.none': 'Keine Priorität',
'contextPanel.linear.priority.urgent': 'Dringend',
'contextPanel.linear.priority.high': 'Hoch',
'contextPanel.linear.priority.medium': 'Mittel',
'contextPanel.linear.priority.low': 'Niedrig',
'contextPanel.linear.label.comments': 'Kommentare',
'contextPanel.linear.label.statusAria': 'Status des Linear-Issues',
'contextPanel.linear.label.workspace': 'Arbeitsbereich',
'contextPanel.linear.label.workspaceAria': 'Linear-Workspace',
'contextPanel.linear.filter.statusAria': 'Issues nach Status filtern',
'contextPanel.linear.filter.assigneeAria': 'Issues nach Zuweisung filtern',
'contextPanel.linear.filter.teamAria': 'Issues nach Team filtern',
'contextPanel.linear.filter.priorityAria': 'Issues nach Priorität filtern',
'contextPanel.linear.filter.searchAria': 'Issues durchsuchen',
'contextPanel.linear.filter.clear': 'Zurücksetzen',
'contextPanel.linear.filter.clearAria': 'Issue-Filter zurücksetzen',
'contextPanel.linear.filter.status.all': 'Alle',
'contextPanel.linear.filter.status.backlog': 'Warteliste',
'contextPanel.linear.filter.status.todo': 'Zu tun',
'contextPanel.linear.filter.status.started': 'In Bearbeitung',
'contextPanel.linear.filter.status.inReview': 'In Prüfung',
'contextPanel.linear.filter.status.completed': 'Erledigt',
'contextPanel.linear.filter.status.canceled': 'Abgebrochen',
'contextPanel.linear.filter.status.duplicate': 'Duplikat',
'contextPanel.linear.filter.assignee.any': 'Alle Personen',
'contextPanel.linear.filter.assignee.me': 'Mir zugewiesen',
'contextPanel.linear.filter.team.all': 'Alle Teams',
'contextPanel.linear.filter.priority.all': 'Alle Prioritäten',
'contextPanel.linear.empty.noDescription': 'Keine Beschreibung',
'contextPanel.linear.empty.noComments': 'Keine Kommentare',
'contextPanel.linear.empty.noMatchingIssues': 'Keine Issues passen zu diesen Filtern',
'contextPanel.linear.loading.issue': 'Issue wird geladen…',
'contextPanel.linear.toast.statusUpdated': 'Issue-Status aktualisiert',
'contextPanel.linear.toast.statusUpdateFailed': 'Issue-Status konnte nicht aktualisiert werden',
'contextPanel.linear.toast.closeFailed': 'Issue konnte nicht geschlossen werden',
'contextPanel.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden',
'contextPanel.linear.error.noCompletedState': 'Dieses Team hat keinen erledigten Status',
},
fr: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Parcourir les tickets Linear, changer le statut et démarrer une session',
'contextPanel.linear.actions.backToList': 'Retour aux tickets',
'contextPanel.linear.actions.startSession': 'Démarrer une session',
'contextPanel.linear.actions.closeIssue': 'Fermer le ticket',
'contextPanel.linear.actions.closeSearch': 'Fermer la recherche',
'contextPanel.linear.label.status': 'Statut',
'contextPanel.linear.label.team': 'Équipe',
'contextPanel.linear.label.assignee': 'Assigné',
'contextPanel.linear.label.unassigned': 'Non assigné',
'contextPanel.linear.label.priority': 'Priorité',
'contextPanel.linear.label.labels': 'Libellés',
'contextPanel.linear.priority.none': 'Sans priorité',
'contextPanel.linear.priority.urgent': 'Urgente',
'contextPanel.linear.priority.high': 'Haute',
'contextPanel.linear.priority.medium': 'Moyenne',
'contextPanel.linear.priority.low': 'Basse',
'contextPanel.linear.label.comments': 'Commentaires',
'contextPanel.linear.label.statusAria': 'Statut du ticket Linear',
'contextPanel.linear.label.workspace': 'Espace de travail',
'contextPanel.linear.label.workspaceAria': 'Espace de travail Linear',
'contextPanel.linear.filter.statusAria': 'Filtrer les tickets par statut',
'contextPanel.linear.filter.assigneeAria': 'Filtrer les tickets par assigné',
'contextPanel.linear.filter.teamAria': 'Filtrer les tickets par équipe',
'contextPanel.linear.filter.priorityAria': 'Filtrer les tickets par priorité',
'contextPanel.linear.filter.searchAria': 'Rechercher des tickets',
'contextPanel.linear.filter.clear': 'Effacer',
'contextPanel.linear.filter.clearAria': 'Effacer les filtres des tickets',
'contextPanel.linear.filter.status.all': 'Tous',
'contextPanel.linear.filter.status.backlog': 'Liste dattente',
'contextPanel.linear.filter.status.todo': 'À faire',
'contextPanel.linear.filter.status.started': 'En cours',
'contextPanel.linear.filter.status.inReview': 'En revue',
'contextPanel.linear.filter.status.completed': 'Terminé',
'contextPanel.linear.filter.status.canceled': 'Annulé',
'contextPanel.linear.filter.status.duplicate': 'Doublon',
'contextPanel.linear.filter.assignee.any': 'Tout le monde',
'contextPanel.linear.filter.assignee.me': 'Assignés à moi',
'contextPanel.linear.filter.team.all': 'Toutes les équipes',
'contextPanel.linear.filter.priority.all': 'Toutes les priorités',
'contextPanel.linear.empty.noDescription': 'Aucune description',
'contextPanel.linear.empty.noComments': 'Aucun commentaire',
'contextPanel.linear.empty.noMatchingIssues': 'Aucun ticket ne correspond à ces filtres',
'contextPanel.linear.loading.issue': 'Chargement du ticket…',
'contextPanel.linear.toast.statusUpdated': 'Statut du ticket mis à jour',
'contextPanel.linear.toast.statusUpdateFailed': 'Impossible de mettre à jour le statut du ticket',
'contextPanel.linear.toast.closeFailed': 'Impossible de fermer le ticket',
'contextPanel.linear.toast.workspaceSwitched': 'Workspace Linear modifié',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear',
'contextPanel.linear.error.noCompletedState': 'Cette équipe na pas de statut terminé',
},
es: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Explora issues de Linear, cambia el estado e inicia una sesión',
'contextPanel.linear.actions.backToList': 'Volver a los issues',
'contextPanel.linear.actions.startSession': 'Iniciar sesión',
'contextPanel.linear.actions.closeIssue': 'Cerrar issue',
'contextPanel.linear.actions.closeSearch': 'Cerrar búsqueda',
'contextPanel.linear.label.status': 'Estado',
'contextPanel.linear.label.team': 'Equipo',
'contextPanel.linear.label.assignee': 'Asignado',
'contextPanel.linear.label.unassigned': 'Sin asignar',
'contextPanel.linear.label.priority': 'Prioridad',
'contextPanel.linear.label.labels': 'Etiquetas',
'contextPanel.linear.priority.none': 'Sin prioridad',
'contextPanel.linear.priority.urgent': 'Urgente',
'contextPanel.linear.priority.high': 'Alta',
'contextPanel.linear.priority.medium': 'Media',
'contextPanel.linear.priority.low': 'Baja',
'contextPanel.linear.label.comments': 'Comentarios',
'contextPanel.linear.label.statusAria': 'Estado del issue de Linear',
'contextPanel.linear.label.workspace': 'Espacio de trabajo',
'contextPanel.linear.label.workspaceAria': 'Espacio de trabajo de Linear',
'contextPanel.linear.filter.statusAria': 'Filtrar issues por estado',
'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por asignado',
'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipo',
'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridad',
'contextPanel.linear.filter.searchAria': 'Buscar issues',
'contextPanel.linear.filter.clear': 'Borrar',
'contextPanel.linear.filter.clearAria': 'Borrar filtros de issues',
'contextPanel.linear.filter.status.all': 'Todos',
'contextPanel.linear.filter.status.backlog': 'Lista de espera',
'contextPanel.linear.filter.status.todo': 'Por hacer',
'contextPanel.linear.filter.status.started': 'En curso',
'contextPanel.linear.filter.status.inReview': 'En revisión',
'contextPanel.linear.filter.status.completed': 'Hecho',
'contextPanel.linear.filter.status.canceled': 'Cancelado',
'contextPanel.linear.filter.status.duplicate': 'Duplicado',
'contextPanel.linear.filter.assignee.any': 'Cualquiera',
'contextPanel.linear.filter.assignee.me': 'Asignados a mí',
'contextPanel.linear.filter.team.all': 'Todos los equipos',
'contextPanel.linear.filter.priority.all': 'Todas las prioridades',
'contextPanel.linear.empty.noDescription': 'Sin descripción',
'contextPanel.linear.empty.noComments': 'Sin comentarios',
'contextPanel.linear.empty.noMatchingIssues': 'Ningún issue coincide con estos filtros',
'contextPanel.linear.loading.issue': 'Cargando issue…',
'contextPanel.linear.toast.statusUpdated': 'Estado del issue actualizado',
'contextPanel.linear.toast.statusUpdateFailed': 'No se pudo actualizar el estado del issue',
'contextPanel.linear.toast.closeFailed': 'No se pudo cerrar el issue',
'contextPanel.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado',
'contextPanel.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear',
'contextPanel.linear.error.noCompletedState': 'Este equipo no tiene un estado completado',
},
ja: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Linear の Issue を一覧し、状態を変えてセッションを開始します',
'contextPanel.linear.actions.backToList': 'Issue 一覧に戻る',
'contextPanel.linear.actions.startSession': 'セッションを開始',
'contextPanel.linear.actions.closeIssue': 'Issue をクローズ',
'contextPanel.linear.actions.closeSearch': '検索を閉じる',
'contextPanel.linear.label.status': '状態',
'contextPanel.linear.label.team': 'チーム',
'contextPanel.linear.label.assignee': '担当者',
'contextPanel.linear.label.unassigned': '未割り当て',
'contextPanel.linear.label.priority': '優先度',
'contextPanel.linear.label.labels': 'ラベル',
'contextPanel.linear.priority.none': '優先度なし',
'contextPanel.linear.priority.urgent': '緊急',
'contextPanel.linear.priority.high': '高',
'contextPanel.linear.priority.medium': '中',
'contextPanel.linear.priority.low': '低',
'contextPanel.linear.label.comments': 'コメント',
'contextPanel.linear.label.statusAria': 'Linear Issue の状態',
'contextPanel.linear.label.workspace': 'ワークスペース',
'contextPanel.linear.label.workspaceAria': 'Linear ワークスペース',
'contextPanel.linear.filter.statusAria': '状態で Issue を絞り込む',
'contextPanel.linear.filter.assigneeAria': '担当者で Issue を絞り込む',
'contextPanel.linear.filter.teamAria': 'チームで Issue を絞り込む',
'contextPanel.linear.filter.priorityAria': '優先度で Issue を絞り込む',
'contextPanel.linear.filter.searchAria': 'Issue を検索',
'contextPanel.linear.filter.clear': 'クリア',
'contextPanel.linear.filter.clearAria': 'Issue フィルターをクリア',
'contextPanel.linear.filter.status.all': 'すべて',
'contextPanel.linear.filter.status.backlog': 'バックログ',
'contextPanel.linear.filter.status.todo': '未着手',
'contextPanel.linear.filter.status.started': '進行中',
'contextPanel.linear.filter.status.inReview': 'レビュー中',
'contextPanel.linear.filter.status.completed': '完了',
'contextPanel.linear.filter.status.canceled': 'キャンセル',
'contextPanel.linear.filter.status.duplicate': '重複',
'contextPanel.linear.filter.assignee.any': '全員',
'contextPanel.linear.filter.assignee.me': '自分に割り当て',
'contextPanel.linear.filter.team.all': 'すべてのチーム',
'contextPanel.linear.filter.priority.all': 'すべての優先度',
'contextPanel.linear.empty.noDescription': '説明はありません',
'contextPanel.linear.empty.noComments': 'コメントはありません',
'contextPanel.linear.empty.noMatchingIssues': 'この条件に合う Issue はありません',
'contextPanel.linear.loading.issue': 'Issue を読み込み中…',
'contextPanel.linear.toast.statusUpdated': 'Issue の状態を更新しました',
'contextPanel.linear.toast.statusUpdateFailed': 'Issue の状態を更新できませんでした',
'contextPanel.linear.toast.closeFailed': 'Issue をクローズできませんでした',
'contextPanel.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした',
'contextPanel.linear.error.noCompletedState': 'このチームには完了ステータスがありません',
},
ko: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Linear 이슈를 보고 상태를 바꾼 뒤 세션을 시작합니다',
'contextPanel.linear.actions.backToList': '이슈 목록으로',
'contextPanel.linear.actions.startSession': '세션 시작',
'contextPanel.linear.actions.closeIssue': '이슈 닫기',
'contextPanel.linear.actions.closeSearch': '검색 닫기',
'contextPanel.linear.label.status': '상태',
'contextPanel.linear.label.team': '팀',
'contextPanel.linear.label.assignee': '담당자',
'contextPanel.linear.label.unassigned': '담당자 없음',
'contextPanel.linear.label.priority': '우선순위',
'contextPanel.linear.label.labels': '레이블',
'contextPanel.linear.priority.none': '우선순위 없음',
'contextPanel.linear.priority.urgent': '긴급',
'contextPanel.linear.priority.high': '높음',
'contextPanel.linear.priority.medium': '보통',
'contextPanel.linear.priority.low': '낮음',
'contextPanel.linear.label.comments': '댓글',
'contextPanel.linear.label.statusAria': 'Linear 이슈 상태',
'contextPanel.linear.label.workspace': '워크스페이스',
'contextPanel.linear.label.workspaceAria': 'Linear 워크스페이스',
'contextPanel.linear.filter.statusAria': '상태로 이슈 필터',
'contextPanel.linear.filter.assigneeAria': '담당자로 이슈 필터',
'contextPanel.linear.filter.teamAria': '팀으로 이슈 필터',
'contextPanel.linear.filter.priorityAria': '우선순위로 이슈 필터',
'contextPanel.linear.filter.searchAria': '이슈 검색',
'contextPanel.linear.filter.clear': '지우기',
'contextPanel.linear.filter.clearAria': '이슈 필터 지우기',
'contextPanel.linear.filter.status.all': '전체',
'contextPanel.linear.filter.status.backlog': '백로그',
'contextPanel.linear.filter.status.todo': '할 일',
'contextPanel.linear.filter.status.started': '작업 중',
'contextPanel.linear.filter.status.inReview': '검토 중',
'contextPanel.linear.filter.status.completed': '완료',
'contextPanel.linear.filter.status.canceled': '취소됨',
'contextPanel.linear.filter.status.duplicate': '중복',
'contextPanel.linear.filter.assignee.any': '누구나',
'contextPanel.linear.filter.assignee.me': '내게 할당됨',
'contextPanel.linear.filter.team.all': '모든 팀',
'contextPanel.linear.filter.priority.all': '모든 우선순위',
'contextPanel.linear.empty.noDescription': '설명이 없습니다',
'contextPanel.linear.empty.noComments': '댓글이 없습니다',
'contextPanel.linear.empty.noMatchingIssues': '이 필터에 맞는 이슈가 없습니다',
'contextPanel.linear.loading.issue': '이슈를 불러오는 중…',
'contextPanel.linear.toast.statusUpdated': '이슈 상태를 업데이트했습니다',
'contextPanel.linear.toast.statusUpdateFailed': '이슈 상태를 업데이트하지 못했습니다',
'contextPanel.linear.toast.closeFailed': '이슈를 닫지 못했습니다',
'contextPanel.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다',
'contextPanel.linear.error.noCompletedState': '이 팀에는 완료 상태가 없습니다',
},
pl: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Przeglądaj zgłoszenia Linear, zmieniaj status i uruchamiaj sesję',
'contextPanel.linear.actions.backToList': 'Wróć do zgłoszeń',
'contextPanel.linear.actions.startSession': 'Uruchom sesję',
'contextPanel.linear.actions.closeIssue': 'Zamknij zgłoszenie',
'contextPanel.linear.actions.closeSearch': 'Zamknij wyszukiwanie',
'contextPanel.linear.label.status': 'Status',
'contextPanel.linear.label.team': 'Zespół',
'contextPanel.linear.label.assignee': 'Przypisane',
'contextPanel.linear.label.unassigned': 'Nieprzypisane',
'contextPanel.linear.label.priority': 'Priorytet',
'contextPanel.linear.label.labels': 'Etykiety',
'contextPanel.linear.priority.none': 'Brak priorytetu',
'contextPanel.linear.priority.urgent': 'Pilne',
'contextPanel.linear.priority.high': 'Wysoki',
'contextPanel.linear.priority.medium': 'Średni',
'contextPanel.linear.priority.low': 'Niski',
'contextPanel.linear.label.comments': 'Komentarze',
'contextPanel.linear.label.statusAria': 'Status zgłoszenia Linear',
'contextPanel.linear.label.workspace': 'Obszar roboczy',
'contextPanel.linear.label.workspaceAria': 'Workspace Linear',
'contextPanel.linear.filter.statusAria': 'Filtruj zgłoszenia według statusu',
'contextPanel.linear.filter.assigneeAria': 'Filtruj zgłoszenia według osoby',
'contextPanel.linear.filter.teamAria': 'Filtruj zgłoszenia według zespołu',
'contextPanel.linear.filter.priorityAria': 'Filtruj zgłoszenia według priorytetu',
'contextPanel.linear.filter.searchAria': 'Szukaj zgłoszeń',
'contextPanel.linear.filter.clear': 'Wyczyść',
'contextPanel.linear.filter.clearAria': 'Wyczyść filtry zgłoszeń',
'contextPanel.linear.filter.status.all': 'Wszystkie',
'contextPanel.linear.filter.status.backlog': 'Lista oczekujących',
'contextPanel.linear.filter.status.todo': 'Do zrobienia',
'contextPanel.linear.filter.status.started': 'W toku',
'contextPanel.linear.filter.status.inReview': 'W recenzji',
'contextPanel.linear.filter.status.completed': 'Ukończone',
'contextPanel.linear.filter.status.canceled': 'Anulowane',
'contextPanel.linear.filter.status.duplicate': 'Duplikat',
'contextPanel.linear.filter.assignee.any': 'Ktokolwiek',
'contextPanel.linear.filter.assignee.me': 'Przypisane do mnie',
'contextPanel.linear.filter.team.all': 'Wszystkie zespoły',
'contextPanel.linear.filter.priority.all': 'Wszystkie priorytety',
'contextPanel.linear.empty.noDescription': 'Brak opisu',
'contextPanel.linear.empty.noComments': 'Brak komentarzy',
'contextPanel.linear.empty.noMatchingIssues': 'Żadne zgłoszenie nie pasuje do tych filtrów',
'contextPanel.linear.loading.issue': 'Wczytywanie zgłoszenia…',
'contextPanel.linear.toast.statusUpdated': 'Zaktualizowano status zgłoszenia',
'contextPanel.linear.toast.statusUpdateFailed': 'Nie udało się zaktualizować statusu zgłoszenia',
'contextPanel.linear.toast.closeFailed': 'Nie udało się zamknąć zgłoszenia',
'contextPanel.linear.toast.workspaceSwitched': 'Przełączono workspace Linear',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear',
'contextPanel.linear.error.noCompletedState': 'Ten zespół nie ma statusu ukończenia',
},
'pt-BR': {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Navegue pelas issues do Linear, altere o status e inicie uma sessão',
'contextPanel.linear.actions.backToList': 'Voltar às issues',
'contextPanel.linear.actions.startSession': 'Iniciar sessão',
'contextPanel.linear.actions.closeIssue': 'Fechar issue',
'contextPanel.linear.actions.closeSearch': 'Fechar pesquisa',
'contextPanel.linear.label.status': 'Status',
'contextPanel.linear.label.team': 'Equipe',
'contextPanel.linear.label.assignee': 'Responsável',
'contextPanel.linear.label.unassigned': 'Sem responsável',
'contextPanel.linear.label.priority': 'Prioridade',
'contextPanel.linear.label.labels': 'Etiquetas',
'contextPanel.linear.priority.none': 'Sem prioridade',
'contextPanel.linear.priority.urgent': 'Urgente',
'contextPanel.linear.priority.high': 'Alta',
'contextPanel.linear.priority.medium': 'Média',
'contextPanel.linear.priority.low': 'Baixa',
'contextPanel.linear.label.comments': 'Comentários',
'contextPanel.linear.label.statusAria': 'Status da issue do Linear',
'contextPanel.linear.label.workspace': 'Espaço de trabalho',
'contextPanel.linear.label.workspaceAria': 'Workspace do Linear',
'contextPanel.linear.filter.statusAria': 'Filtrar issues por status',
'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por responsável',
'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipe',
'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridade',
'contextPanel.linear.filter.searchAria': 'Pesquisar issues',
'contextPanel.linear.filter.clear': 'Limpar',
'contextPanel.linear.filter.clearAria': 'Limpar filtros de issues',
'contextPanel.linear.filter.status.all': 'Todas',
'contextPanel.linear.filter.status.backlog': 'Lista de espera',
'contextPanel.linear.filter.status.todo': 'A fazer',
'contextPanel.linear.filter.status.started': 'Em andamento',
'contextPanel.linear.filter.status.inReview': 'Em revisão',
'contextPanel.linear.filter.status.completed': 'Concluído',
'contextPanel.linear.filter.status.canceled': 'Cancelado',
'contextPanel.linear.filter.status.duplicate': 'Duplicado',
'contextPanel.linear.filter.assignee.any': 'Qualquer pessoa',
'contextPanel.linear.filter.assignee.me': 'Atribuídas a mim',
'contextPanel.linear.filter.team.all': 'Todas as equipes',
'contextPanel.linear.filter.priority.all': 'Todas as prioridades',
'contextPanel.linear.empty.noDescription': 'Sem descrição',
'contextPanel.linear.empty.noComments': 'Sem comentários',
'contextPanel.linear.empty.noMatchingIssues': 'Nenhuma issue corresponde a estes filtros',
'contextPanel.linear.loading.issue': 'Carregando issue…',
'contextPanel.linear.toast.statusUpdated': 'Status da issue atualizado',
'contextPanel.linear.toast.statusUpdateFailed': 'Não foi possível atualizar o status da issue',
'contextPanel.linear.toast.closeFailed': 'Não foi possível fechar a issue',
'contextPanel.linear.toast.workspaceSwitched': 'Workspace do Linear alterado',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear',
'contextPanel.linear.error.noCompletedState': 'Esta equipe não tem um status de concluído',
},
uk: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': 'Переглядайте Linear issue, змінюйте статус і запускайте сесію',
'contextPanel.linear.actions.backToList': 'Назад до issues',
'contextPanel.linear.actions.startSession': 'Почати сесію',
'contextPanel.linear.actions.closeIssue': 'Закрити issue',
'contextPanel.linear.actions.closeSearch': 'Закрити пошук',
'contextPanel.linear.label.status': 'Статус',
'contextPanel.linear.label.team': 'Команда',
'contextPanel.linear.label.assignee': 'Виконавець',
'contextPanel.linear.label.unassigned': 'Не призначено',
'contextPanel.linear.label.priority': 'Пріоритет',
'contextPanel.linear.label.labels': 'Мітки',
'contextPanel.linear.priority.none': 'Без пріоритету',
'contextPanel.linear.priority.urgent': 'Терміновий',
'contextPanel.linear.priority.high': 'Високий',
'contextPanel.linear.priority.medium': 'Середній',
'contextPanel.linear.priority.low': 'Низький',
'contextPanel.linear.label.comments': 'Коментарі',
'contextPanel.linear.label.statusAria': 'Статус Linear issue',
'contextPanel.linear.label.workspace': 'Робочий простір',
'contextPanel.linear.label.workspaceAria': 'Робочий простір Linear',
'contextPanel.linear.filter.statusAria': 'Фільтрувати issues за статусом',
'contextPanel.linear.filter.assigneeAria': 'Фільтрувати issues за виконавцем',
'contextPanel.linear.filter.teamAria': 'Фільтрувати issues за командою',
'contextPanel.linear.filter.priorityAria': 'Фільтрувати issues за пріоритетом',
'contextPanel.linear.filter.searchAria': 'Шукати issues',
'contextPanel.linear.filter.clear': 'Скинути',
'contextPanel.linear.filter.clearAria': 'Скинути фільтри issues',
'contextPanel.linear.filter.status.all': 'Усі',
'contextPanel.linear.filter.status.backlog': 'Беклог',
'contextPanel.linear.filter.status.todo': 'До виконання',
'contextPanel.linear.filter.status.started': 'У роботі',
'contextPanel.linear.filter.status.inReview': 'На перегляді',
'contextPanel.linear.filter.status.completed': 'Готово',
'contextPanel.linear.filter.status.canceled': 'Скасовано',
'contextPanel.linear.filter.status.duplicate': 'Дублікат',
'contextPanel.linear.filter.assignee.any': 'Будь-хто',
'contextPanel.linear.filter.assignee.me': 'Призначені мені',
'contextPanel.linear.filter.team.all': 'Усі команди',
'contextPanel.linear.filter.priority.all': 'Усі пріоритети',
'contextPanel.linear.empty.noDescription': 'Немає опису',
'contextPanel.linear.empty.noComments': 'Немає коментарів',
'contextPanel.linear.empty.noMatchingIssues': 'Немає issues за цими фільтрами',
'contextPanel.linear.loading.issue': 'Завантаження issue…',
'contextPanel.linear.toast.statusUpdated': 'Статус issue оновлено',
'contextPanel.linear.toast.statusUpdateFailed': 'Не вдалося оновити статус issue',
'contextPanel.linear.toast.closeFailed': 'Не вдалося закрити issue',
'contextPanel.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace',
'contextPanel.linear.error.noCompletedState': 'У цієї команди немає статусу completed',
},
'zh-CN': {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': '浏览 Linear Issue、更改状态并开始会话',
'contextPanel.linear.actions.backToList': '返回 Issue 列表',
'contextPanel.linear.actions.startSession': '开始会话',
'contextPanel.linear.actions.closeIssue': '关闭 Issue',
'contextPanel.linear.actions.closeSearch': '关闭搜索',
'contextPanel.linear.label.status': '状态',
'contextPanel.linear.label.team': '团队',
'contextPanel.linear.label.assignee': '负责人',
'contextPanel.linear.label.unassigned': '未指派',
'contextPanel.linear.label.priority': '优先级',
'contextPanel.linear.label.labels': '标签',
'contextPanel.linear.priority.none': '无优先级',
'contextPanel.linear.priority.urgent': '紧急',
'contextPanel.linear.priority.high': '高',
'contextPanel.linear.priority.medium': '中',
'contextPanel.linear.priority.low': '低',
'contextPanel.linear.label.comments': '评论',
'contextPanel.linear.label.statusAria': 'Linear Issue 状态',
'contextPanel.linear.label.workspace': '工作区',
'contextPanel.linear.label.workspaceAria': 'Linear 工作区',
'contextPanel.linear.filter.statusAria': '按状态筛选 Issue',
'contextPanel.linear.filter.assigneeAria': '按负责人筛选 Issue',
'contextPanel.linear.filter.teamAria': '按团队筛选 Issue',
'contextPanel.linear.filter.priorityAria': '按优先级筛选 Issue',
'contextPanel.linear.filter.searchAria': '搜索 Issue',
'contextPanel.linear.filter.clear': '清除',
'contextPanel.linear.filter.clearAria': '清除 Issue 筛选',
'contextPanel.linear.filter.status.all': '全部',
'contextPanel.linear.filter.status.backlog': '待办池',
'contextPanel.linear.filter.status.todo': '待办',
'contextPanel.linear.filter.status.started': '进行中',
'contextPanel.linear.filter.status.inReview': '审核中',
'contextPanel.linear.filter.status.completed': '已完成',
'contextPanel.linear.filter.status.canceled': '已取消',
'contextPanel.linear.filter.status.duplicate': '重复',
'contextPanel.linear.filter.assignee.any': '任何人',
'contextPanel.linear.filter.assignee.me': '指派给我',
'contextPanel.linear.filter.team.all': '所有团队',
'contextPanel.linear.filter.priority.all': '所有优先级',
'contextPanel.linear.empty.noDescription': '没有描述',
'contextPanel.linear.empty.noComments': '没有评论',
'contextPanel.linear.empty.noMatchingIssues': '没有符合这些筛选条件的 Issue',
'contextPanel.linear.loading.issue': '正在加载 Issue…',
'contextPanel.linear.toast.statusUpdated': '已更新 Issue 状态',
'contextPanel.linear.toast.statusUpdateFailed': '无法更新 Issue 状态',
'contextPanel.linear.toast.closeFailed': '无法关闭 Issue',
'contextPanel.linear.toast.workspaceSwitched': '已切换 Linear 工作区',
'contextPanel.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区',
'contextPanel.linear.error.noCompletedState': '此团队没有已完成状态',
},
'zh-TW': {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': '瀏覽 Linear Issue、變更狀態並開始會話',
'contextPanel.linear.actions.backToList': '返回 Issue 列表',
'contextPanel.linear.actions.startSession': '開始會話',
'contextPanel.linear.actions.closeIssue': '關閉 Issue',
'contextPanel.linear.actions.closeSearch': '關閉搜尋',
'contextPanel.linear.label.status': '狀態',
'contextPanel.linear.label.team': '團隊',
'contextPanel.linear.label.assignee': '負責人',
'contextPanel.linear.label.unassigned': '未指派',
'contextPanel.linear.label.priority': '優先級',
'contextPanel.linear.label.labels': '標籤',
'contextPanel.linear.priority.none': '無優先級',
'contextPanel.linear.priority.urgent': '緊急',
'contextPanel.linear.priority.high': '高',
'contextPanel.linear.priority.medium': '中',
'contextPanel.linear.priority.low': '低',
'contextPanel.linear.label.comments': '留言',
'contextPanel.linear.label.statusAria': 'Linear Issue 狀態',
'contextPanel.linear.label.workspace': '工作區',
'contextPanel.linear.label.workspaceAria': 'Linear 工作區',
'contextPanel.linear.filter.statusAria': '依狀態篩選 Issue',
'contextPanel.linear.filter.assigneeAria': '依負責人篩選 Issue',
'contextPanel.linear.filter.teamAria': '依團隊篩選 Issue',
'contextPanel.linear.filter.priorityAria': '依優先級篩選 Issue',
'contextPanel.linear.filter.searchAria': '搜尋 Issue',
'contextPanel.linear.filter.clear': '清除',
'contextPanel.linear.filter.clearAria': '清除 Issue 篩選',
'contextPanel.linear.filter.status.all': '全部',
'contextPanel.linear.filter.status.backlog': '待辦池',
'contextPanel.linear.filter.status.todo': '待辦',
'contextPanel.linear.filter.status.started': '進行中',
'contextPanel.linear.filter.status.inReview': '審核中',
'contextPanel.linear.filter.status.completed': '已完成',
'contextPanel.linear.filter.status.canceled': '已取消',
'contextPanel.linear.filter.status.duplicate': '重複',
'contextPanel.linear.filter.assignee.any': '任何人',
'contextPanel.linear.filter.assignee.me': '指派給我',
'contextPanel.linear.filter.team.all': '所有團隊',
'contextPanel.linear.filter.priority.all': '所有優先級',
'contextPanel.linear.empty.noDescription': '沒有描述',
'contextPanel.linear.empty.noComments': '沒有留言',
'contextPanel.linear.empty.noMatchingIssues': '沒有符合這些篩選條件的 Issue',
'contextPanel.linear.loading.issue': '正在載入 Issue…',
'contextPanel.linear.toast.statusUpdated': '已更新 Issue 狀態',
'contextPanel.linear.toast.statusUpdateFailed': '無法更新 Issue 狀態',
'contextPanel.linear.toast.closeFailed': '無法關閉 Issue',
'contextPanel.linear.toast.workspaceSwitched': '已切換 Linear 工作區',
'contextPanel.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區',
'contextPanel.linear.error.noCompletedState': '此團隊沒有已完成狀態',
},
tr: {
'contextPanel.mode.linear': 'Linear',
'contextRail.surface.linear.description': "Linear issue'larını incele, durumu değiştir ve session başlat",
'contextPanel.linear.actions.backToList': 'Issue listesine dön',
'contextPanel.linear.actions.startSession': 'Session başlat',
'contextPanel.linear.actions.closeIssue': "Issue'u kapat",
'contextPanel.linear.actions.closeSearch': 'Aramayı kapat',
'contextPanel.linear.label.status': 'Durum',
'contextPanel.linear.label.team': 'Ekip',
'contextPanel.linear.label.assignee': 'Atanan',
'contextPanel.linear.label.unassigned': 'Atanmamış',
'contextPanel.linear.label.priority': 'Öncelik',
'contextPanel.linear.label.labels': 'Etiketler',
'contextPanel.linear.priority.none': 'Öncelik yok',
'contextPanel.linear.priority.urgent': 'Acil',
'contextPanel.linear.priority.high': 'Yüksek',
'contextPanel.linear.priority.medium': 'Orta',
'contextPanel.linear.priority.low': 'Düşük',
'contextPanel.linear.label.comments': 'Yorumlar',
'contextPanel.linear.label.statusAria': 'Linear issue durumu',
'contextPanel.linear.label.workspace': 'Çalışma alanı',
'contextPanel.linear.label.workspaceAria': 'Linear çalışma alanı',
'contextPanel.linear.filter.statusAria': "Issue'ları duruma göre süz",
'contextPanel.linear.filter.assigneeAria': "Issue'ları atanan kişiye göre süz",
'contextPanel.linear.filter.teamAria': "Issue'ları ekibe göre süz",
'contextPanel.linear.filter.priorityAria': "Issue'ları önceliğe göre süz",
'contextPanel.linear.filter.searchAria': "Issue'larda ara",
'contextPanel.linear.filter.clear': 'Temizle',
'contextPanel.linear.filter.clearAria': "Issue filtrelerini temizle",
'contextPanel.linear.filter.status.all': 'Tümü',
'contextPanel.linear.filter.status.backlog': 'Bekleme listesi',
'contextPanel.linear.filter.status.todo': 'Yapılacak',
'contextPanel.linear.filter.status.started': 'Devam ediyor',
'contextPanel.linear.filter.status.inReview': 'İncelemede',
'contextPanel.linear.filter.status.completed': 'Bitti',
'contextPanel.linear.filter.status.canceled': 'İptal',
'contextPanel.linear.filter.status.duplicate': 'Yinelenen',
'contextPanel.linear.filter.assignee.any': 'Herkes',
'contextPanel.linear.filter.assignee.me': 'Bana atananlar',
'contextPanel.linear.filter.team.all': 'Tüm ekipler',
'contextPanel.linear.filter.priority.all': 'Tüm öncelikler',
'contextPanel.linear.empty.noDescription': 'Açıklama yok',
'contextPanel.linear.empty.noComments': 'Yorum yok',
'contextPanel.linear.empty.noMatchingIssues': 'Bu süzgeçlere uyan issue yok',
'contextPanel.linear.loading.issue': 'Issue yükleniyor…',
'contextPanel.linear.toast.statusUpdated': 'Issue durumu güncellendi',
'contextPanel.linear.toast.statusUpdateFailed': 'Issue durumu güncellenemedi',
'contextPanel.linear.toast.closeFailed': 'Issue kapatılamadı',
'contextPanel.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi',
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi',
'contextPanel.linear.error.noCompletedState': 'Bu ekibin tamamlandı durumu yok',
},
} as const;
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go',
@@ -818,25 +819,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Przełącz ulubiony model wstecz',
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Otwórz wybór modelu',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Przełącz ulubiony model w przód',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Przełącz zakładkę usług',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Przełącz motyw',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Przełącz agenta',
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Rozwiń pole wprowadzania',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Przełącz nawigator promptów',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Skup pole wprowadzania',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nowa sesja',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Poprzednia sesja',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Następna sesja',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Zmień nazwę bieżącej sesji',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Przełącz automatyczne zatwierdzanie',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Zamknij kartę sesji',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nowy szkic obszaru roboczego',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nowe okno Mini Chat',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Otwórz paletę poleceń',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Przełącz kartę sesji',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz panel kontekstu',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Dodaj zaznaczenie do czatu',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Przełącz pasek boczny',
@@ -849,7 +852,29 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ta kombinacja jest już używana przez inny skrót. Nadpisać i wyczyścić to inne przypisanie?',
'settings.openchamber.keyboardShortcuts.title': 'Skróty klawiszowe',
'settings.openchamber.keyboardShortcuts.tooltip': 'Przechwyć nową kombinację klawiszy, zapisz ją, a przypisania zostaną natychmiast zaktualizowane.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Został jednak zapisany.',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Nadal możesz go zapisać.',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Ta sekwencja współdzieli prefiks kontekstowy z działaniem {action}. Gdy jego kontekst jest aktywny, to działanie ma pierwszeństwo.',
'settings.openchamber.keyboardShortcuts.category.session': 'Sterowanie sesją',
'settings.openchamber.keyboardShortcuts.category.models': 'Modele i agenci',
'settings.openchamber.keyboardShortcuts.category.panels': 'Panele i narzędzia',
'settings.openchamber.keyboardShortcuts.category.navigation': 'Nawigacja',
'settings.openchamber.keyboardShortcuts.category.application': 'Aplikacja',
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj',
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Potwierdź',
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy, po najwyżej trzy klawisze każda. Po pierwszej odczekaj do 3 sekund na drugą kombinację. Wybierz Potwierdź, aby zastosować, lub Anuluj, aby odrzucić. Backspace usuwa ostatnią.',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Pierwsza kombinacja',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…',
'settings.openchamber.keyboardShortcuts.unassigned': 'Nieprzypisany',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'To koliduje z sekwencją używaną przez {action}. Wybierz inną kombinację.',
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Ta kombinacja jest już używana przez {action}.',
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Ta kombinacja koliduje z wbudowanym skrótem, którego nie można zastąpić.',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Otwórz wybór projektu szkicu',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Otwórz wybór worktree szkicu',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz ostatnie sesje',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Otwórz oś czasu rozmowy',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Wprowadzanie głosowe',
'settings.openchamber.opencodeCli.actions.browse': 'Przeglądaj',
'settings.openchamber.opencodeCli.actions.browseAria': 'Przeglądaj ścieżkę do pliku binarnego OpenCode',
'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'Restartowanie OpenCode...',
@@ -1042,6 +1067,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Włącz sprawdzanie pisowni w polach tekstowych',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Włącz sprawdzanie pisowni w polach tekstowych',
'settings.openchamber.visual.field.largeTextPaste': 'Wklejanie dużego tekstu',
'settings.openchamber.visual.field.largeTextPasteHint': 'Przy wklejaniu ponad około 2000 znaków lub 25 wierszy wybierz, czy dołączyć tekst jako plik, wkleić go w treści, czy pytać za każdym razem.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Zachowanie przy wklejaniu dużego tekstu',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Wklejanie dużego tekstu: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Pytaj za każdym razem',
'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Dołącz jako plik',
'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Wklej w treści',
'settings.openchamber.visual.field.fontSizePercentageAria': 'Procentowy rozmiar czcionki',
'settings.openchamber.visual.field.inputBarOffset': 'Przesunięcie paska wpisywania',
'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Podnieś pasek wpisywania, aby uniknąć zasłaniania przez systemowe elementy ekranu, takie jak pasek gestów.',
@@ -1212,7 +1244,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Podążaj za nową treścią podczas streamingu',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatycznie podążaj za nową treścią podczas streamowania odpowiedzi',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Podczas napływania odpowiedzi widok płynnie podąża za najnowszą treścią. Wyłącz, aby widok pozostał nieruchomy i przewijać ręcznie.',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Podczas napływania odpowiedzi widok płynnie podąża za najnowszą treścią. Wyłącz, aby widok pozostał nieruchomy i przewijać ręcznie; wysłanie wiadomości ze środka czatu również nie przesunie wtedy widoku.',
'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości',
'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki',
'settings.openchamber.visual.section.composer': 'Pole wiadomości',
@@ -2144,7 +2176,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': 'Serwer',
'settings.voice.page.provider.local': 'Lokalny',
'settings.voice.page.tooltip.sttLocal': 'Transkrypcja lokalna na serwerze OpenChamber. Modele pobierają się automatycznie; klucz API nie jest potrzebny.',
'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro, angielski). Model pobiera się automatycznie; klucz API nie jest potrzebny.',
'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro dla angielskiego; modele innych języków pobierane przy pierwszym użyciu). Klucz API nie jest potrzebny.',
'settings.voice.page.field.followTextLanguage': 'Dopasuj głos do języka tekstu',
'settings.voice.page.field.followTextLanguageAria': 'Dopasuj głos do języka tekstu',
'settings.voice.page.field.followTextLanguageInfo': 'Gdy odpowiedź jest w innym języku, używany jest głos dla tego języka: pasujący głos macOS albo lokalny model pobierany przy pierwszym użyciu.',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (angielski)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 języków europejskich)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base (wielojęzyczny)',
@@ -2189,5 +2224,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...linearIntegrationI18n.pl,
...thirdPartyIntegrationI18n.pl,
};
+92 -16
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './pl.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n.pl,
...linearPanelI18n.pl,
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
'terminalView.actions.restart': 'Uruchom terminal ponownie',
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
@@ -39,6 +43,7 @@ export const dict: Record<I18nKey, string> = {
'common.language.korean': 'Koreański',
'common.language.polish': 'Polski',
'common.language.japanese': 'Japoński',
'common.language.turkish': 'Turecki',
'common.revealPath.finder': 'Pokaż w Finderze',
'common.revealPath.fileExplorer': 'Otwórz w Eksploratorze plików',
'common.revealPath.fileManager': 'Otwórz w Menedżerze plików',
@@ -131,6 +136,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Zmień projekt',
'mobile.sessions.section.projects': 'Projekty',
'mobile.sessions.section.chats': 'Czaty',
'mobile.sessions.empty.noProjectsTitle': 'Brak projektów',
'mobile.sessions.empty.noProjectsDescription': 'Dodaj projekt, aby zacząć rozmawiać ze swoim kodem.',
'mobile.sessions.empty.noSessionsTitle': 'Brak sesji',
@@ -330,11 +336,33 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie',
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Przenieś do nowego worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Przenieś do worktree',
'sessions.sidebar.session.menu.newWorktree': 'Nowy worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Sesja została przeniesiona do nowego worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Nie udało się przenieść sesji do nowego worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi, przenosi niezacommitowane zmiany oraz tę sesję i jej podsesje.',
'sessions.sidebar.session.moveToWorktree.main': 'Główny worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Odświeżanie worktree...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Nie udało się wczytać worktree',
'sessions.sidebar.session.moveToWorktree.current': 'Bieżący worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sesję przeniesiono do worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Nie udało się przenieść sesji do worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Pokazuje istniejące worktree i opcję utworzenia nowego dla tej sesji.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi i przenosi tam tę sesję wraz z podsesjami. Jeśli w źródle są niezacommitowane zmiany, decydujesz, czy je przenieść.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Dostępne, gdy sesja jest bezczynna. Zatrzymaj bieżącą aktywność lub poczekaj na jej zakończenie.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Ta sesja jest już przenoszona do nowego worktree.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Źródło ma niezacommitowane zmiany',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Zmienione pliki w tym worktree: {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode śledzi te zmiany według katalogu, a nie sesji.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Przenosi tę sesję i jej podsesje, pozostawiając każdy plik źródłowy bez zmian.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Przenosi zmiany w katalogu sesji. Pliki niezacommitowane i nieśledzone opuszczają źródło po sukcesie.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Zmiany w indeksie pozostają w źródle i są kopiowane do miejsca docelowego.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Przeniesienie może się nie udać, gdy cel używa innej bazy Git.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Przenieś tylko sesję',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Przenieś wszystkie zmiany ze źródła',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Anuluj',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Nie udało się zweryfikować zmian w źródle. Żaden worktree ani sesja nie został zmieniony.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Cel nie mógł przyjąć zmian ze źródła. Sesja i zmiany w źródle nie zostały przeniesione. Spróbuj ponownie i wybierz Przenieś tylko sesję.',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Połączenie zostało zerwane, zanim cel potwierdził przeniesienie. Sesja mogła nie zostać przeniesiona, a niezatwierdzone zmiany mogą już być w docelowym worktree. Sprawdź go przed ponowną próbą.',
'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze',
@@ -522,7 +550,7 @@ export const dict: Record<I18nKey, string> = {
'multirun.launcher.attachments.attach': 'Dołącz',
'multirun.launcher.attachments.tooltip': 'Te same pliki wysłane do wszystkich uruchomień',
'multirun.launcher.models.label': 'Modele',
'multirun.launcher.models.info': 'Wybierz od 2 do {max} modeli. Ten sam model może być dodany wielokrotnie.',
'multirun.launcher.models.info': 'Wybierz 2 lub więcej modeli. Ten sam model może być dodany wielokrotnie.',
'multirun.launcher.toast.fileTooLarge': 'Plik "{fileName}" jest zbyt duży (max 10MB)',
'multirun.launcher.toast.attachFailed': 'Nie udało się dołączyć "{fileName}"',
'multirun.launcher.toast.attachedSingle': 'Dołączono {count} plik',
@@ -776,7 +804,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': 'Zadania',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało',
'chat.statusRow.aborted': 'Przerwane',
'chat.revertIndicator.redo': 'Ponów',
'chat.revertIndicator.redoAria': 'Ponów — przywróć cofnięte wiadomości',
'chat.revertPopover.title': 'Cofnięte',
@@ -853,7 +880,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji',
'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.',
'chat.container.sessionLoadError.description': 'Nie udało się pobrać rozmowy — serwer może być wyłączony lub nieosiągalny. Nic nie przepadło; spróbuj ponownie, gdy wróci.',
'chat.container.sessionLoadError.authDescription': 'Sesja wygasła, więc serwer odrzucił żądanie. Zaloguj się, a rozmowa się wczyta.',
'chat.container.sessionLoadError.retry': 'Spróbuj ponownie',
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
@@ -896,10 +924,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
'chat.textSelection.comment.attach': 'Załącz',
'chat.textSelection.actions.newSession': 'Nowa sesja',
'chat.textSelection.actions.addToNotes': 'Dodaj do notatek',
'chat.textSelection.title.addToCurrentChat': 'Dodaj do obecnego czatu',
'chat.textSelection.title.newSessionWithSelection': 'Utwórz nową sesję z zaznaczeniem',
'chat.textSelection.title.saveInsightToNotes': 'Zapisz zaznaczony tekst do notatek',
'chat.messageBody.actions.revertAria': 'Cofnij do tej wiadomości',
'chat.messageBody.actions.revert': 'Cofnij od tego miejsca',
@@ -1278,8 +1304,13 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.unsupportedAttachmentModalities': 'Model {model} nie obsługuje danych wejściowych {modalities} wymaganych przez {files}. Nadal możesz wysłać wiadomość, ale te załączniki mogą zostać zignorowane.',
'chat.chatInput.toast.attachmentsTooLarge': 'Załączniki są zbyt duże, aby je wysłać. Spróbuj zmniejszyć liczbę lub rozmiar obrazów.',
'chat.chatInput.toast.clipboardAttachFailed': 'Nie udało się dołączyć obrazu ze schowka',
'chat.chatInput.toast.clipboardTextAttachFailed': 'Nie udało się dołączyć wklejonego tekstu jako pliku',
'chat.chatInput.toast.largeTextPaste.title': 'Wykryto duży tekst',
'chat.chatInput.toast.largeTextPaste.attach': 'Dołącz jako plik',
'chat.chatInput.toast.largeTextPaste.inline': 'Wklej w treści',
'chat.chatInput.toast.compactFailed': 'Nie udało się skompaktować sesji',
'chat.chatInput.toast.messageSendFailed': 'Nie udało się wysłać wiadomości. Załączniki zostały przywrócone.',
'chat.chatInput.toast.noModelSelected': 'Wybierz dostawcę i model przed wysłaniem.',
'chat.chatInput.toast.openSessionFirst': 'Najpierw otwórz sesję',
'chat.chatInput.toast.reviewFailed': 'Nie udało się przejrzeć zmian',
'chat.chatInput.toast.planFeatureFailed': 'Nie udało się rozpocząć planowania funkcji',
@@ -1434,6 +1465,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.showRawJson': 'Pokaż surowy JSON',
'chat.toolPart.showFormattedJson': 'Pokaż sformatowany JSON',
'chat.toolPart.showNavigableJson': 'Pokaż nawigowalny JSON',
'chat.toolPart.openFile': 'Otwórz plik',
'chat.toolPart.openFileAtFirstChange': 'Otwórz plik przy pierwszej zmianie',
'chat.toolPart.openFileDiff': 'Otwórz różnice pliku',
'chat.toolPart.copyOutput': 'Kopiuj wyjście',
@@ -1452,6 +1484,15 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.showSessionSwitcher': 'Pokaż przełącznik sesji',
'commandPalette.item.toggleSidebar': 'Przełącz panel boczny',
'commandPalette.item.toggleTerminal': 'Przełącz terminal',
'commandPalette.item.cycleTheme': 'Przełącz motyw',
'commandPalette.item.showOpenCodeStatus': 'Pokaż status OpenCode',
'commandPalette.item.toggleMemoryDebug': 'Przełącz panel debugowania pamięci',
'commandPalette.item.pinSession': 'Przypnij lub odepnij sesję',
'commandPalette.item.copySessionId': 'Kopiuj ID sesji',
'commandPalette.item.openMultiRun': 'Otwórz panel multi-run',
'commandPalette.item.openArchive': 'Otwórz zarchiwizowane sesje',
'commandPalette.item.openNotes': 'Otwórz panel notatek',
'commandPalette.item.openTodos': 'Otwórz panel zadań',
'commandPalette.session.untitled': 'Nienazwana sesja',
'commandPalette.title': 'Paleta poleceń',
'contextPanel.actions.closePanel': 'Zamknij panel',
@@ -1469,6 +1510,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.pr': 'Pull Request',
'contextPanel.mode.preview': 'Podgląd',
'contextPanel.mode.browser': 'Przeglądarka',
'contextRail.configure.open': 'Konfiguruj panele',
'contextRail.configure.dialogTitle': 'Panele paska',
'contextRail.configure.dialogDescription': 'Wybierz, które panele pokazuje pasek. Ukryte panele zachowują dane i pozostają dostępne z palety poleceń.',
'contextRail.configure.showAll': 'Pokaż wszystkie',
'contextRail.configure.noneWarning': 'Wszystkie panele są ukryte.',
'contextRail.aria.rail': 'Powierzchnie panelu',
'contextPanel.editorEmpty.title': 'Brak otwartego pliku',
'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.',
@@ -1654,6 +1700,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.preview.upstreamUnreachable': 'Serwer deweloperski nie odpowiada.',
'contextPanel.preview.upstreamUnreachableHint': 'Upewnij się, że serwer deweloperski nadal działa, a następnie ponów próbę.',
'contextPanel.tab.closeTabAria': 'Zamknij kartę {label}',
'contextPanel.tab.menu.close': 'Zamknij',
'contextPanel.tab.menu.closeOthers': 'Zamknij pozostałe',
'contextPanel.tab.menu.closeToLeft': 'Zamknij karty po lewej',
'contextPanel.tab.menu.closeToRight': 'Zamknij karty po prawej',
'contextPanel.tab.menu.closeAll': 'Zamknij wszystkie karty',
'contextSidebar.actions.copied': 'Skopiowano',
'contextSidebar.actions.copy': 'Kopiuj',
'contextSidebar.actions.copyJson': 'Kopiuj JSON',
@@ -1843,6 +1894,7 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.selectorAria": "Wybierz tryb zmian",
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
'directoryExplorerDialog.actions.addSelected': 'Dodaj zaznaczone',
'directoryExplorerDialog.actions.addLocalProject': 'Dodaj projekt lokalny',
'directoryExplorerDialog.actions.adding': 'Dodawanie...',
'directoryExplorerDialog.actions.alreadyAdded': 'Już dodano',
@@ -1854,6 +1906,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.actions.openingFinder': 'Otwieranie...',
'directoryExplorerDialog.browse.addedBadge': 'Dodano',
'directoryExplorerDialog.browse.quickAdd': 'Dodaj',
'directoryExplorerDialog.browse.selectForAdd': 'Zaznacz do dodania',
'directoryExplorerDialog.browse.directories': 'Katalogi',
'directoryExplorerDialog.browse.empty': 'Brak pasujących katalogów.',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber potrzebuje dostępu do tego folderu.',
@@ -1872,6 +1925,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.title': 'Dodaj katalog projektu',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Aplikacja desktopowa nie mogła przyznać dostępu do pliku.',
'directoryExplorerDialog.toast.desktopDeniedAccess': 'Aplikacja desktopowa odmówiła dostępu do katalogu.',
'directoryExplorerDialog.toast.addedProjects': 'Dodano {count} projektów',
'directoryExplorerDialog.toast.failedToAddProject': 'Nie udało się dodać projektu',
'directoryExplorerDialog.toast.cloneUrlRequired': 'Wpisz URL repozytorium przed klonowaniem.',
'directoryExplorerDialog.toast.failedToOpenDirectory': 'Nie udało się otworzyć katalogu',
@@ -1915,6 +1969,12 @@ export const dict: Record<I18nKey, string> = {
'filesView.editor.enableLineWrap': 'Włącz zawijanie linii',
'filesView.editor.exitFullscreen': 'Wyjdź z pełnego ekranu',
'filesView.editor.findInFile': 'Znajdź w pliku',
'filesView.preview.find.placeholder': 'Szukaj w podglądzie',
'filesView.preview.find.nextAria': 'Następne dopasowanie',
'filesView.preview.find.previousAria': 'Poprzednie dopasowanie',
'filesView.preview.find.closeAria': 'Zamknij wyszukiwanie',
'filesView.preview.find.noMatches': 'Brak dopasowań',
'filesView.preview.find.countAria': '{current} z {total}',
'filesView.editor.fullscreen': 'Pełny ekran',
'filesView.editor.goToLine': 'Przejdź do linii',
'filesView.editor.htmlPreviewTitle': 'Podgląd HTML',
@@ -2383,6 +2443,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})',
'chat.recap.aria': 'Podsumowanie sesji',
'chat.recap.label': 'Podsumowanie:',
'chat.sessionError.title': 'OpenCode przerwał tę odpowiedź',
'chat.sessionError.noDetails': 'OpenCode nie podał szczegółów. Otwórz raport stanu (Ctrl/Cmd+Shift+L), aby zobaczyć ostatnie błędy.',
'chat.sessionError.noReply': 'OpenCode nie rozpoczął odpowiedzi na tę wiadomość.',
'chat.goal.dialog.titleCreate': 'Ustaw cel sesji',
'chat.goal.dialog.titleManage': 'Cel sesji',
'chat.goal.dialog.objectiveLabel': 'Cel',
@@ -2456,7 +2519,6 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.createNewSession': 'Utwórz nową sesję',
'helpDialog.item.createNewWorktreeDraft': 'Utwórz nowy szkic drzewa pracy',
'helpDialog.item.cycleAgent': 'Przełącz agenta (w polu czatu)',
'helpDialog.item.cycleServicesTab': 'Przełącz kartę usług',
'helpDialog.item.cycleTheme': 'Przełącz motyw (Jasny → Ciemny → Systemowy)',
'helpDialog.item.cycleThinkingVariant': 'Przełącz wariant myślenia (skrót globalny)',
'helpDialog.item.focusChatInput': 'Ustaw fokus na polu czatu',
@@ -2465,13 +2527,10 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.newWindow': 'Nowe okno (tylko desktop)',
'helpDialog.item.openCommandPalette': 'Otwórz paletę poleceń',
'helpDialog.item.openModelSelector': 'Otwórz selektor modeli',
'helpDialog.item.openRightSidebarFilesTab': 'Otwórz powierzchnię plików',
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
'helpDialog.item.openSettings': 'Otwórz ustawienia',
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
'helpDialog.item.switchSessionTab': 'Przełącz kartę sesji',
'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)',
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji',
'helpDialog.item.addSelectionToChat': 'Dodaj zaznaczenie do czatu',
@@ -2480,7 +2539,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.keyCombiner.or': 'lub',
'helpDialog.proTips.commandPalette': 'Użyj Palety poleceń ({shortcut}), aby szybko uzyskać dostęp do wszystkich akcji',
'helpDialog.proTips.recentSessions': '5 ostatnich sesji pojawia się w Palecie poleceń',
'helpDialog.proTips.themeCycling': 'Przełączanie motywów zapamiętuje twoje preferencje między sesjami',
'helpDialog.proTips.leaderSequences': 'Skróty dwustopniowe: naciśnij kombinację, potem drugi klawisz — Esc anuluje',
'helpDialog.proTips.title': 'Wskazówki:',
'helpDialog.section.interface': 'Interfejs',
'helpDialog.section.navigationCommands': 'Nawigacja i polecenia',
@@ -2494,7 +2553,7 @@ export const dict: Record<I18nKey, string> = {
'inlineComment.actions.save': 'Zapisz',
'inlineComment.actions.showLess': 'Show less',
'inlineComment.actions.showMore': 'Show more',
'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)',
'inlineComment.input.placeholder': 'Dodaj komentarz... ({shortcut}, aby zapisać)',
'inlineComment.input.placeholderShort': 'Dodaj komentarz...',
'inlineComment.range.lines': 'Lines {start}-{end}',
'inlineComment.toast.selectSessionToSave': 'Select a session to save comment',
@@ -2564,8 +2623,19 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.streaming.copy.copied': 'Skopiowano JSON debugowania streamingu',
'memoryDebugPanel.streaming.copy.failed': 'Nie udało się skopiować JSON',
'memoryDebugPanel.streaming.copy.hint': 'Kopiowanie eksportuje metryki streamingu zarówno UI, jak i VS Code w formacie JSON',
'memoryDebugPanel.requests.inFlight': 'W trakcie',
'memoryDebugPanel.requests.peak': 'Szczyt',
'memoryDebugPanel.requests.duration': 'Czas trwania',
'memoryDebugPanel.requests.totalRequests': 'Łączne żądania',
'memoryDebugPanel.requests.tracking': 'Śledzenie',
'memoryDebugPanel.requests.now': 'teraz',
'memoryDebugPanel.requests.noSamples': 'Brak żądań. Utrzymuj ten panel otwarty, aby rejestrować aktywność fetch.',
'memoryDebugPanel.requests.chartLabel': 'Żądania fetch w trakcie w czasie, szczyt {peak}',
'memoryDebugPanel.requests.windowHint': 'ostatnie {seconds}s',
'memoryDebugPanel.requests.percentileChartLabel': 'Percentyle wieku żądań w trakcie (p50, p90, p99, max) w czasie',
'memoryDebugPanel.tabs.memory': 'Pamięć',
'memoryDebugPanel.tabs.streaming': 'Streaming',
'memoryDebugPanel.tabs.requests': 'Żądania',
'memoryDebugPanel.title': 'Panel debugowania',
'memoryDebugPanel.tooltip.logCurrentState': 'Zaloguj bieżący stan pamięci do konsoli przeglądarki',
'openChamberLogo.aria.logo': 'Logo OpenChamber',
@@ -2817,8 +2887,6 @@ export const dict: Record<I18nKey, string> = {
'session.newWorktree.newSessionTitle': 'Nowa sesja',
'session.newWorktree.noBranchesFound': 'Nie znaleziono gałęzi',
'session.newWorktree.noMatchingBranches': 'Brak pasujących gałęzi',
'session.newWorktree.otherLocalBranches': 'Pozostałe lokalne gałęzie',
'session.newWorktree.otherRemoteBranches': 'Pozostałe zdalne gałęzie',
'session.newWorktree.prNumber': 'PR #{number}',
'session.newWorktree.remoteBranches': 'Zdalne gałęzie',
'session.newWorktree.resetToMatchBranchName': 'Zresetuj do nazwy zgodnej z gałęzią',
@@ -2859,6 +2927,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.locked.passwordDescription': 'Ta sesja jest chroniona hasłem.',
'sessionAuth.locked.tunnelDescription': 'Otwórz ten tunel za pomocą jednorazowego linku połączenia z aplikacji desktopowej.',
'sessionAuth.locked.tunnelTitle': 'Wymagany dostęp przez tunel',
'sessionAuth.expired.banner': 'Sesja wygasła — zaloguj się, aby kontynuować.',
'sessionAuth.expired.loginAction': 'Zaloguj się',
'sessionAuth.expired.sendBlocked': 'Sesja wygasła — zaloguj się, aby wysyłać wiadomości.',
'sessionAuth.locked.unlockTitle': 'Odblokuj OpenChamber',
'sessionAuth.password.placeholder': 'Wpisz hasło',
'sessionAuth.toast.passkeyAdded': 'Dodano klucz dostępu',
@@ -2958,6 +3029,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.actions.restartToUpdate': 'Uruchom ponownie, aby zaktualizować',
'updateDialog.actions.updateNow': 'Aktualizuj teraz',
'updateDialog.error.takingLonger': 'Aktualizacja trwa dłużej niż oczekiwano. Poczekaj chwilę i odśwież albo uruchom: openchamber update',
'updateDialog.error.signatureRejected': 'Pobrana aktualizacja została odrzucona: jej podpis kodu nie pasuje do tej instalacji. Zwykle oznacza to, że uruchomiona kopia nie pochodzi z oficjalnego podpisanego wydania. Zainstaluj OpenChamber z oficjalnego wydania i zaktualizuj ponownie.',
'updateDialog.error.updaterDisabled': 'Aktualizator zatrzymał się po nieudanej instalacji. Zamknij OpenChamber, otwórz go ponownie i spróbuj zaktualizować jeszcze raz.',
'updateDialog.error.restartFailed': 'Nie udało się uruchomić ponownie, aby zainstalować aktualizację.',
'updateDialog.error.restartUnavailable': 'Instalacja aktualizacji wymaga aplikacji desktopowej OpenChamber.',
'updateDialog.error.updateFailed': 'Aktualizacja nie powiodła się',
'mobileUpdate.toast.available.title': 'Dostępna aktualizacja OpenChamber',
'mobileUpdate.toast.available.description': 'Wersja {version} jest gotowa dla Androida.',
@@ -3120,9 +3195,10 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premium': 'Premium Interactions',
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'quota.window.premiumInteractions': 'Kredyty AI',
'chat.workStatus.ariaLabel': 'Stan pracy',
'chat.workStatus.context.label': 'Kontekst',
'chat.workStatus.cost.breakdown': 'Sesja {session} · Podagenci {subagents}',
'chat.workStatus.git.changedFileSingle': 'Zmieniono {count} plik',
'chat.workStatus.git.changedFilePlural': 'Zmieniono {count} plików',
'chat.workStatus.pr.untitled': 'Pull request bez tytułu',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go',
@@ -1101,7 +1102,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinação já está sendo usada por outro atalho. Sobrescrever e limpar essa outra atribuição?",
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Pressione as teclas...",
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura um atalho primeiro.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, ele será salvo.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, você pode salvá-lo.",
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir para linha (editor de arquivos)",
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos",
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Focar entrada",
@@ -1110,18 +1111,20 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher terminal",
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Adicionar seleção ao chat",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar ou ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Alternar aba de sessão",
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
"settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Sessão anterior",
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Próxima sessão",
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renomear sessão atual",
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprovação automática",
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Fechar aba da sessão",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
"settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atalhos de teclado",
"settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar painel de plano de contexto",
"settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar ou ocultar menu de serviços",
"settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Alternar aba de serviços",
"settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Alternar tema",
"settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Alternar agente",
"settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Próximo modelo favorito",
@@ -1130,6 +1133,27 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada",
"settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir linha do tempo da conversa",
"settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar ou ocultar navegador de prompts",
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta sequência compartilha um prefixo contextual com {action}. Quando esse contexto está ativo, essa ação tem prioridade.",
"settings.openchamber.keyboardShortcuts.category.session": "Controles de sessão",
"settings.openchamber.keyboardShortcuts.category.models": "Modelos e agentes",
"settings.openchamber.keyboardShortcuts.category.panels": "Painéis e ferramentas",
"settings.openchamber.keyboardShortcuts.category.navigation": "Navegação",
"settings.openchamber.keyboardShortcuts.category.application": "Aplicação",
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas, com no máximo três teclas em cada uma. Após a primeira, aguarde até 3 segundos por uma segunda combinação. Use Confirmar para aplicar ou Cancelar para descartar. Backspace remove a última.",
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primeira combinação",
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
"settings.openchamber.keyboardShortcuts.unassigned": "Não atribuído",
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Isto entra em conflito com a sequência usada por {action}. Escolha outra combinação.",
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinação já é usada por {action}.",
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinação entra em conflito com um atalho integrado, que não pode ser substituído.",
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir seletor de projeto do rascunho",
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir seletor de worktree do rascunho",
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sessões recentes",
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada por voz",
"settings.projects.sidebar.total": "Total {count}",
"settings.projects.sidebar.actions.addProject": "Adicionar projeto",
"settings.projects.page.empty.noProjects": "Não há projetos disponíveis.",
@@ -1815,7 +1839,10 @@ export const settingsDict = {
"settings.voice.page.provider.server": "Servidor",
"settings.voice.page.provider.local": "Local",
"settings.voice.page.tooltip.sttLocal": "Transcrição local no servidor do OpenChamber. Os modelos são baixados automaticamente; não é necessária chave de API.",
"settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro, inglês). O modelo é baixado automaticamente; não é necessária chave de API.",
"settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro para inglês; modelos de outros idiomas são baixados no primeiro uso). Não requer chave de API.",
"settings.voice.page.field.followTextLanguage": "Ajustar a voz ao idioma do texto",
"settings.voice.page.field.followTextLanguageAria": "Ajustar a voz ao idioma do texto",
"settings.voice.page.field.followTextLanguageInfo": "Se uma resposta estiver em outro idioma, uma voz desse idioma é usada: uma voz do macOS correspondente ou um modelo local baixado no primeiro uso.",
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglês)",
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeus)",
"settings.voice.page.stt.model.whisperBase": "Whisper base (multilíngue)",
@@ -1909,7 +1936,7 @@ export const settingsDict = {
"settings.openchamber.visual.section.streaming": "Streaming",
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir o novo conteúdo durante o streaming",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automaticamente o novo conteúdo enquanto uma resposta é transmitida",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Enquanto uma resposta chega, a visualização acompanha o conteúdo mais recente. Desative para manter a visualização parada e rolar manualmente.",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Enquanto uma resposta chega, a visualização acompanha o conteúdo mais recente. Desative para manter a visualização parada e rolar manualmente; enviar uma mensagem do meio da conversa também deixará a visualização onde está.",
"settings.openchamber.visual.section.messageAppearance": "Aparência das mensagens",
"settings.openchamber.visual.section.toolsAndFiles": "Ferramentas e arquivos",
"settings.openchamber.visual.section.composer": "Campo de mensagem",
@@ -2039,6 +2066,13 @@ export const settingsDict = {
"settings.openchamber.visual.field.persistDraftMessages": "Manter rascunhos de mensagens",
"settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Ativar ortografia em campos de texto",
"settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Ativar ortografia em campos de texto",
"settings.openchamber.visual.field.largeTextPaste": "Colagem de texto grande",
"settings.openchamber.visual.field.largeTextPasteHint": "Ao colar mais de cerca de 2.000 caracteres ou 25 linhas, escolha anexar o texto como arquivo, colar no corpo da mensagem ou perguntar sempre.",
"settings.openchamber.visual.field.largeTextPasteAria": "Comportamento da colagem de texto grande",
"settings.openchamber.visual.field.largeTextPasteOptionAria": "Colagem de texto grande: {option}",
"settings.openchamber.visual.option.largeTextPaste.ask.label": "Perguntar sempre",
"settings.openchamber.visual.option.largeTextPaste.attach.label": "Anexar como arquivo",
"settings.openchamber.visual.option.largeTextPaste.inline.label": "Colar no corpo",
"settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar relatórios anônimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar relatórios anônimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Ajuda-nos a entender quais versões do aplicativo são usadas ativamente para priorizar melhorias. Coletamos apenas a versão do aplicativo, a plataforma e o ambiente de execução; não coletamos dados pessoais nem código.",
@@ -2197,5 +2231,6 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
...linearIntegrationI18n['pt-BR'],
...thirdPartyIntegrationI18n['pt-BR'],
} as const;
+92 -16
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './pt-BR.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n['pt-BR'],
...linearPanelI18n['pt-BR'],
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
@@ -38,6 +42,7 @@ export const dict: Record<I18nKey, string> = {
"common.language.korean": "Coreano",
"common.language.polish": "Polonês",
"common.language.japanese": "Japonês",
"common.language.turkish": "Turco",
"common.revealPath.finder": "Mostrar no Finder",
"common.revealPath.fileExplorer": "Abrir no File Explorer",
"common.revealPath.fileManager": "Abrir no gerenciador de arquivos",
@@ -130,6 +135,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.section.worktrees": "Worktrees",
"mobile.sessions.section.otherProjects": "Trocar de projeto",
"mobile.sessions.section.projects": "Projetos",
"mobile.sessions.section.chats": "Conversas",
"mobile.sessions.empty.noProjectsTitle": "Sem projetos",
"mobile.sessions.empty.noProjectsDescription": "Adicione um projeto para começar a conversar com seu código.",
"mobile.sessions.empty.noSessionsTitle": "Sem sessões",
@@ -384,7 +390,7 @@ export const dict: Record<I18nKey, string> = {
"multirun.launcher.attachments.attach": "Anexar",
"multirun.launcher.attachments.tooltip": "Arquivos idênticos enviados a todas as execuções",
"multirun.launcher.models.label": "Modelos",
"multirun.launcher.models.info": "Selecione 2-{max} modelos. O mesmo modelo pode ser adicionado várias vezes.",
"multirun.launcher.models.info": "Selecione 2 ou mais modelos. O mesmo modelo pode ser adicionado várias vezes.",
"multirun.launcher.toast.fileTooLarge": "O arquivo \"{fileName}\" é grande demais (máximo 10MB)",
"multirun.launcher.toast.attachFailed": "Não foi possível anexar \"{fileName}\"",
"multirun.launcher.toast.attachedSingle": "Arquivo anexado ({count})",
@@ -537,11 +543,33 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Parar de compartilhar",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover para um novo worktree",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover para worktree",
"sessions.sidebar.session.menu.newWorktree": "Novo worktree...",
"sessions.sidebar.session.moveToWorktree.success": "Sessão movida para um novo worktree",
"sessions.sidebar.session.moveToWorktree.failed": "Não foi possível mover a sessão para um novo worktree",
"sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual, transfere alterações não commitadas e move esta sessão e suas subsessões para lá.",
"sessions.sidebar.session.moveToWorktree.main": "Worktree principal",
"sessions.sidebar.session.moveToWorktree.refreshing": "Atualizando worktrees...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "Não foi possível carregar os worktrees",
"sessions.sidebar.session.moveToWorktree.current": "Worktree atual",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sessão movida para o worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "Não foi possível mover a sessão para o worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Mostra os worktrees existentes e a opção de criar um novo para esta sessão.",
"sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual e move esta sessão e suas subsessões para lá. Quando a fonte tem alterações não commitadas, você escolhe se as transfere.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponível quando a sessão está ociosa. Interrompa a atividade atual ou aguarde sua conclusão.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sessão já está sendo movida para um novo worktree.",
"sessions.sidebar.session.moveToWorktree.confirm.title": "A fonte tem alterações não commitadas",
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Arquivos alterados neste worktree: {count}.",
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "O OpenCode rastreia essas alterações por diretório, não por sessão.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Move esta sessão e suas subsessões deixando todos os arquivos da fonte inalterados.",
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfere as alterações no diretório da sessão. Arquivos não adicionados ao stage e não rastreados saem da fonte após o sucesso.",
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Alterações já no stage permanecem na fonte e são copiadas para o destino.",
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "A transferência pode falhar quando o destino usa uma base do Git diferente.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover apenas a sessão",
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todas as alterações da fonte",
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "As alterações da fonte não puderam ser verificadas. Nenhum worktree ou sessão foi alterado.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "O destino não pôde aceitar as alterações da fonte. A sessão e as alterações da fonte não foram movidas. Tente novamente e escolha Mover apenas a sessão.",
"sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "A conexão caiu antes de o destino confirmar a movimentação. A sessão pode não ter sido movida, e suas alterações não commitadas podem já estar no worktree de destino. Confira lá antes de tentar de novo.",
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
@@ -1144,6 +1172,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Prévia",
"contextPanel.mode.browser": "Navegador",
"contextRail.configure.open": "Configurar painéis",
"contextRail.configure.dialogTitle": "Painéis da barra",
"contextRail.configure.dialogDescription": "Escolha quais painéis a barra mostra. Painéis ocultos mantêm seus dados e continuam acessíveis pela paleta de comandos.",
"contextRail.configure.showAll": "Mostrar todos",
"contextRail.configure.noneWarning": "Todos os painéis estão ocultos.",
"contextRail.aria.rail": "Superfícies do painel",
"contextPanel.editorEmpty.title": "Nenhum arquivo aberto",
"contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.",
@@ -1284,6 +1317,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.browser.annotate.submit": "Anexar",
"contextPanel.browser.trustNotice": "As páginas abertas aqui são executadas com acesso total ao OpenChamber — necessário para inspeção e capturas de tela. Abra apenas sites confiáveis: uma página maliciosa pode ler seus dados ou agir em seu nome.",
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
"contextPanel.tab.menu.close": "Fechar",
"contextPanel.tab.menu.closeOthers": "Fechar outras",
"contextPanel.tab.menu.closeToLeft": "Fechar abas à esquerda",
"contextPanel.tab.menu.closeToRight": "Fechar abas à direita",
"contextPanel.tab.menu.closeAll": "Fechar todas as abas",
"contextPanel.actions.collapsePanel": "Recolher painel",
"contextPanel.actions.expandPanel": "Expandir painel",
"contextPanel.actions.closePanel": "Fechar painel",
@@ -1380,6 +1418,12 @@ export const dict: Record<I18nKey, string> = {
"filesView.editor.disableLineWrap": "Desativar ajuste de linha",
"filesView.editor.enableLineWrap": "Ativar ajuste de linha",
"filesView.editor.findInFile": "Buscar no arquivo",
"filesView.preview.find.placeholder": "Buscar na pré-visualização",
"filesView.preview.find.nextAria": "Próxima correspondência",
"filesView.preview.find.previousAria": "Correspondência anterior",
"filesView.preview.find.closeAria": "Fechar busca",
"filesView.preview.find.noMatches": "Sem correspondências",
"filesView.preview.find.countAria": "{current} de {total}",
"filesView.editor.goToLine": "Ir para linha",
"filesView.editor.switchToEditMode": "Alternar para o modo de edição",
"filesView.editor.switchToPreviewMode": "Alternar para o modo de visualização",
@@ -1650,7 +1694,7 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.toast.planImported": "Plano importado",
"rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Não foi possível ler o arquivo do plano",
"inlineComment.range.lines": "Linhas {start}-{end}",
"inlineComment.input.placeholder": "Adicionar um comentário... (Cmd+Enter para salvar)",
"inlineComment.input.placeholder": "Adicionar um comentário... ({shortcut} para salvar)",
"inlineComment.input.placeholderShort": "Adicionar um comentário...",
"inlineComment.actions.cancel": "Cancelar",
"inlineComment.actions.save": "Salvar",
@@ -1692,6 +1736,9 @@ export const dict: Record<I18nKey, string> = {
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
"chat.recap.aria": "Resumo da sessão",
"chat.recap.label": "Resumo:",
"chat.sessionError.title": "O OpenCode interrompeu esta resposta",
"chat.sessionError.noDetails": "O OpenCode não informou detalhes. Abra o relatório de status (Ctrl/Cmd+Shift+L) para ver os erros recentes.",
"chat.sessionError.noReply": "O OpenCode não iniciou uma resposta a esta mensagem.",
"chat.goal.dialog.titleCreate": "Definir objetivo da sessão",
"chat.goal.dialog.titleManage": "Objetivo da sessão",
"chat.goal.dialog.objectiveLabel": "Objetivo",
@@ -1767,6 +1814,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.actions.openInFinder": "Abrir no Finder",
"directoryExplorerDialog.actions.adding": "Adicionando...",
"directoryExplorerDialog.actions.addProject": "Adicionar projeto",
"directoryExplorerDialog.actions.addSelected": "Adicionar selecionados",
"directoryExplorerDialog.actions.addLocalProject": "Adicionar projeto local",
"directoryExplorerDialog.actions.cloneRepository": "Clonar repositório",
"directoryExplorerDialog.actions.cloneAndAdd": "Clonar e adicionar",
@@ -1784,6 +1832,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.browse.parentDirectory": "Diretório pai",
"directoryExplorerDialog.browse.addedBadge": "Adicionado",
"directoryExplorerDialog.browse.quickAdd": "Adicionar",
"directoryExplorerDialog.browse.selectForAdd": "Selecionar para adicionar",
"directoryExplorerDialog.footer.navigate": "Navegar",
"directoryExplorerDialog.footer.select": "Selecionar",
"directoryExplorerDialog.footer.add": "Adicionar",
@@ -1792,6 +1841,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.toast.desktopDeniedAccess": "O desktop negou o acesso ao diretório.",
"directoryExplorerDialog.toast.failedToOpenDirectory": "Não foi possível abrir o diretório",
"directoryExplorerDialog.toast.desktopCouldNotGrantAccess": "O desktop não pôde conceder acesso ao arquivo.",
"directoryExplorerDialog.toast.addedProjects": "Foram adicionados {count} projeto(s)",
"directoryExplorerDialog.toast.failedToAddProject": "Não foi possível adicionar o projeto",
"directoryExplorerDialog.toast.cloneUrlRequired": "Insira uma URL de repositório antes de clonar.",
"directoryExplorerDialog.toast.selectValidDirectoryPath": "Selecione um caminho de diretório válido.",
@@ -1840,22 +1890,18 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.focusChatInput": "Focar entrada do chat",
"helpDialog.item.togglePromptNavigator": "Mostrar ou ocultar navegador de prompts",
"helpDialog.item.abortActiveRun": "Interromper execução ativa (duplo clique)",
"helpDialog.item.toggleRightSidebar": 'Alternar painel de contexto',
"helpDialog.item.openRightSidebarGitTab": 'Abrir superfície do Git',
"helpDialog.item.openRightSidebarFilesTab": 'Abrir superfície de arquivos',
"helpDialog.item.toggleTerminalDock": "Mostrar ou ocultar dock de terminal",
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
"helpDialog.item.switchSessionTab": "Alternar aba de sessão",
"helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)",
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
"helpDialog.item.openSettings": "Abrir configurações",
"helpDialog.keyCombiner.or": "ou",
"helpDialog.proTips.title": "Dicas:",
"helpDialog.proTips.commandPalette": "Use a paleta de comandos ({shortcut}) para acessar rapidamente todas as ações",
"helpDialog.proTips.recentSessions": "As cinco sessões mais recentes aparecem na paleta de comandos",
"helpDialog.proTips.themeCycling": "A alternância de tema lembra sua preferência entre sessões",
"helpDialog.proTips.leaderSequences": "Atalhos em duas etapas: pressione a combinação e depois a segunda tecla — Esc cancela",
"header.actions.rightSidebarWithShortcut": "Barra lateral direita ({shortcut})",
"header.actions.toggleRightSidebarAria": "Mostrar ou ocultar barra lateral direita",
"header.actions.openAppMenu": "Menu do OpenChamber",
@@ -1939,8 +1985,6 @@ export const dict: Record<I18nKey, string> = {
"session.newWorktree.noMatchingBranches": "Não há branches coincidentes",
"session.newWorktree.localBranches": "Branches locais",
"session.newWorktree.remoteBranches": "Branches remotas",
"session.newWorktree.otherLocalBranches": "Outras branches locais",
"session.newWorktree.otherRemoteBranches": "Outras branches remotas",
"session.newWorktree.branchName": "Nome da branch",
"session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature",
"session.newWorktree.actions.change": "Alterar",
@@ -2065,7 +2109,6 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "Tarefas",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes",
"chat.statusRow.aborted": "Interrompido",
"chat.revertIndicator.redo": "Refazer",
"chat.revertIndicator.redoAria": "Refazer — restaurar mensagens revertidas",
"chat.revertPopover.title": "Revertidas",
@@ -2143,7 +2186,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
"chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.",
"chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.",
"chat.container.sessionLoadError.authDescription": "Sua sessão expirou, então o servidor recusou a solicitação. Entre e a conversa será carregada.",
"chat.container.sessionLoadError.retry": "Tentar novamente",
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
@@ -2186,10 +2230,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.title.commentOnSelection": "Comentar a seleção",
"chat.textSelection.comment.placeholder": "Adicione um comentário opcional...",
"chat.textSelection.comment.attach": "Anexar",
"chat.textSelection.actions.newSession": "Nova sessão",
"chat.textSelection.actions.addToNotes": "Adicionar às notas",
"chat.textSelection.title.addToCurrentChat": "Adicionar ao chat atual",
"chat.textSelection.title.newSessionWithSelection": "Criar nova sessão com seleção",
"chat.textSelection.title.saveInsightToNotes": "Salvar texto selecionado em notas",
"chat.messageBody.actions.revertAria": "Voltar para esta mensagem",
"chat.messageBody.actions.revert": "Voltar daqui",
@@ -2273,7 +2315,12 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.toast.attachmentsTooLarge": "Os anexos são grandes demais para enviar. Tente reduzir a quantidade ou o tamanho das imagens.",
"chat.chatInput.toast.sendAttachmentsFailed": "Não foi possível enviar os anexos. Tente com menos arquivos ou imagens menores.",
"chat.chatInput.toast.messageSendFailed": "A mensagem não pôde ser enviada. Os anexos foram restaurados.",
"chat.chatInput.toast.noModelSelected": "Selecione um provedor e um modelo antes de enviar.",
"chat.chatInput.toast.clipboardAttachFailed": "Não foi possível anexar a imagem da área de transferência",
"chat.chatInput.toast.clipboardTextAttachFailed": "Não foi possível anexar o texto colado como arquivo",
"chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado",
"chat.chatInput.toast.largeTextPaste.attach": "Anexar como arquivo",
"chat.chatInput.toast.largeTextPaste.inline": "Colar no corpo",
"chat.chatInput.toast.addedFileMentions": "Foram adicionadas {count} menção(es) de arquivo",
"chat.chatInput.toast.attachFileFailed": "Não foi possível anexar o arquivo",
"chat.chatInput.toast.attachNamedFailed": "Não foi possível anexar {name}",
@@ -2322,6 +2369,7 @@ export const dict: Record<I18nKey, string> = {
"chat.toolPart.showRawJson": "Mostrar JSON bruto",
"chat.toolPart.showFormattedJson": "Mostrar JSON formatado",
"chat.toolPart.showNavigableJson": "Mostrar JSON navegável",
"chat.toolPart.openFile": "Abrir arquivo",
"chat.toolPart.openFileAtFirstChange": "Abrir arquivo na primeira alteração",
"chat.toolPart.openFileDiff": "Abrir diferenças do arquivo",
"chat.toolPart.copyOutput": "Copiar saída",
@@ -2453,6 +2501,15 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.toggleSidebar": "Mostrar ou ocultar barra lateral",
"commandPalette.item.showContextUsage": "Mostrar uso do contexto",
"commandPalette.item.toggleTerminal": "Mostrar ou ocultar terminal",
"commandPalette.item.cycleTheme": "Alternar tema",
"commandPalette.item.showOpenCodeStatus": "Mostrar status do OpenCode",
"commandPalette.item.toggleMemoryDebug": "Alternar painel de depuração de memória",
"commandPalette.item.pinSession": "Fixar ou desafixar sessão",
"commandPalette.item.copySessionId": "Copiar ID da sessão",
"commandPalette.item.openMultiRun": "Abrir lançador multi-run",
"commandPalette.item.openArchive": "Abrir sessões arquivadas",
"commandPalette.item.openNotes": "Abrir painel de notas",
"commandPalette.item.openTodos": "Abrir painel de tarefas",
"commandPalette.item.openSettings": "Abrir configurações...",
"commandPalette.session.untitled": "Sessão sem título",
"openCodeStatusDialog.title": "Status do OpenCode",
@@ -2667,6 +2724,9 @@ export const dict: Record<I18nKey, string> = {
"sessionAuth.error.passkeySignInCanceled": "O início de sessão com chave de acesso foi cancelado.",
"sessionAuth.error.enterPasswordForPasskey": "Digite sua senha para adicionar uma chave de acesso.",
"sessionAuth.locked.tunnelTitle": "É necessário acesso por túnel",
"sessionAuth.expired.banner": "Sua sessão expirou — entre para continuar.",
"sessionAuth.expired.loginAction": "Entrar",
"sessionAuth.expired.sendBlocked": "Sessão expirada — entre para enviar mensagens.",
"sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber",
"sessionAuth.locked.tunnelDescription": "Abra este túnel usando o link de conexão única do aplicativo desktop.",
"sessionAuth.locked.passwordDescription": "Esta sessão está protegida com senha.",
@@ -2949,6 +3009,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Atualizando...",
"updateDialog.error.updateFailed": "Não foi possível atualizar",
"updateDialog.error.takingLonger": "A atualização está demorando mais do que o esperado. Aguarde um pouco e atualize, ou execute: openchamber update",
"updateDialog.error.signatureRejected": "A atualização baixada foi rejeitada: a assinatura de código não corresponde a esta instalação. Isso costuma significar que a cópia em execução não foi instalada a partir de uma versão oficial assinada. Instale o OpenChamber a partir de uma versão oficial e atualize novamente.",
"updateDialog.error.updaterDisabled": "O atualizador parou após uma instalação com falha. Feche o OpenChamber, abra-o de novo e tente atualizar outra vez.",
"updateDialog.error.restartFailed": "Não foi possível reiniciar para instalar a atualização.",
"updateDialog.error.restartUnavailable": "Instalar a atualização exige o aplicativo de desktop do OpenChamber.",
"mobileUpdate.toast.available.title": "Atualização do OpenChamber disponível",
"mobileUpdate.toast.available.description": "A versão {version} está pronta para Android.",
"mobileUpdate.toast.actions.download": "Baixar",
@@ -2969,6 +3033,7 @@ export const dict: Record<I18nKey, string> = {
"memoryDebugPanel.title": "Painel de depuração",
"memoryDebugPanel.tabs.memory": "Memória",
"memoryDebugPanel.tabs.streaming": "Transmissão",
"memoryDebugPanel.tabs.requests": "Solicitações",
"memoryDebugPanel.section.sessionsInMemory": "Sessões em memória",
"memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI",
"memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas da ponte do VS Code",
@@ -3006,6 +3071,16 @@ export const dict: Record<I18nKey, string> = {
"memoryDebugPanel.streaming.copy.copied": "JSON de depuração em streaming copiado",
"memoryDebugPanel.streaming.copy.failed": "Não foi possível copiar JSON",
"memoryDebugPanel.streaming.copy.hint": "Copia exportações de métricas da UI e do VS Code em formato JSON",
"memoryDebugPanel.requests.inFlight": "Em curso",
"memoryDebugPanel.requests.peak": "Pico",
"memoryDebugPanel.requests.duration": "Duração",
"memoryDebugPanel.requests.totalRequests": "Solicitações totais",
"memoryDebugPanel.requests.tracking": "Rastreamento",
"memoryDebugPanel.requests.now": "agora",
"memoryDebugPanel.requests.noSamples": "Nenhuma solicitação registrada. Mantenha este painel aberto para registrar a atividade de fetch.",
"memoryDebugPanel.requests.chartLabel": "Solicitações fetch em curso ao longo do tempo, pico {peak}",
"memoryDebugPanel.requests.windowHint": "últimos {seconds}s",
"memoryDebugPanel.requests.percentileChartLabel": "Percentis de idade das solicitações em curso (p50, p90, p99, máx) ao longo do tempo",
"memoryDebugPanel.common.idle": "inativo",
"memoryDebugPanel.common.live": "ao vivo",
"memoryDebugPanel.common.notAvailable": "n/a",
@@ -3104,9 +3179,10 @@ export const dict: Record<I18nKey, string> = {
"quota.window.premium": "Premium Interactions",
"quota.window.chat": "Chat Requests",
"quota.window.completions": "Completions",
"quota.window.premiumInteractions": "Premium interactions",
"quota.window.premiumInteractions": "Créditos de IA",
'chat.workStatus.ariaLabel': 'Status do trabalho',
'chat.workStatus.context.label': 'Contexto',
'chat.workStatus.cost.breakdown': "Sessão {session} · Subagentes {subagents}",
'chat.workStatus.git.changedFileSingle': '{count} arquivo alterado',
'chat.workStatus.git.changedFilePlural': '{count} arquivos alterados',
'chat.workStatus.pr.untitled': 'Pull request sem título',
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW'] as const;
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
const requiredKeys = [
'settings.page.integrations.title',
@@ -385,4 +385,39 @@ export const thirdPartyIntegrationI18n = {
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 內部模型的充足額度,現已可用於 OpenChamber。',
},
tr: {
'settings.integrations.experimentalWarning': 'Deneysel özellik. Provider politikalarına saygı göstermeye çalışıyoruz, ancak hesap kısıtlamaları ve askıya almalar her provider\'ın kararıdır. Entegrasyonları kendi riskinize kullanın.',
'settings.page.integrations.title': 'Entegrasyonlar',
'settings.page.integrations.description': 'OpenChamber provider olarak kullanmak için üçüncü taraf abonelikler ekleyin.',
'settings.integrations.thirdParty.title': 'Üçüncü taraf entegrasyonlar',
'settings.integrations.thirdParty.info': 'Bir provider eklentisi kurun, ardından aboneliğinizi ayarlayın ki OpenChamber onu kullanabilsin.',
'settings.integrations.thirdParty.actions.install': 'Kur',
'settings.integrations.thirdParty.actions.update': 'Güncelle',
'settings.integrations.thirdParty.actions.setup': 'Ayarla',
'settings.integrations.thirdParty.actions.remove': 'Kaldır',
'settings.integrations.thirdParty.actions.docs': 'Dokümanlar',
'settings.integrations.thirdParty.actions.managePlugins': 'Eklentileri yönet',
'settings.integrations.thirdParty.status.notInstalled': 'Kurulu değil',
'settings.integrations.thirdParty.status.installed': 'Kurulu',
'settings.integrations.thirdParty.status.installedVersion': 'Kurulu: {version}',
'settings.integrations.thirdParty.status.updateAvailable': 'Güncelleme var: {version}',
'settings.integrations.thirdParty.status.unpinned': 'En son release takip ediliyor',
'settings.integrations.thirdParty.status.projectInstalled': 'Ayrıca bu proje için yapılandırıldı',
'settings.integrations.thirdParty.status.ambiguous': 'Birden çok kullanıcı genelinde eklenti girdisi elle yönetilmeli',
'settings.integrations.thirdParty.status.restartRequired': 'Bu provider\'ı ayarlamadan önce OpenCode\'u yeniden başlatın.',
'settings.integrations.thirdParty.status.registryUnavailable': 'npm şu anda denetlenemedi.',
'settings.integrations.thirdParty.status.providerUnavailable': 'Provider henüz kullanılabilir değil. OpenCode\'u yeniden başlatıp tekrar deneyin.',
'settings.integrations.thirdParty.dialog.remove.title': 'Entegrasyonu kaldır',
'settings.integrations.thirdParty.dialog.remove.description': '{name}, kullanıcı genelindeki OpenCode yapılandırmanızdan kaldırılsın mı? OpenCode yenilendikten sonra provider artık yüklenmeyecek.',
'settings.integrations.thirdParty.toast.installed': '{name} kuruldu',
'settings.integrations.thirdParty.toast.updated': '{name} güncellendi',
'settings.integrations.thirdParty.toast.removed': '{name} kaldırıldı',
'settings.integrations.thirdParty.toast.actionFailed': 'Entegrasyon güncellenemedi',
'settings.integrations.thirdParty.toast.providerUnavailable': 'Provider henüz açılamadı',
'settings.integrations.thirdParty.toast.restartRequired': 'Değişikliklerin geçerli olması için OpenCode\'u yeniden başlatın',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max planınızı kullanın — API anahtarı gerekmez, Claude uygulamaları gerekmez.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor\'ın cömert kendi model limitleri artık OpenChamber\'da.',
},
} as const;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go',
@@ -206,9 +207,9 @@ export const settingsDict = {
"settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting": "Перед початком додайте токен керованого віддаленого тунелю",
"settings.openchamber.tunnel.toast.startFailed": "Не вдалося запустити тунель",
"settings.openchamber.tunnel.toast.startedButNoPublicUrl": "Тунель запущено, але публічний URL не повернувся",
"settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесія.",
"settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесію.",
"settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions": "Попередній тунель замінено: відкликано 1 посилання, анульовано сесій: {invalidatedSessionCount}.",
"settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесія.",
"settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесію.",
"settings.openchamber.tunnel.toast.replacedTunnelManyMany": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано сесій: {invalidatedSessionCount}.",
"settings.openchamber.tunnel.toast.linkReady": "Тунель готовий",
"settings.openchamber.tunnel.toast.stopped": "Тунель зупинено",
@@ -1101,7 +1102,7 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.overwritePrompt": "Ця комбінація вже використовується іншою комбінацією клавіш. Перезаписати та очистити інше зіставлення?",
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Натисніть клавіші...",
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Спочатку запишіть комбінацію клавіш.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Її все одно збережено.",
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Ви все одно можете її зберегти.",
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Перейти до рядка (редактор файлів)",
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Відкрити палітру команд",
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Фокус на полі вводу",
@@ -1110,18 +1111,20 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Розгорнути або згорнути термінал",
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Додати виділення в чат",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Перемкнути бічну панель",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Перемкнути вкладку сесії",
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
"settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Попередня сесія",
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Наступна сесія",
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Перейменувати поточну сесію",
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Перемкнути авто-дозволи",
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Закрити вкладку сесії",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
"settings.openchamber.keyboardShortcuts.action.open_help.label": "Відкрити комбінації клавіш",
"settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Перемкнути контекстну панель плану",
"settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Перемкнути меню сервісів",
"settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Перемкнути вкладку сервісів",
"settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Перемкнути тему",
"settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Перемкнути агента",
"settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Перемкнути улюблену модель вперед",
@@ -1130,6 +1133,27 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.expand_input.label": "Розгорнути введення",
"settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Відкрити хронологію розмови",
"settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Показати або приховати навігатор промптів",
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Ця послідовність має спільний контекстний префікс із дією {action}. Коли її контекст активний, ця дія має пріоритет.",
"settings.openchamber.keyboardShortcuts.category.session": "Керування сесією",
"settings.openchamber.keyboardShortcuts.category.models": "Моделі й агенти",
"settings.openchamber.keyboardShortcuts.category.panels": "Панелі та інструменти",
"settings.openchamber.keyboardShortcuts.category.navigation": "Навігація",
"settings.openchamber.keyboardShortcuts.category.application": "Застосунок",
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
"settings.openchamber.keyboardShortcuts.actions.confirm": "Підтвердити",
"settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}",
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш, не більше трьох клавіш у кожній. Після першої зачекайте до 3 секунд на другу комбінацію. Виберіть Підтвердити, щоб застосувати, або Скасувати, щоб відхилити. Backspace видаляє останню.",
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація",
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
"settings.openchamber.keyboardShortcuts.unassigned": "Не призначено",
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Це конфліктує з послідовністю, яку використовує {action}. Виберіть іншу комбінацію.",
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Цю комбінацію вже використовує {action}.",
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Ця комбінація конфліктує з вбудованим скороченням, яке не можна замінити.",
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Відкрити вибір проєкту чернетки",
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Відкрити вибір worktree чернетки",
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити останні сесії",
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Голосове введення",
"settings.projects.sidebar.total": "Усього {count}",
"settings.projects.sidebar.actions.addProject": "Додати проєкт",
"settings.projects.page.empty.noProjects": "Немає доступних проєктів.",
@@ -1815,7 +1839,10 @@ export const settingsDict = {
"settings.voice.page.provider.server": "Сервер",
"settings.voice.page.provider.local": "Локальний",
"settings.voice.page.tooltip.sttLocal": "Локальна розшифровка на сервері OpenChamber. Моделі завантажуються автоматично; ключ API не потрібен.",
"settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro, англійська). Модель завантажується автоматично; ключ API не потрібен.",
"settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro для англійської; моделі для інших мов завантажуються при першому використанні). Ключ API не потрібен.",
"settings.voice.page.field.followTextLanguage": "Підбирати голос під мову тексту",
"settings.voice.page.field.followTextLanguageAria": "Підбирати голос під мову тексту",
"settings.voice.page.field.followTextLanguageInfo": "Якщо відповідь іншою мовою, використовується голос цієї мови: відповідний голос macOS або локальна модель, яка завантажується при першому використанні.",
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (англійська)",
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 європейських мов)",
"settings.voice.page.stt.model.whisperBase": "Whisper base (мультимовна)",
@@ -1909,7 +1936,7 @@ export const settingsDict = {
"settings.openchamber.visual.section.streaming": "Стримінг",
"settings.openchamber.visual.field.streamingAutoFollow": "Слідкувати за новим вмістом під час стримінгу",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Автоматично слідкувати за новим вмістом під час стримінгу відповіді",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Поки відповідь надходить, вигляд плавно рухається до найновішого вмісту. Вимкніть, щоб вигляд залишався нерухомим і гортати вручну.",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Поки відповідь надходить, вигляд плавно рухається до найновішого вмісту. Вимкніть, щоб вигляд залишався нерухомим і гортати вручну; тоді й надсилання повідомлення з середини чату не зсуватиме вигляд.",
"settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень",
"settings.openchamber.visual.section.toolsAndFiles": "Інструменти та файли",
"settings.openchamber.visual.section.composer": "Поле вводу",
@@ -2039,6 +2066,13 @@ export const settingsDict = {
"settings.openchamber.visual.field.persistDraftMessages": "Зберігати чернетки повідомлень",
"settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Увімкнути перевірку орфографії під час введення тексту",
"settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Увімкнути перевірку орфографії в текстових полях",
"settings.openchamber.visual.field.largeTextPaste": "Вставлення великого тексту",
"settings.openchamber.visual.field.largeTextPasteHint": "Під час вставлення понад приблизно 2000 символів або 25 рядків виберіть, чи долучити текст як файл, вставити його в повідомлення чи запитувати щоразу.",
"settings.openchamber.visual.field.largeTextPasteAria": "Поведінка вставлення великого тексту",
"settings.openchamber.visual.field.largeTextPasteOptionAria": "Вставлення великого тексту: {option}",
"settings.openchamber.visual.option.largeTextPaste.ask.label": "Запитувати щоразу",
"settings.openchamber.visual.option.largeTextPaste.attach.label": "Долучити як файл",
"settings.openchamber.visual.option.largeTextPaste.inline.label": "Вставити в повідомлення",
"settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Надсилати анонімні звіти про використання",
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.",
@@ -2122,7 +2156,7 @@ export const settingsDict = {
"settings.magicPrompts.page.group.planImprove.title": "Поліпшити план",
"settings.magicPrompts.page.group.planImprove.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік покращення.",
"settings.magicPrompts.page.group.planTodo.title": "Планування Todo",
"settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нового сесії планування.",
"settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нової сесії планування.",
"settings.magicPrompts.page.group.planImplement.title": "Реалізувати план",
"settings.magicPrompts.page.group.planImplement.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік реалізації.",
"settings.magicPrompts.page.group.sessionSummary.title": "Підсумок сесії",
@@ -2197,5 +2231,6 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
...linearIntegrationI18n.uk,
...thirdPartyIntegrationI18n.uk,
} as const;
+101 -25
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './uk.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n.uk,
...linearPanelI18n.uk,
'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід',
'terminalView.actions.restart': 'Перезапустити термінал',
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
@@ -38,6 +42,7 @@ export const dict: Record<I18nKey, string> = {
"common.language.korean": "Корейська",
"common.language.polish": "Польська",
"common.language.japanese": "Японська",
"common.language.turkish": "Турецька",
"common.revealPath.finder": "Показати у Finder",
"common.revealPath.fileExplorer": "Відкрити у File Explorer",
"common.revealPath.fileManager": "Відкрити у файловому менеджері",
@@ -130,6 +135,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.section.worktrees": "Worktrees",
"mobile.sessions.section.otherProjects": "Інші проєкти",
"mobile.sessions.section.projects": "Проєкти",
"mobile.sessions.section.chats": "Чати",
"mobile.sessions.empty.noProjectsTitle": "Ще немає проєктів",
"mobile.sessions.empty.noProjectsDescription": "Додай проєкт, щоб почати спілкування з кодом.",
"mobile.sessions.empty.noSessionsTitle": "Ще немає сесій",
@@ -384,7 +390,7 @@ export const dict: Record<I18nKey, string> = {
"multirun.launcher.attachments.attach": "Прикріпити",
"multirun.launcher.attachments.tooltip": "Ті самі файли буде надіслано в усі запуски",
"multirun.launcher.models.label": "Моделі",
"multirun.launcher.models.info": "Вибрати моделі 2-{max}. Ту саму модель можна додавати кілька разів.",
"multirun.launcher.models.info": "Вибрати 2 або більше моделей. Ту саму модель можна додавати кілька разів.",
"multirun.launcher.toast.fileTooLarge": "Файл \"{fileName}\" завеликий (макс. 10 МБ)",
"multirun.launcher.toast.attachFailed": "Не вдалося вкласти \"{fileName}\"",
"multirun.launcher.toast.attachedSingle": "Прикріплено файл: {count}",
@@ -504,12 +510,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.failedDeletePlural": "Не вдалося видалити сесії {count}",
"sessions.sidebar.bulkActions.archivedSingle": "Заархівовано сесію: {count}",
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесію {count}",
"sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {count}",
"sessions.sidebar.bulkActions.restore": "Відновити",
"sessions.sidebar.bulkActions.restoredSingle": "Відновлено сесію: {count}",
"sessions.sidebar.bulkActions.restoredPlural": "Відновлено сесій: {count}",
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесія {count}",
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесію {count}",
"sessions.sidebar.bulkActions.failedRestorePlural": "Не вдалося відновити сесії {count}",
"sessions.sidebar.folders.none": "Папок ще немає",
"sessions.sidebar.folders.newFolderEllipsis": "Нова папка...",
@@ -537,11 +543,33 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ",
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Перенести в worktree",
"sessions.sidebar.session.menu.newWorktree": "Новий worktree...",
"sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree",
"sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree",
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.",
"sessions.sidebar.session.moveToWorktree.main": "Основний worktree",
"sessions.sidebar.session.moveToWorktree.refreshing": "Оновлення worktree...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "Не вдалося завантажити worktree",
"sessions.sidebar.session.moveToWorktree.current": "Поточний worktree",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Сесію перенесено в worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "Не вдалося перенести сесію в worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Показує наявні worktree і можливість створити новий для цієї сесії.",
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки та переміщує туди цю сесію і її підсесії. Якщо у джерелі є незакомічені зміни, ви обираєте, переносити їх чи ні.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.",
"sessions.sidebar.session.moveToWorktree.confirm.title": "У джерелі є незакомічені зміни",
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Змінені файли у цьому worktree: {count}.",
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode відстежує ці зміни за каталогом, а не за сесією.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Переносить цю сесію та її підсесії, не змінюючи жодного файла джерела.",
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Переносить зміни з каталогу сесії. Незакомічені та невідстежувані файли залишають джерело після успіху.",
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Закомічені в індекс зміни залишаються в джерелі та копіюються до призначення.",
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "Перенесення може не вдатися, якщо призначення використовує іншу базу Git.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Перенести лише сесію",
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Перенести всі зміни з джерела",
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Скасувати",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "Не вдалося перевірити зміни в джерелі. Жоден worktree чи сесію не змінено.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "Призначення не змогло прийняти зміни з джерела. Сесію та зміни в джерелі не перенесено. Спробуйте знову й оберіть Перенести лише сесію.",
"sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "З’єднання обірвалося, перш ніж призначення підтвердило перенесення. Сесія могла не переїхати, а незакомічені зміни можуть уже бути в цільовому worktree. Перевірте його, перш ніж повторювати.",
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
@@ -560,9 +588,9 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.export.dialog.descriptionMany": "Ця сесія має {count} завдань під-агентів. Додати їх до експорту?",
"sessions.sidebar.session.export.dialog.includeSubtasks": "Додати завдання під-агентів",
"sessions.sidebar.session.export.dialog.confirm": "Експортувати",
"sessions.sidebar.session.status.active": "Сесія активний",
"sessions.sidebar.session.status.active": "Сесія активна",
"sessions.sidebar.session.status.unread": "Непрочитані оновлення",
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
"sessions.sidebar.session.status.pinned": "Закріплена сесія",
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
"sessions.sidebar.session.status.questionPendingSingle": "1 запитання очікує відповіді",
@@ -571,8 +599,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.lastTurnDuration": "Останній хід тривав {duration}",
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесія?",
"sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесія?",
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесію?",
"sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесію?",
"sessions.sidebar.dialogs.deleteSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.",
"sessions.sidebar.dialogs.deleteSession.withManySubtasks": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.",
"sessions.sidebar.dialogs.archiveSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде заархівовано.",
@@ -581,7 +609,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.dialogs.archiveSession.single": "\"{sessionTitle}\" буде заархівовано.",
"sessions.sidebar.dialogs.neverAsk": "Більше не питати",
"sessions.sidebar.dialogs.cancel": "Скасувати",
"sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесія",
"sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесію",
"sessions.sidebar.dialogs.deleteSessions.titleAction": "Видалити сесії",
"sessions.sidebar.dialogs.deleteSessions.title": "Видалити сесії?",
"sessions.sidebar.dialogs.archiveSessions.title": "Архівувати сесії?",
@@ -661,7 +689,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.folderItem.deleteFolderAria": "Видалити папку {folderName}",
"sessions.sidebar.folderItem.emptyFolder": "Порожня папка",
"sessions.sidebar.sessionDialogs.ok": "OK",
"sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язаний сесія",
"sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язана сесія",
"sessions.sidebar.sessionDialogs.linkedSessionPlural": "Пов’язані сесії",
"sessions.sidebar.sessionDialogs.delete.note": "Каталоги worktree залишаються недоторканими. Підсесії, пов’язані з вибраними сесіями, також буде видалено.",
"sessions.sidebar.sessionDialogs.directory.errorSelectTitle": "Не вдалося вибрати каталог",
@@ -1144,6 +1172,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.mode.context": "Контекст",
"contextPanel.mode.preview": "Перегляд",
"contextPanel.mode.browser": "Браузер",
"contextRail.configure.open": "Налаштувати панелі",
"contextRail.configure.dialogTitle": "Панелі рейки",
"contextRail.configure.dialogDescription": "Обери, які панелі показує рейка. Приховані панелі зберігають дані й доступні з палітри команд.",
"contextRail.configure.showAll": "Показати всі",
"contextRail.configure.noneWarning": "Усі панелі приховано.",
"contextRail.aria.rail": "Поверхні панелі",
"contextPanel.editorEmpty.title": "Файл не відкрито",
"contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.",
@@ -1284,6 +1317,11 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.browser.annotate.submit": "Додати",
"contextPanel.browser.trustNotice": "Сторінки, відкриті тут, працюють із повним доступом до OpenChamber — це потрібно для inspect і скріншотів. Відкривайте лише сайти, яким довіряєте: шкідлива сторінка може прочитати ваші дані чи діяти від вашого імені.",
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
"contextPanel.tab.menu.close": "Закрити",
"contextPanel.tab.menu.closeOthers": "Закрити інші",
"contextPanel.tab.menu.closeToLeft": "Закрити вкладки ліворуч",
"contextPanel.tab.menu.closeToRight": "Закрити вкладки праворуч",
"contextPanel.tab.menu.closeAll": "Закрити всі вкладки",
"contextPanel.actions.collapsePanel": "Згорнути панель",
"contextPanel.actions.expandPanel": "Розгорнути панель",
"contextPanel.actions.closePanel": "Закрити панель",
@@ -1380,6 +1418,12 @@ export const dict: Record<I18nKey, string> = {
"filesView.editor.disableLineWrap": "Вимкнути перенос рядків",
"filesView.editor.enableLineWrap": "Увімкнути перенос рядків",
"filesView.editor.findInFile": "Знайти у файлі",
"filesView.preview.find.placeholder": "Пошук у попередньому перегляді",
"filesView.preview.find.nextAria": "Наступний збіг",
"filesView.preview.find.previousAria": "Попередній збіг",
"filesView.preview.find.closeAria": "Закрити пошук",
"filesView.preview.find.noMatches": "Збігів немає",
"filesView.preview.find.countAria": "{current} із {total}",
"filesView.editor.goToLine": "Перейти до рядка",
"filesView.editor.switchToEditMode": "Перемкнутися в режим редагування",
"filesView.editor.switchToPreviewMode": "Перемкнутися в режим попереднього перегляду",
@@ -1650,7 +1694,7 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.toast.planImported": "План імпортовано",
"rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Не вдалося прочитати файл плану",
"inlineComment.range.lines": "Рядки {start}-{end}",
"inlineComment.input.placeholder": "Додайте коментар... (Cmd+Enter, щоб зберегти)",
"inlineComment.input.placeholder": "Додайте коментар... ({shortcut}, щоб зберегти)",
"inlineComment.input.placeholderShort": "Додайте коментар...",
"inlineComment.actions.cancel": "Скасувати",
"inlineComment.actions.save": "Зберегти",
@@ -1692,6 +1736,9 @@ export const dict: Record<I18nKey, string> = {
"header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})",
"chat.recap.aria": "Підсумок сесії",
"chat.recap.label": "Підсумок:",
"chat.sessionError.title": "OpenCode зупинив цю відповідь",
"chat.sessionError.noDetails": "OpenCode не повідомив деталей. Відкрий звіт про стан (Ctrl/Cmd+Shift+L), щоб побачити останні помилки.",
"chat.sessionError.noReply": "OpenCode не почав відповідь на це повідомлення.",
"chat.goal.dialog.titleCreate": "Встановити ціль сесії",
"chat.goal.dialog.titleManage": "Ціль сесії",
"chat.goal.dialog.objectiveLabel": "Ціль",
@@ -1767,6 +1814,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.actions.openInFinder": "Відкрити у Finder",
"directoryExplorerDialog.actions.adding": "Додавання...",
"directoryExplorerDialog.actions.addProject": "Додати проєкт",
"directoryExplorerDialog.actions.addSelected": "Додати вибрані",
"directoryExplorerDialog.actions.addLocalProject": "Додати локальний проєкт",
"directoryExplorerDialog.actions.cloneRepository": "Клонувати репозиторій",
"directoryExplorerDialog.actions.cloneAndAdd": "Клонувати й додати",
@@ -1784,6 +1832,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.browse.parentDirectory": "Батьківський каталог",
"directoryExplorerDialog.browse.addedBadge": "Додано",
"directoryExplorerDialog.browse.quickAdd": "Додати",
"directoryExplorerDialog.browse.selectForAdd": "Вибрати для додавання",
"directoryExplorerDialog.footer.navigate": "Навігація",
"directoryExplorerDialog.footer.select": "Вибрати",
"directoryExplorerDialog.footer.add": "Додати",
@@ -1792,6 +1841,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.toast.desktopDeniedAccess": "Десктопному застосунку заборонено доступ до каталогу.",
"directoryExplorerDialog.toast.failedToOpenDirectory": "Не вдалося відкрити каталог",
"directoryExplorerDialog.toast.desktopCouldNotGrantAccess": "Десктопний застосунок не зміг надати доступ до файлу.",
"directoryExplorerDialog.toast.addedProjects": "Додано {count} проєкт(и)",
"directoryExplorerDialog.toast.failedToAddProject": "Не вдалося додати проєкт",
"directoryExplorerDialog.toast.cloneUrlRequired": "Введіть URL репозиторію перед клонуванням.",
"directoryExplorerDialog.toast.selectValidDirectoryPath": "Виберіть правильний шлях до каталогу.",
@@ -1840,22 +1890,18 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.focusChatInput": "Фокус на полі вводу чату",
"helpDialog.item.togglePromptNavigator": "Показати або приховати навігатор промптів",
"helpDialog.item.abortActiveRun": "Перервати активний запуск (подвійне натискання)",
"helpDialog.item.toggleRightSidebar": 'Перемкнути контекстну панель',
"helpDialog.item.openRightSidebarGitTab": 'Відкрити поверхню Git',
"helpDialog.item.openRightSidebarFilesTab": 'Відкрити поверхню файлів',
"helpDialog.item.toggleTerminalDock": "Перемкнути панель терміналу",
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
"helpDialog.item.switchSessionTab": "Перемкнути вкладку сесії",
"helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)",
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
"helpDialog.item.openSettings": "Відкрити налаштування",
"helpDialog.keyCombiner.or": "або",
"helpDialog.proTips.title": "Поради:",
"helpDialog.proTips.commandPalette": "Використовуйте палітру команд ({shortcut}), щоб швидко перейти до будь-якої дії",
"helpDialog.proTips.recentSessions": "5 останніх сесій відображаються на панелі команд",
"helpDialog.proTips.themeCycling": "Перемикання теми запам’ятовує ваші переваги протягом сесій",
"helpDialog.proTips.leaderSequences": "Двокрокові шорткати: натисни комбінацію, потім другу клавішу — Esc скасовує",
"header.actions.rightSidebarWithShortcut": "Права бічна панель ({shortcut})",
"header.actions.toggleRightSidebarAria": "Перемкнути праву бічну панель",
"header.actions.openAppMenu": "Меню OpenChamber",
@@ -1939,8 +1985,6 @@ export const dict: Record<I18nKey, string> = {
"session.newWorktree.noMatchingBranches": "Немає відповідних гілок",
"session.newWorktree.localBranches": "Локальні гілки",
"session.newWorktree.remoteBranches": "Віддалені гілки",
"session.newWorktree.otherLocalBranches": "Інші локальні гілки",
"session.newWorktree.otherRemoteBranches": "Інші віддалені гілки",
"session.newWorktree.branchName": "Назва гілки",
"session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature",
"session.newWorktree.actions.change": "Змінити",
@@ -2065,7 +2109,6 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "завдання",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}",
"chat.statusRow.aborted": "Перервано",
"chat.revertIndicator.redo": "Повторити",
"chat.revertIndicator.redoAria": "Повторити — відновити відкочені повідомлення",
"chat.revertPopover.title": "Відкочено",
@@ -2143,7 +2186,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
"chat.container.sessionLoadError.title": "Не вдалося завантажити сесію",
"chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.",
"chat.container.sessionLoadError.description": "Не вдалося отримати розмову — сервер може бути вимкнений або недосяжний. Нічого не втрачено; спробуй знову, коли він повернеться.",
"chat.container.sessionLoadError.authDescription": "Сесія завершилась, тож сервер відхилив запит. Увійди — і розмова завантажиться.",
"chat.container.sessionLoadError.retry": "Спробувати знову",
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
@@ -2186,10 +2230,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
"chat.textSelection.comment.attach": "Прикріпити",
"chat.textSelection.actions.newSession": "Нова сесія",
"chat.textSelection.actions.addToNotes": "Додати до нотаток",
"chat.textSelection.title.addToCurrentChat": "Додати до поточного чату",
"chat.textSelection.title.newSessionWithSelection": "Створити нову сесію із виділенням",
"chat.textSelection.title.saveInsightToNotes": "Зберегти вибраний текст у нотатках",
"chat.messageBody.actions.revertAria": "Повернутися до цього повідомлення",
"chat.messageBody.actions.revert": "Повернутися звідси",
@@ -2273,7 +2315,12 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.toast.attachmentsTooLarge": "Вкладені файли завеликі для надсилання. Спробуйте зменшити кількість або розмір зображень.",
"chat.chatInput.toast.sendAttachmentsFailed": "Не вдалося надіслати вкладення. Спробуйте зменшити кількість файлів або зображень.",
"chat.chatInput.toast.messageSendFailed": "Не вдалося надіслати повідомлення. Вкладення відновлено.",
"chat.chatInput.toast.noModelSelected": "Виберіть постачальника та модель перед надсиланням.",
"chat.chatInput.toast.clipboardAttachFailed": "Не вдалося вкласти зображення з буфера обміну",
"chat.chatInput.toast.clipboardTextAttachFailed": "Не вдалося долучити вставлений текст як файл",
"chat.chatInput.toast.largeTextPaste.title": "Виявлено великий текст",
"chat.chatInput.toast.largeTextPaste.attach": "Долучити як файл",
"chat.chatInput.toast.largeTextPaste.inline": "Вставити в повідомлення",
"chat.chatInput.toast.addedFileMentions": "Додано згадки файлів {count}",
"chat.chatInput.toast.attachFileFailed": "Не вдалося прикріпити файл",
"chat.chatInput.toast.attachNamedFailed": "Не вдалося прикріпити {name}",
@@ -2322,6 +2369,7 @@ export const dict: Record<I18nKey, string> = {
"chat.toolPart.showRawJson": "Показати сирий JSON",
"chat.toolPart.showFormattedJson": "Показати форматований JSON",
"chat.toolPart.showNavigableJson": "Показати навігаційний JSON",
"chat.toolPart.openFile": "Відкрити файл",
"chat.toolPart.openFileAtFirstChange": "Відкрити файл на першій зміні",
"chat.toolPart.openFileDiff": "Відкрити diff файлу",
"chat.toolPart.copyOutput": "Скопіювати вивід",
@@ -2434,7 +2482,7 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.subtask.title": "Делеговане завдання",
"chat.messageBody.subtask.hidePrompt": "Приховати промпт",
"chat.messageBody.subtask.showPrompt": "Показати промпт",
"chat.messageBody.subtask.openSession": "Відкрити сесія підзавдання",
"chat.messageBody.subtask.openSession": "Відкрити сесію підзавдання",
"chat.messageBody.shellCommand.title": "Команда оболонки",
"chat.messageBody.shellCommand.hideOutput": "Приховати вивід",
"chat.messageBody.shellCommand.showOutput": "Показати результат",
@@ -2453,6 +2501,15 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.toggleSidebar": "Перемкнути бічну панель",
"commandPalette.item.showContextUsage": "Показати використання контексту",
"commandPalette.item.toggleTerminal": "Перемкнути термінал",
"commandPalette.item.cycleTheme": "Перемкнути тему",
"commandPalette.item.showOpenCodeStatus": "Показати статус OpenCode",
"commandPalette.item.toggleMemoryDebug": "Показати/сховати панель memory debug",
"commandPalette.item.pinSession": "Прикріпити або відкріпити сесію",
"commandPalette.item.copySessionId": "Скопіювати ID сесії",
"commandPalette.item.openMultiRun": "Відкрити лаунчер multi-run",
"commandPalette.item.openArchive": "Відкрити архівовані сесії",
"commandPalette.item.openNotes": "Відкрити панель нотаток",
"commandPalette.item.openTodos": "Відкрити панель завдань",
"commandPalette.item.openSettings": "Відкрити налаштування...",
"commandPalette.session.untitled": "Сесія без назви",
"openCodeStatusDialog.title": "Статус OpenCode",
@@ -2667,6 +2724,9 @@ export const dict: Record<I18nKey, string> = {
"sessionAuth.error.passkeySignInCanceled": "Вхід за ключем доступу скасовано.",
"sessionAuth.error.enterPasswordForPasskey": "Введіть пароль, щоб додати ключ доступу.",
"sessionAuth.locked.tunnelTitle": "Потрібен доступ до тунелю",
"sessionAuth.expired.banner": "Сесія завершилась — увійди, щоб продовжити.",
"sessionAuth.expired.loginAction": "Увійти",
"sessionAuth.expired.sendBlocked": "Сесія завершилась — увійди, щоб надсилати повідомлення.",
"sessionAuth.locked.unlockTitle": "Розблокувати OpenChamber",
"sessionAuth.locked.tunnelDescription": "Відкрийте цей тунель за допомогою одноразового посилання для з’єднання з настільної програми.",
"sessionAuth.locked.passwordDescription": "Ця сесія захищена паролем.",
@@ -2949,6 +3009,10 @@ export const dict: Record<I18nKey, string> = {
"updateDialog.status.updating": "Оновлення...",
"updateDialog.error.updateFailed": "Помилка оновлення",
"updateDialog.error.takingLonger": "Оновлення триває довше, ніж очікувалося. Зачекайте трохи та оновіть або запустіть: openchamber update",
"updateDialog.error.signatureRejected": "Завантажене оновлення відхилено: його підпис коду не збігається з цією інсталяцією. Зазвичай це означає, що запущену копію встановлено не з офіційного підписаного релізу. Встановіть OpenChamber з офіційного релізу й оновіться ще раз.",
"updateDialog.error.updaterDisabled": "Оновлювач зупинився після невдалого встановлення. Закрийте OpenChamber, відкрийте його знову й повторіть оновлення.",
"updateDialog.error.restartFailed": "Не вдалося перезапустити, щоб встановити оновлення.",
"updateDialog.error.restartUnavailable": "Щоб встановити оновлення, потрібен застосунок OpenChamber для комп’ютера.",
"mobileUpdate.toast.available.title": "Доступне оновлення OpenChamber",
"mobileUpdate.toast.available.description": "Версія {version} готова для Android.",
"mobileUpdate.toast.actions.download": "Завантажити",
@@ -2969,6 +3033,7 @@ export const dict: Record<I18nKey, string> = {
"memoryDebugPanel.title": "Панель налагодження",
"memoryDebugPanel.tabs.memory": "Пам'ять",
"memoryDebugPanel.tabs.streaming": "Потокове передавання",
"memoryDebugPanel.tabs.requests": "Запити",
"memoryDebugPanel.section.sessionsInMemory": "Сесії в пам'яті",
"memoryDebugPanel.section.uiStreamingMetrics": "Потокові показники інтерфейсу користувача",
"memoryDebugPanel.section.vscodeBridgeMetrics": "Метрики мосту VS Code",
@@ -3006,6 +3071,16 @@ export const dict: Record<I18nKey, string> = {
"memoryDebugPanel.streaming.copy.copied": "Потокове налагодження JSON скопійовано",
"memoryDebugPanel.streaming.copy.failed": "Не вдалося скопіювати JSON",
"memoryDebugPanel.streaming.copy.hint": "Копіювання експортує метрики потокового інтерфейсу користувача та VS Code як JSON",
"memoryDebugPanel.requests.inFlight": "Виконуються",
"memoryDebugPanel.requests.peak": "Пік",
"memoryDebugPanel.requests.duration": "Тривалість",
"memoryDebugPanel.requests.totalRequests": "Усього запитів",
"memoryDebugPanel.requests.tracking": "Відстеження",
"memoryDebugPanel.requests.now": "зараз",
"memoryDebugPanel.requests.noSamples": "Запитів ще немає. Тримайте цю панель відкритою, щоб фіксувати активність fetch.",
"memoryDebugPanel.requests.chartLabel": "Запити fetch у виконанні з часом, пік {peak}",
"memoryDebugPanel.requests.windowHint": "останні {seconds} с",
"memoryDebugPanel.requests.percentileChartLabel": "Перцентилі віку запитів у виконанні (p50, p90, p99, max) з часом",
"memoryDebugPanel.common.idle": "очікування",
"memoryDebugPanel.common.live": "live",
"memoryDebugPanel.common.notAvailable": "n/a",
@@ -3104,9 +3179,10 @@ export const dict: Record<I18nKey, string> = {
"quota.window.premium": "Premium Interactions",
"quota.window.chat": "Chat Requests",
"quota.window.completions": "Completions",
"quota.window.premiumInteractions": "Premium interactions",
"quota.window.premiumInteractions": "Кредити ШІ",
'chat.workStatus.ariaLabel': 'Стан роботи',
'chat.workStatus.context.label': 'Контекст',
'chat.workStatus.cost.breakdown': "Сеанс {session} · Субагенти {subagents}",
'chat.workStatus.git.changedFileSingle': 'Змінено {count} файл',
'chat.workStatus.git.changedFilePlural': 'Змінено {count} файлів',
'chat.workStatus.pr.untitled': 'Pull request без назви',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪',
@@ -1101,7 +1102,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.overwritePrompt': '该组合已被其他快捷键使用。是否覆盖并清除原映射?',
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按键...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '请先录入一个快捷键。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍保存。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍保存。',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳转到行(文件编辑器)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '打开命令面板',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦输入框',
@@ -1110,18 +1111,20 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切换终端展开',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '将选中内容添加到聊天',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切换侧边栏',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '切换会话标签页',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '上一个会话',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一个会话',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重命名当前会话',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切换权限自动批准',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '关闭会话标签页',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
'settings.openchamber.keyboardShortcuts.action.open_help.label': '打开键盘快捷键',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '切换上下文面板中的计划',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '切换服务菜单',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '轮换服务菜单标签',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '轮换主题',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '轮换智能体',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前轮换收藏模型',
@@ -1130,6 +1133,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展开输入框',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '打开对话时间线',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '显示或隐藏提示词导航',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列与“{action}”共享上下文前缀。对应上下文生效时,该操作会优先执行。',
'settings.openchamber.keyboardShortcuts.category.session': '会话控制',
'settings.openchamber.keyboardShortcuts.category.models': '模型和智能体',
'settings.openchamber.keyboardShortcuts.category.panels': '面板和工具',
'settings.openchamber.keyboardShortcuts.category.navigation': '导航',
'settings.openchamber.keyboardShortcuts.category.application': '应用程序',
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
'settings.openchamber.keyboardShortcuts.actions.confirm': '确认',
'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多输入两个按键组合,每个组合最多同时按下三个按键。输入第一个组合后,最多等待 3 秒以输入第二个组合。点击确认应用,或点击取消放弃;按 Backspace 删除最后一个组合。',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
'settings.openchamber.keyboardShortcuts.unassigned': '未分配',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '这与 {action} 使用的序列冲突。请选择其他组合。',
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此组合已被 {action} 使用。',
'settings.openchamber.keyboardShortcuts.error.internalConflict': '此组合与内置快捷键冲突,内置快捷键不能被替换。',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '打开草稿项目选择器',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '打开草稿工作树选择器',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开最近会话',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '语音输入',
'settings.projects.sidebar.total': '总计 {count}',
'settings.projects.sidebar.actions.addProject': '添加项目',
'settings.projects.page.empty.noProjects': '暂无项目。',
@@ -1815,7 +1839,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': '服务器',
'settings.voice.page.provider.local': '本地',
'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 服务器上本地转写。模型自动下载,无需 API 密钥。',
'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(Kokoro,英语)。模型自动下载,无需 API 密钥。',
'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(英语使用 Kokoro;其他语言的模型在首次使用时下载)。无需 API 密钥。',
'settings.voice.page.field.followTextLanguage': '根据文本语言匹配语音',
'settings.voice.page.field.followTextLanguageAria': '根据文本语言匹配语音',
'settings.voice.page.field.followTextLanguageInfo': '当回复使用其他语言时,将使用该语言的语音:匹配的 macOS 语音,或首次使用时下载的本地模型。',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英语)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v325 种欧洲语言)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多语言)',
@@ -1909,7 +1936,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': '流式输出',
'settings.openchamber.visual.field.streamingAutoFollow': '流式输出时跟随新内容',
'settings.openchamber.visual.field.streamingAutoFollowAria': '在回复流式输出时自动跟随新内容',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回复流式输出时,视图会持续滚动到最新内容。关闭后视图保持不动,可手动滚动。',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回复流式输出时,视图会持续滚动到最新内容。关闭后视图保持不动,可手动滚动;此时从聊天中间发送消息也不会移动视图。',
'settings.openchamber.visual.section.messageAppearance': '消息外观',
'settings.openchamber.visual.section.toolsAndFiles': '工具和文件',
'settings.openchamber.visual.section.composer': '输入框',
@@ -2039,6 +2066,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '保留草稿消息',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文本输入框启用拼写检查',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文本输入框启用拼写检查',
'settings.openchamber.visual.field.largeTextPaste': '粘贴大段文本',
'settings.openchamber.visual.field.largeTextPasteHint': '粘贴超过约 2000 个字符或 25 行时,可选择附加为文件、直接粘贴到输入框,或每次询问。',
'settings.openchamber.visual.field.largeTextPasteAria': '大段文本粘贴行为',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文本粘贴:{option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次询问',
'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加为文件',
'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接粘贴',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '发送匿名使用报告',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。',
@@ -2197,5 +2231,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...linearIntegrationI18n['zh-CN'],
...thirdPartyIntegrationI18n['zh-CN'],
} as const;
+92 -16
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './zh-CN.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n['zh-CN'],
...linearPanelI18n['zh-CN'],
'terminalView.actions.attachSelection': '附加所选输出',
'terminalView.actions.restart': '重启终端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
@@ -38,6 +42,7 @@ export const dict: Record<I18nKey, string> = {
'common.language.korean': '韩语',
'common.language.polish': '波兰语',
'common.language.japanese': '日语',
'common.language.turkish': '土耳其语',
'common.revealPath.finder': '在 Finder 中显示',
'common.revealPath.fileExplorer': '在文件资源管理器中打开',
'common.revealPath.fileManager': '在文件管理器中打开',
@@ -130,6 +135,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '工作树',
'mobile.sessions.section.otherProjects': '切换项目',
'mobile.sessions.section.projects': '项目',
'mobile.sessions.section.chats': '聊天',
'mobile.sessions.empty.noProjectsTitle': '暂无项目',
'mobile.sessions.empty.noProjectsDescription': '添加项目以开始与代码对话。',
'mobile.sessions.empty.noSessionsTitle': '暂无会话',
@@ -384,7 +390,7 @@ export const dict: Record<I18nKey, string> = {
'multirun.launcher.attachments.attach': '附加',
'multirun.launcher.attachments.tooltip': '相同文件会发送到所有运行',
'multirun.launcher.models.label': '模型',
'multirun.launcher.models.info': '选择 2-{max} 个模型。同一模型可重复添加。',
'multirun.launcher.models.info': '选择 2 个或更多模型。同一模型可重复添加。',
'multirun.launcher.toast.fileTooLarge': '文件“{fileName}”过大(最大 10MB',
'multirun.launcher.toast.attachFailed': '附加“{fileName}”失败',
'multirun.launcher.toast.attachedSingle': '已附加 {count} 个文件',
@@ -537,11 +543,33 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树',
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作树',
'sessions.sidebar.session.menu.newWorktree': '新建工作树...',
'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树',
'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树',
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。',
'sessions.sidebar.session.moveToWorktree.main': '主工作树',
'sessions.sidebar.session.moveToWorktree.refreshing': '正在刷新工作树...',
'sessions.sidebar.session.moveToWorktree.loadFailed': '无法加载工作树',
'sessions.sidebar.session.moveToWorktree.current': '当前工作树',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '会话已移至工作树',
'sessions.sidebar.session.moveToWorktree.existingFailed': '无法将会话移至工作树',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '显示现有工作树,以及为此会话创建新工作树的选项。',
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,并将此会话及其子会话移至其中。当源有未提交的更改时,由你选择是否一并转移。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。',
'sessions.sidebar.session.moveToWorktree.confirm.title': '源有未提交的更改',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作树中已更改的文件:{count}。',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 按目录而非会话跟踪这些更改。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移动此会话及其子会话,同时保持每个源文件不变。',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '转移会话目录下的更改。未暂存和未跟踪的文件在成功后离开源。',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暂存的更改保留在源中,并复制到目的地。',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '当目的地使用不同的 Git 基准时,转移可能失败。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '仅移动会话',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移动全部源更改',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '无法验证源的更改。未更改任何工作树或会话。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地无法接受源的更改。会话和源更改均未移动。请重试并选择“仅移动会话”。',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '在目的地确认移动之前连接中断。会话可能没有移动,未提交的更改可能已经在目标工作树中。重试前请先检查。',
'sessions.sidebar.session.menu.runFusion': '运行融合',
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
@@ -1144,6 +1172,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.context': '上下文',
'contextPanel.mode.preview': '预览',
'contextPanel.mode.browser': '浏览器',
'contextRail.configure.open': '配置面板',
'contextRail.configure.dialogTitle': '侧栏面板',
'contextRail.configure.dialogDescription': '选择侧栏显示哪些面板。隐藏的面板会保留数据,仍可通过命令面板打开。',
'contextRail.configure.showAll': '全部显示',
'contextRail.configure.noneWarning': '所有面板均已隐藏。',
'contextRail.aria.rail': '面板界面',
'contextPanel.editorEmpty.title': '未打开文件',
'contextPanel.editorEmpty.description': '从文件树中选择一个文件开始编辑。',
@@ -1284,6 +1317,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.browser.annotate.submit': '附加',
'contextPanel.browser.trustNotice': '在此打开的页面以对 OpenChamber 的完全访问权限运行 — 检查和截图需要此权限。仅打开你信任的站点:恶意页面可能读取你的数据或以你的身份执行操作。',
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
'contextPanel.tab.menu.close': '关闭',
'contextPanel.tab.menu.closeOthers': '关闭其他',
'contextPanel.tab.menu.closeToLeft': '关闭左侧标签',
'contextPanel.tab.menu.closeToRight': '关闭右侧标签',
'contextPanel.tab.menu.closeAll': '关闭所有标签',
'contextPanel.actions.collapsePanel': '折叠面板',
'contextPanel.actions.expandPanel': '展开面板',
'contextPanel.actions.closePanel': '关闭面板',
@@ -1380,6 +1418,12 @@ export const dict: Record<I18nKey, string> = {
'filesView.editor.disableLineWrap': '关闭自动换行',
'filesView.editor.enableLineWrap': '开启自动换行',
'filesView.editor.findInFile': '文件内查找',
'filesView.preview.find.placeholder': '在预览中查找',
'filesView.preview.find.nextAria': '下一个匹配',
'filesView.preview.find.previousAria': '上一个匹配',
'filesView.preview.find.closeAria': '关闭搜索',
'filesView.preview.find.noMatches': '无匹配项',
'filesView.preview.find.countAria': '第 {current} 个,共 {total} 个',
'filesView.editor.goToLine': '跳转到行',
'filesView.editor.switchToEditMode': '切换到编辑模式',
'filesView.editor.switchToPreviewMode': '切换到预览模式',
@@ -1638,7 +1682,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.planImported': '计划已导入',
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '读取计划文件失败',
'inlineComment.range.lines': '行 {start}-{end}',
'inlineComment.input.placeholder': '添加评论...Cmd+Enter 保存)',
'inlineComment.input.placeholder': '添加评论...{shortcut} 保存)',
'inlineComment.input.placeholderShort': '添加评论…',
'inlineComment.actions.cancel': '取消',
'inlineComment.actions.save': '保存',
@@ -1680,6 +1724,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': '终端面板({shortcut}',
'chat.recap.aria': '会话回顾',
'chat.recap.label': '回顾:',
'chat.sessionError.title': 'OpenCode 停止了本次回复',
'chat.sessionError.noDetails': 'OpenCode 未报告任何详情。打开状态报告(Ctrl/Cmd+Shift+L)查看最近的错误。',
'chat.sessionError.noReply': 'OpenCode 没有开始回复这条消息。',
'chat.goal.dialog.titleCreate': '设置会话目标',
'chat.goal.dialog.titleManage': '会话目标',
'chat.goal.dialog.objectiveLabel': '目标',
@@ -1755,6 +1802,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.actions.openInFinder': '在 Finder 中打开',
'directoryExplorerDialog.actions.adding': '添加中...',
'directoryExplorerDialog.actions.addProject': '添加项目',
'directoryExplorerDialog.actions.addSelected': '添加所选项目',
'directoryExplorerDialog.actions.addLocalProject': '添加本地项目',
'directoryExplorerDialog.actions.cloneRepository': '克隆仓库',
'directoryExplorerDialog.actions.cloneAndAdd': '克隆并添加',
@@ -1772,6 +1820,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.parentDirectory': '上级目录',
'directoryExplorerDialog.browse.addedBadge': '已添加',
'directoryExplorerDialog.browse.quickAdd': '添加',
'directoryExplorerDialog.browse.selectForAdd': '选择以添加',
'directoryExplorerDialog.footer.navigate': '导航',
'directoryExplorerDialog.footer.select': '选择',
'directoryExplorerDialog.footer.add': '添加',
@@ -1780,6 +1829,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': '桌面端拒绝了目录访问。',
'directoryExplorerDialog.toast.failedToOpenDirectory': '打开目录失败',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '桌面端无法授予文件访问权限。',
'directoryExplorerDialog.toast.addedProjects': '已添加 {count} 个项目',
'directoryExplorerDialog.toast.failedToAddProject': '添加项目失败',
'directoryExplorerDialog.toast.cloneUrlRequired': '克隆前请输入仓库 URL。',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '请选择有效的目录路径。',
@@ -1828,22 +1878,18 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': '聚焦聊天输入框',
'helpDialog.item.togglePromptNavigator': '显示或隐藏提示词导航',
'helpDialog.item.abortActiveRun': '中止当前运行(双击)',
'helpDialog.item.toggleRightSidebar': '切换上下文面板',
'helpDialog.item.openRightSidebarGitTab': '打开 Git 界面',
'helpDialog.item.openRightSidebarFilesTab': '打开文件界面',
'helpDialog.item.toggleTerminalDock': '切换终端停靠栏',
'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态',
'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板',
'helpDialog.item.cycleTheme': '循环切换主题(浅色 → 深色 → 跟随系统)',
'helpDialog.item.switchSessionTab': '切换会话标签页',
'helpDialog.item.switchContextSurface': '切换上下文面板界面(数字键)',
'helpDialog.item.toggleServicesMenu': '切换服务菜单',
'helpDialog.item.cycleServicesTab': '循环服务标签',
'helpDialog.item.openSettings': '打开设置',
'helpDialog.keyCombiner.or': '或',
'helpDialog.proTips.title': '使用提示:',
'helpDialog.proTips.commandPalette': '使用命令面板({shortcut})可快速访问所有操作',
'helpDialog.proTips.recentSessions': '最近 5 个会话会显示在命令面板中',
'helpDialog.proTips.themeCycling': '主题循环会记住你在各会话中的偏好',
'helpDialog.proTips.leaderSequences': '两段式快捷键:先按组合键,再按第二个键(Esc 取消)',
'header.actions.rightSidebarWithShortcut': '右侧边栏({shortcut}',
'header.actions.toggleRightSidebarAria': '切换右侧边栏',
'header.actions.openAppMenu': 'OpenChamber 菜单',
@@ -1927,8 +1973,6 @@ export const dict: Record<I18nKey, string> = {
'session.newWorktree.noMatchingBranches': '没有匹配分支',
'session.newWorktree.localBranches': '本地分支',
'session.newWorktree.remoteBranches': '远程分支',
'session.newWorktree.otherLocalBranches': '其他本地分支',
'session.newWorktree.otherRemoteBranches': '其他远程分支',
'session.newWorktree.branchName': '分支名',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '更改',
@@ -2053,7 +2097,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '任务',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个',
'chat.statusRow.aborted': '已中止',
'chat.revertIndicator.redo': '重做',
'chat.revertIndicator.redoAria': '重做 — 恢复已撤回的消息',
'chat.revertPopover.title': '已撤回',
@@ -2131,7 +2174,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。',
'chat.container.sessionLoadError.title': '无法加载会话',
'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。',
'chat.container.sessionLoadError.description': '无法获取对话——服务器可能已关闭或无法访问。内容没有丢失;等它恢复后重试即可。',
'chat.container.sessionLoadError.authDescription': '会话已过期,服务器拒绝了请求。登录后对话即会加载。',
'chat.container.sessionLoadError.retry': '重试',
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
@@ -2174,10 +2218,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '评论所选内容',
'chat.textSelection.comment.placeholder': '添加可选评论...',
'chat.textSelection.comment.attach': '附加',
'chat.textSelection.actions.newSession': '新建会话',
'chat.textSelection.actions.addToNotes': '添加到笔记',
'chat.textSelection.title.addToCurrentChat': '添加到当前聊天',
'chat.textSelection.title.newSessionWithSelection': '使用选中内容创建新会话',
'chat.textSelection.title.saveInsightToNotes': '将选中文本保存到笔记',
'chat.messageBody.actions.revertAria': '回退到这条消息',
'chat.messageBody.actions.revert': '从此处回退',
@@ -2273,7 +2315,12 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.attachmentsTooLarge': '附件过大,无法发送。请减少图片数量或大小。',
'chat.chatInput.toast.sendAttachmentsFailed': '发送附件失败。请尝试更少文件或更小图片。',
'chat.chatInput.toast.messageSendFailed': '消息发送失败,附件已恢复。',
'chat.chatInput.toast.noModelSelected': '发送前请先选择提供商和模型。',
'chat.chatInput.toast.clipboardAttachFailed': '从剪贴板附加图片失败',
'chat.chatInput.toast.clipboardTextAttachFailed': '无法将粘贴的文本附加为文件',
'chat.chatInput.toast.largeTextPaste.title': '检测到大段文本',
'chat.chatInput.toast.largeTextPaste.attach': '附加为文件',
'chat.chatInput.toast.largeTextPaste.inline': '直接粘贴',
'chat.chatInput.toast.addedFileMentions': '已添加 {count} 个文件提及',
'chat.chatInput.toast.attachFileFailed': '附加文件失败',
'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失败',
@@ -2322,6 +2369,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.showRawJson': '显示原始 JSON',
'chat.toolPart.showFormattedJson': '显示格式化 JSON',
'chat.toolPart.showNavigableJson': '显示可导航 JSON',
'chat.toolPart.openFile': '打开文件',
'chat.toolPart.openFileAtFirstChange': '在首次更改处打开文件',
'chat.toolPart.openFileDiff': '打开文件差异',
'chat.toolPart.copyOutput': '复制输出',
@@ -2453,6 +2501,15 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.toggleSidebar': '切换侧边栏',
'commandPalette.item.showContextUsage': '显示上下文用量',
'commandPalette.item.toggleTerminal': '切换终端',
'commandPalette.item.cycleTheme': '轮换主题',
'commandPalette.item.showOpenCodeStatus': '显示 OpenCode 状态',
'commandPalette.item.toggleMemoryDebug': '切换内存调试面板',
'commandPalette.item.pinSession': '固定或取消固定会话',
'commandPalette.item.copySessionId': '复制会话 ID',
'commandPalette.item.openMultiRun': '打开多任务启动器',
'commandPalette.item.openArchive': '打开已归档会话',
'commandPalette.item.openNotes': '打开笔记面板',
'commandPalette.item.openTodos': '打开待办面板',
'commandPalette.item.openSettings': '打开设置...',
'commandPalette.session.untitled': '未命名会话',
'openCodeStatusDialog.title': 'OpenCode 状态',
@@ -2667,6 +2724,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey 登录已取消。',
'sessionAuth.error.enterPasswordForPasskey': '请输入密码以添加 passkey。',
'sessionAuth.locked.tunnelTitle': '需要隧道访问',
'sessionAuth.expired.banner': '会话已过期——请登录以继续。',
'sessionAuth.expired.loginAction': '登录',
'sessionAuth.expired.sendBlocked': '会话已过期——请登录后再发送消息。',
'sessionAuth.locked.unlockTitle': '解锁 OpenChamber',
'sessionAuth.locked.tunnelDescription': '请使用桌面应用提供的一次性连接链接打开该隧道。',
'sessionAuth.locked.passwordDescription': '此会话受密码保护。',
@@ -2949,6 +3009,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新失败',
'updateDialog.error.takingLonger': '更新耗时超出预期。请稍等后刷新,或运行:openchamber update',
'updateDialog.error.signatureRejected': '下载的更新被拒绝:其代码签名与当前安装不匹配。这通常说明正在运行的副本不是从官方签名版本安装的。请从官方版本安装 OpenChamber,然后再次更新。',
'updateDialog.error.updaterDisabled': '一次安装失败后,更新程序已停止。请退出 OpenChamber,重新打开后再试一次更新。',
'updateDialog.error.restartFailed': '无法重启以安装更新。',
'updateDialog.error.restartUnavailable': '安装更新需要 OpenChamber 桌面应用。',
'mobileUpdate.toast.available.title': 'OpenChamber 更新可用',
'mobileUpdate.toast.available.description': '版本 {version} 已可用于 Android。',
'mobileUpdate.toast.actions.download': '下载',
@@ -2969,6 +3033,7 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.title': '调试面板',
'memoryDebugPanel.tabs.memory': '内存',
'memoryDebugPanel.tabs.streaming': '流式',
'memoryDebugPanel.tabs.requests': '请求',
'memoryDebugPanel.section.sessionsInMemory': '内存中的会话',
'memoryDebugPanel.section.uiStreamingMetrics': 'UI 流式指标',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 桥接指标',
@@ -3006,6 +3071,16 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.streaming.copy.copied': '流式调试 JSON 已复制',
'memoryDebugPanel.streaming.copy.failed': '复制 JSON 失败',
'memoryDebugPanel.streaming.copy.hint': '复制会导出 UI 与 VS Code 的流式指标 JSON',
'memoryDebugPanel.requests.inFlight': '进行中',
'memoryDebugPanel.requests.peak': '峰值',
'memoryDebugPanel.requests.duration': '时长',
'memoryDebugPanel.requests.totalRequests': '请求总数',
'memoryDebugPanel.requests.tracking': '跟踪',
'memoryDebugPanel.requests.now': '当前',
'memoryDebugPanel.requests.noSamples': '尚未记录请求。保持此面板打开以记录 fetch 活动。',
'memoryDebugPanel.requests.chartLabel': '随时间变化的进行中 fetch 请求,峰值 {peak}',
'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒',
'memoryDebugPanel.requests.percentileChartLabel': '进行中请求年龄百分位(p50、p90、p99、最大值)随时间的变化',
'memoryDebugPanel.common.idle': '空闲',
'memoryDebugPanel.common.live': '实时',
'memoryDebugPanel.common.notAvailable': '无',
@@ -3104,9 +3179,10 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premium': 'Premium Interactions',
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'quota.window.premiumInteractions': 'AI 点数',
'chat.workStatus.ariaLabel': '工作状态',
'chat.workStatus.context.label': '上下文',
'chat.workStatus.cost.breakdown': '会话 {session} · 子智能体 {subagents}',
'chat.workStatus.git.changedFileSingle': '已更改 {count} 个文件',
'chat.workStatus.git.changedFilePlural': '已更改 {count} 个文件',
'chat.workStatus.pr.untitled': '未命名的拉取请求',
@@ -1,3 +1,4 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤',
@@ -1008,7 +1009,7 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.overwritePrompt': '該組合已被其他快速鍵使用。是否覆寫並清除原對應?',
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按鍵...',
'settings.openchamber.keyboardShortcuts.error.captureFirst': '請先錄入一個快速鍵。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍儲存。',
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍儲存。',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳轉到行(檔案編輯器)',
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '開啟命令面板',
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦輸入方塊',
@@ -1017,18 +1018,20 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切換終端機展開',
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '將選取內容加入聊天',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切換側邊欄',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '切換工作階段分頁',
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段',
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '上一個工作階段',
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一個工作階段',
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重新命名目前的工作階段',
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切換權限自動核准',
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '關閉工作階段分頁',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
'settings.openchamber.keyboardShortcuts.action.open_help.label': '開啟鍵盤快速鍵',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '切換上下文面板中的計畫',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '切換服務選單',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '輪換服務選單分頁',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '輪換主題',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '輪換 agent',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前輪換收藏模型',
@@ -1037,6 +1040,27 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展開輸入方塊',
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '開啟對話時間軸',
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '顯示或隱藏提示詞導覽',
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列與「{action}」共用情境前綴。對應情境生效時,該操作會優先執行。',
'settings.openchamber.keyboardShortcuts.category.session': '工作階段控制',
'settings.openchamber.keyboardShortcuts.category.models': '模型與代理',
'settings.openchamber.keyboardShortcuts.category.panels': '面板與工具',
'settings.openchamber.keyboardShortcuts.category.navigation': '導覽',
'settings.openchamber.keyboardShortcuts.category.application': '應用程式',
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}',
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多輸入兩個按鍵組合,每個組合最多同時按下三個按鍵。輸入第一個組合後,最多等待 3 秒以輸入第二個組合。點擊確認套用,或點擊取消放棄;按 Backspace 刪除最後一個組合。',
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合',
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…',
'settings.openchamber.keyboardShortcuts.unassigned': '未指派',
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '這與 {action} 使用的序列衝突。請選擇其他組合。',
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此組合已由 {action} 使用。',
'settings.openchamber.keyboardShortcuts.error.internalConflict': '此組合與內建快捷鍵衝突,內建快捷鍵不能被取代。',
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '開啟草稿專案選擇器',
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '開啟草稿 worktree 選擇器',
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟最近工作階段',
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '語音輸入',
'settings.projects.sidebar.total': '總計 {count}',
'settings.projects.sidebar.actions.addProject': '新增專案',
'settings.projects.page.empty.noProjects': '暫無專案。',
@@ -1722,7 +1746,10 @@ export const settingsDict = {
'settings.voice.page.provider.server': '伺服器',
'settings.voice.page.provider.local': '本機',
'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 伺服器上本機轉寫。模型會自動下載,無需 API 金鑰。',
'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(Kokoro,英文)。模型會自動下載,無需 API 金鑰。',
'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(英文使用 Kokoro;其他語言的模型在首次使用時下載)。不需要 API 金鑰。',
'settings.voice.page.field.followTextLanguage': '依文字語言選擇語音',
'settings.voice.page.field.followTextLanguageAria': '依文字語言選擇語音',
'settings.voice.page.field.followTextLanguageInfo': '當回覆使用其他語言時,會使用該語言的語音:相符的 macOS 語音,或首次使用時下載的本機模型。',
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英文)',
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v325 種歐洲語言)',
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多語言)',
@@ -1816,7 +1843,7 @@ export const settingsDict = {
'settings.openchamber.visual.section.streaming': '串流',
'settings.openchamber.visual.field.streamingAutoFollow': '串流時跟隨新內容',
'settings.openchamber.visual.field.streamingAutoFollowAria': '回覆串流時自動跟隨新內容',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回覆串流時,畫面會持續捲動到最新內容。關閉後畫面保持不動,可手動捲動。',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回覆串流時,畫面會持續捲動到最新內容。關閉後畫面保持不動,可手動捲動;此時從聊天中間傳送訊息也不會移動畫面。',
'settings.openchamber.visual.section.messageAppearance': '訊息外觀',
'settings.openchamber.visual.section.toolsAndFiles': '工具與檔案',
'settings.openchamber.visual.section.composer': '輸入框',
@@ -1946,6 +1973,13 @@ export const settingsDict = {
'settings.openchamber.visual.field.persistDraftMessages': '保留草稿訊息',
'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文字輸入方塊啟用拼寫檢查',
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文字輸入方塊啟用拼寫檢查',
'settings.openchamber.visual.field.largeTextPaste': '貼上大段文字',
'settings.openchamber.visual.field.largeTextPasteHint': '貼上超過約 2000 個字元或 25 行時,可選擇附加為檔案、直接貼到輸入框,或每次詢問。',
'settings.openchamber.visual.field.largeTextPasteAria': '大段文字貼上行為',
'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文字貼上:{option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次詢問',
'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加為檔案',
'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接貼上',
'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '送出匿名使用報告',
'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。',
@@ -2197,5 +2231,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...linearIntegrationI18n['zh-TW'],
...thirdPartyIntegrationI18n['zh-TW'],
} as const;
+92 -16
View File
@@ -1,8 +1,12 @@
import type { I18nKey } from './en';
import { settingsDict } from './zh-TW.settings';
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
...settingsDict,
...linearIssuePickerI18n['zh-TW'],
...linearPanelI18n['zh-TW'],
'terminalView.actions.attachSelection': '附加所選輸出',
'terminalView.actions.restart': '重新啟動終端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
@@ -38,6 +42,7 @@ export const dict: Record<I18nKey, string> = {
'common.language.korean': '韓語',
'common.language.polish': '波蘭語',
'common.language.japanese': '日語',
'common.language.turkish': '土耳其語',
'common.revealPath.finder': '在 Finder 中顯示',
'common.revealPath.fileExplorer': '在檔案總管中開啟',
'common.revealPath.fileManager': '在檔案管理員中開啟',
@@ -130,6 +135,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '工作樹',
'mobile.sessions.section.otherProjects': '切換專案',
'mobile.sessions.section.projects': '專案',
'mobile.sessions.section.chats': '聊天',
'mobile.sessions.empty.noProjectsTitle': '尚無專案',
'mobile.sessions.empty.noProjectsDescription': '新增專案即可開始與程式碼聊天。',
'mobile.sessions.empty.noSessionsTitle': '尚無會話',
@@ -397,7 +403,7 @@ export const dict: Record<I18nKey, string> = {
'multirun.launcher.attachments.attach': '附加',
'multirun.launcher.attachments.tooltip': '相同檔案會傳送到所有執行',
'multirun.launcher.models.label': '模型',
'multirun.launcher.models.info': '選擇 2-{max} 個模型。同一模型可重複加入。',
'multirun.launcher.models.info': '選擇 2 個或更多模型。同一模型可重複加入。',
'multirun.launcher.toast.fileTooLarge': '檔案「{fileName}」過大(最大 10MB',
'multirun.launcher.toast.attachFailed': '附加「{fileName}」失敗',
'multirun.launcher.toast.attachedSingle': '已附加 {count} 個檔案',
@@ -550,11 +556,33 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹',
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作樹',
'sessions.sidebar.session.menu.newWorktree': '新增工作樹...',
'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹',
'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹',
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。',
'sessions.sidebar.session.moveToWorktree.main': '主要工作樹',
'sessions.sidebar.session.moveToWorktree.refreshing': '正在重新整理工作樹...',
'sessions.sidebar.session.moveToWorktree.loadFailed': '無法載入工作樹',
'sessions.sidebar.session.moveToWorktree.current': '目前的工作樹',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '工作階段已移至工作樹',
'sessions.sidebar.session.moveToWorktree.existingFailed': '無法將工作階段移至工作樹',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '顯示現有工作樹,以及為此工作階段建立新工作樹的選項。',
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,並將此工作階段及其子工作階段移至其中。當來源有未提交的變更時,由你選擇是否一併轉移。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。',
'sessions.sidebar.session.moveToWorktree.confirm.title': '來源有未提交的變更',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作樹中已變更的檔案:{count}。',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 依目錄而非工作階段追蹤這些變更。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移動此工作階段及其子工作階段,同時保持每個來源檔案不變。',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '轉移工作階段目錄下的變更。未暫存與未追蹤的檔案在成功後離開來源。',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暫存的變更保留在來源中,並複製到目的地。',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '當目的地使用不同的 Git 基礎時,轉移可能失敗。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '僅移動工作階段',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移動全部來源變更',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '無法驗證來源的變更。未變更任何工作樹或工作階段。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地無法接受來源的變更。工作階段與來源變更均未移動。請重試並選擇「僅移動工作階段」。',
'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '在目的地確認移動之前連線中斷。工作階段可能沒有移動,未提交的變更可能已經在目標工作樹中。重試前請先檢查。',
'sessions.sidebar.session.menu.runFusion': '執行 fusion',
'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟',
'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟',
@@ -1156,6 +1184,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.context': '上下文',
'contextPanel.mode.preview': '預覽',
'contextPanel.mode.browser': '瀏覽器',
'contextRail.configure.open': '設定面板',
'contextRail.configure.dialogTitle': '側欄面板',
'contextRail.configure.dialogDescription': '選擇側欄顯示哪些面板。隱藏的面板會保留資料,仍可透過命令面板開啟。',
'contextRail.configure.showAll': '全部顯示',
'contextRail.configure.noneWarning': '所有面板皆已隱藏。',
'contextRail.aria.rail': '面板介面',
'contextPanel.editorEmpty.title': '未開啟檔案',
'contextPanel.editorEmpty.description': '從檔案樹選擇檔案開始編輯。',
@@ -1296,6 +1329,11 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.browser.annotate.submit': '附加',
'contextPanel.browser.trustNotice': '在此開啟的頁面以對 OpenChamber 的完整存取權限執行 — 檢查與截圖需要此權限。僅開啟你信任的網站:惡意頁面可能讀取你的資料或以你的身分執行操作。',
'contextPanel.tab.closeTabAria': '關閉 {label} 分頁',
'contextPanel.tab.menu.close': '關閉',
'contextPanel.tab.menu.closeOthers': '關閉其他',
'contextPanel.tab.menu.closeToLeft': '關閉左側分頁',
'contextPanel.tab.menu.closeToRight': '關閉右側分頁',
'contextPanel.tab.menu.closeAll': '關閉所有分頁',
'contextPanel.actions.collapsePanel': '摺疊面板',
'contextPanel.actions.expandPanel': '展開面板',
'contextPanel.actions.closePanel': '關閉面板',
@@ -1391,6 +1429,12 @@ export const dict: Record<I18nKey, string> = {
'filesView.editor.disableLineWrap': '關閉自動換行',
'filesView.editor.enableLineWrap': '開啟自動換行',
'filesView.editor.findInFile': '檔案內尋找',
'filesView.preview.find.placeholder': '在預覽中尋找',
'filesView.preview.find.nextAria': '下一個相符項目',
'filesView.preview.find.previousAria': '上一個相符項目',
'filesView.preview.find.closeAria': '關閉搜尋',
'filesView.preview.find.noMatches': '無相符項目',
'filesView.preview.find.countAria': '第 {current} 個,共 {total} 個',
'filesView.editor.goToLine': '跳轉到行',
'filesView.editor.switchToEditMode': '切換到編輯模式',
'filesView.editor.switchToPreviewMode': '切換到預覽模式',
@@ -1648,7 +1692,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.planImported': '計畫已匯入',
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '讀取計畫檔案失敗',
'inlineComment.range.lines': '行 {start}-{end}',
'inlineComment.input.placeholder': '新增留言...Cmd+Enter 儲存)',
'inlineComment.input.placeholder': '新增留言...{shortcut} 儲存)',
'inlineComment.input.placeholderShort': '新增留言…',
'inlineComment.actions.cancel': '取消',
'inlineComment.actions.save': '儲存',
@@ -1684,6 +1728,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut}',
'chat.recap.aria': '工作階段回顧',
'chat.recap.label': '回顧:',
'chat.sessionError.title': 'OpenCode 停止了本次回覆',
'chat.sessionError.noDetails': 'OpenCode 未回報任何詳情。開啟狀態報告(Ctrl/Cmd+Shift+L)查看最近的錯誤。',
'chat.sessionError.noReply': 'OpenCode 沒有開始回覆這則訊息。',
'chat.goal.dialog.titleCreate': '設定工作階段目標',
'chat.goal.dialog.titleManage': '工作階段目標',
'chat.goal.dialog.objectiveLabel': '目標',
@@ -1759,6 +1806,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.actions.openInFinder': '在 Finder 中開啟',
'directoryExplorerDialog.actions.adding': '新增中...',
'directoryExplorerDialog.actions.addProject': '新增專案',
'directoryExplorerDialog.actions.addSelected': '新增所選項目',
'directoryExplorerDialog.actions.addLocalProject': '新增本地專案',
'directoryExplorerDialog.actions.cloneRepository': '複製儲存庫',
'directoryExplorerDialog.actions.cloneAndAdd': '複製並新增',
@@ -1776,6 +1824,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.parentDirectory': '上層目錄',
'directoryExplorerDialog.browse.addedBadge': '已新增',
'directoryExplorerDialog.browse.quickAdd': '添加',
'directoryExplorerDialog.browse.selectForAdd': '選取以新增',
'directoryExplorerDialog.footer.navigate': '導覽',
'directoryExplorerDialog.footer.select': '選擇',
'directoryExplorerDialog.footer.add': '新增',
@@ -1784,6 +1833,7 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.toast.desktopDeniedAccess': '桌面端拒絕了目錄存取。',
'directoryExplorerDialog.toast.failedToOpenDirectory': '開啟目錄失敗',
'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': '桌面端無法授予檔案存取權限。',
'directoryExplorerDialog.toast.addedProjects': '已新增 {count} 個專案',
'directoryExplorerDialog.toast.failedToAddProject': '新增專案失敗',
'directoryExplorerDialog.toast.cloneUrlRequired': '複製前請輸入儲存庫 URL。',
'directoryExplorerDialog.toast.selectValidDirectoryPath': '請選擇有效的目錄路徑。',
@@ -1832,22 +1882,18 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': '聚焦聊天輸入框',
'helpDialog.item.togglePromptNavigator': '顯示或隱藏提示詞導覽',
'helpDialog.item.abortActiveRun': '中止目前執行(連按兩下)',
'helpDialog.item.toggleRightSidebar': '切換上下文面板',
'helpDialog.item.openRightSidebarGitTab': '開啟 Git 介面',
'helpDialog.item.openRightSidebarFilesTab': '開啟檔案介面',
'helpDialog.item.toggleTerminalDock': '切換終端機停靠欄',
'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態',
'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板',
'helpDialog.item.cycleTheme': '循環切換主題(淺色 → 深色 → 跟隨系統)',
'helpDialog.item.switchSessionTab': '切換工作階段分頁',
'helpDialog.item.switchContextSurface': '切換上下文面板介面(數字鍵)',
'helpDialog.item.toggleServicesMenu': '切換服務選單',
'helpDialog.item.cycleServicesTab': '循環服務標籤',
'helpDialog.item.openSettings': '開啟設定',
'helpDialog.keyCombiner.or': '或',
'helpDialog.proTips.title': '使用提示:',
'helpDialog.proTips.commandPalette': '使用命令面板({shortcut})可快速存取所有操作',
'helpDialog.proTips.recentSessions': '最近 5 個會话會顯示在命令面板中',
'helpDialog.proTips.themeCycling': '主題循環會記住你在各會話中的偏好',
'helpDialog.proTips.leaderSequences': '兩段式快捷鍵:先按組合鍵,再按第二個鍵(Esc 取消)',
'header.actions.rightSidebarWithShortcut': '右側邊欄({shortcut}',
'header.actions.toggleRightSidebarAria': '切換右側邊欄',
'header.actions.openAppMenu': 'OpenChamber 選單',
@@ -1931,8 +1977,6 @@ export const dict: Record<I18nKey, string> = {
'session.newWorktree.noMatchingBranches': '沒有符合分支',
'session.newWorktree.localBranches': '本地分支',
'session.newWorktree.remoteBranches': '遠端分支',
'session.newWorktree.otherLocalBranches': '其他本地分支',
'session.newWorktree.otherRemoteBranches': '其他遠端分支',
'session.newWorktree.branchName': '分支名稱',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '變更',
@@ -2057,7 +2101,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '任務',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} 個活躍 · 剩餘 {left} 個',
'chat.statusRow.aborted': '已中止',
'chat.revertIndicator.redo': '重做',
'chat.revertIndicator.redoAria': '重做 — 恢復已收回的訊息',
'chat.revertPopover.title': '已收回',
@@ -2135,7 +2178,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。',
'chat.container.sessionLoadError.title': '無法載入工作階段',
'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。',
'chat.container.sessionLoadError.description': '無法取得對話——伺服器可能已關閉或無法連線。內容沒有遺失;待其恢復後再試即可。',
'chat.container.sessionLoadError.authDescription': '工作階段已過期,伺服器拒絕了請求。登入後對話即會載入。',
'chat.container.sessionLoadError.retry': '再試一次',
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
@@ -2178,10 +2222,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
'chat.textSelection.comment.placeholder': '新增選填留言...',
'chat.textSelection.comment.attach': '附加',
'chat.textSelection.actions.newSession': '新增會話',
'chat.textSelection.actions.addToNotes': '加入筆記',
'chat.textSelection.title.addToCurrentChat': '加入目前聊天',
'chat.textSelection.title.newSessionWithSelection': '使用選取內容建立新會話',
'chat.textSelection.title.saveInsightToNotes': '將選取文字儲存到筆記',
'chat.messageBody.actions.revertAria': '收回到這條訊息',
'chat.messageBody.actions.revert': '從此處收回',
@@ -2277,7 +2319,12 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.attachmentsTooLarge': '附件過大,無法傳送。請減少圖片數量或大小。',
'chat.chatInput.toast.sendAttachmentsFailed': '傳送附件失敗。請嘗試更少檔案或更小圖片。',
'chat.chatInput.toast.messageSendFailed': '訊息傳送失敗,附件已恢復。',
'chat.chatInput.toast.noModelSelected': '傳送前請先選擇提供者與模型。',
'chat.chatInput.toast.clipboardAttachFailed': '從剪貼簿附加圖片失敗',
'chat.chatInput.toast.clipboardTextAttachFailed': '無法將貼上的文字附加為檔案',
'chat.chatInput.toast.largeTextPaste.title': '偵測到大段文字',
'chat.chatInput.toast.largeTextPaste.attach': '附加為檔案',
'chat.chatInput.toast.largeTextPaste.inline': '直接貼上',
'chat.chatInput.toast.addedFileMentions': '已加入 {count} 個檔案提及',
'chat.chatInput.toast.attachFileFailed': '附加檔案失敗',
'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失敗',
@@ -2326,6 +2373,7 @@ export const dict: Record<I18nKey, string> = {
'chat.toolPart.showRawJson': '顯示原始 JSON',
'chat.toolPart.showFormattedJson': '顯示格式化 JSON',
'chat.toolPart.showNavigableJson': '顯示可導覽 JSON',
'chat.toolPart.openFile': '開啟檔案',
'chat.toolPart.openFileAtFirstChange': '在首次變更處開啟檔案',
'chat.toolPart.openFileDiff': '開啟檔案差異',
'chat.toolPart.copyOutput': '複製輸出',
@@ -2457,6 +2505,15 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.toggleSidebar': '切換側邊欄',
'commandPalette.item.showContextUsage': '顯示上下文用量',
'commandPalette.item.toggleTerminal': '切換終端機',
'commandPalette.item.cycleTheme': '輪換主題',
'commandPalette.item.showOpenCodeStatus': '顯示 OpenCode 狀態',
'commandPalette.item.toggleMemoryDebug': '切換記憶體偵錯面板',
'commandPalette.item.pinSession': '釘選或取消釘選會話',
'commandPalette.item.copySessionId': '複製會話 ID',
'commandPalette.item.openMultiRun': '開啟多任務啟動器',
'commandPalette.item.openArchive': '開啟已封存會話',
'commandPalette.item.openNotes': '開啟筆記面板',
'commandPalette.item.openTodos': '開啟待辦面板',
'commandPalette.item.openSettings': '開啟設定...',
'commandPalette.session.untitled': '未命名會話',
'openCodeStatusDialog.title': 'OpenCode 狀態',
@@ -2671,6 +2728,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey 登入已取消。',
'sessionAuth.error.enterPasswordForPasskey': '請輸入密碼以新增 passkey。',
'sessionAuth.locked.tunnelTitle': '需要 Tunnel 存取',
'sessionAuth.expired.banner': '工作階段已過期——請登入以繼續。',
'sessionAuth.expired.loginAction': '登入',
'sessionAuth.expired.sendBlocked': '工作階段已過期——請登入後再傳送訊息。',
'sessionAuth.locked.unlockTitle': '解鎖 OpenChamber',
'sessionAuth.locked.tunnelDescription': '請使用桌面應用程式提供的一次性連結開啟該 Tunnel。',
'sessionAuth.locked.passwordDescription': '此會話受密碼保護。',
@@ -2946,6 +3006,10 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新失敗',
'updateDialog.error.takingLonger': '更新耗時超出預期。請稍等後重新整理,或執行:openchamber update',
'updateDialog.error.signatureRejected': '下載的更新遭到拒絕:其程式碼簽章與目前的安裝不符。這通常表示執行中的副本不是從官方簽章版本安裝的。請從官方版本安裝 OpenChamber,再重新更新。',
'updateDialog.error.updaterDisabled': '一次安裝失敗後,更新程式已停止。請結束 OpenChamber,重新開啟後再試一次更新。',
'updateDialog.error.restartFailed': '無法重新啟動以安裝更新。',
'updateDialog.error.restartUnavailable': '安裝更新需要 OpenChamber 桌面應用程式。',
'mobileUpdate.toast.available.title': 'OpenChamber 更新可用',
'mobileUpdate.toast.available.description': '版本 {version} 已可用於 Android。',
'mobileUpdate.toast.actions.download': '下載',
@@ -2966,6 +3030,7 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.title': '偵錯面板',
'memoryDebugPanel.tabs.memory': '記憶體',
'memoryDebugPanel.tabs.streaming': '串流',
'memoryDebugPanel.tabs.requests': '請求',
'memoryDebugPanel.section.sessionsInMemory': '記憶體中的會話',
'memoryDebugPanel.section.uiStreamingMetrics': 'UI 串流指標',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 橋接指標',
@@ -3003,6 +3068,16 @@ export const dict: Record<I18nKey, string> = {
'memoryDebugPanel.streaming.copy.copied': '串流偵錯 JSON 已複製',
'memoryDebugPanel.streaming.copy.failed': '複製 JSON 失敗',
'memoryDebugPanel.streaming.copy.hint': '複製會匯出 UI 與 VS Code 的串流指標 JSON',
'memoryDebugPanel.requests.inFlight': '進行中',
'memoryDebugPanel.requests.peak': '峰值',
'memoryDebugPanel.requests.duration': '時長',
'memoryDebugPanel.requests.totalRequests': '請求總數',
'memoryDebugPanel.requests.tracking': '追蹤',
'memoryDebugPanel.requests.now': '目前',
'memoryDebugPanel.requests.noSamples': '尚未記錄請求。保持此面板開啟以記錄 fetch 活動。',
'memoryDebugPanel.requests.chartLabel': '隨時間變化的進行中 fetch 請求,峰值 {peak}',
'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒',
'memoryDebugPanel.requests.percentileChartLabel': '進行中請求年齡百分位(p50、p90、p99、最大值)隨時間的變化',
'memoryDebugPanel.common.idle': '閒置',
'memoryDebugPanel.common.live': '即時',
'memoryDebugPanel.common.notAvailable': '無',
@@ -3103,9 +3178,10 @@ export const dict: Record<I18nKey, string> = {
'quota.window.premium': 'Premium Interactions',
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'quota.window.premiumInteractions': 'AI 點數',
'chat.workStatus.ariaLabel': '工作狀態',
'chat.workStatus.context.label': '上下文',
'chat.workStatus.cost.breakdown': '工作階段 {session} · 子 Agent {subagents}',
'chat.workStatus.git.changedFileSingle': '已變更 {count} 個檔案',
'chat.workStatus.git.changedFilePlural': '已變更 {count} 個檔案',
'chat.workStatus.pr.untitled': '未命名的提取請求',
+7 -3
View File
@@ -1,10 +1,10 @@
export type Locale = 'en' | 'de' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl' | 'ja';
export type Locale = 'en' | 'de' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl' | 'ja' | 'tr';
export const LOCALES = ['en', 'de', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl', 'ja'] as const satisfies readonly Locale[];
export const LOCALES = ['en', 'de', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl', 'ja', 'tr'] as const satisfies readonly Locale[];
export const DEFAULT_LOCALE: Locale = 'en';
export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'common.language.french' | 'common.language.simplifiedChinese' | 'common.language.traditionalChinese' | 'common.language.ukrainian' | 'common.language.spanish' | 'common.language.brazilianPortuguese' | 'common.language.korean' | 'common.language.polish' | 'common.language.german' | 'common.language.japanese'> = {
export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'common.language.french' | 'common.language.simplifiedChinese' | 'common.language.traditionalChinese' | 'common.language.ukrainian' | 'common.language.spanish' | 'common.language.brazilianPortuguese' | 'common.language.korean' | 'common.language.polish' | 'common.language.german' | 'common.language.japanese' | 'common.language.turkish'> = {
en: 'common.language.english',
fr: 'common.language.french',
'zh-CN': 'common.language.simplifiedChinese',
@@ -16,6 +16,7 @@ export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'comm
pl: 'common.language.polish',
de: 'common.language.german',
ja: 'common.language.japanese',
tr: 'common.language.turkish',
};
export const LOCALE_STORAGE_KEY = 'openchamber.i18n.v1';
@@ -66,6 +67,9 @@ export function normalizeLocale(value: string | undefined | null): Locale {
if (normalized === 'pl' || normalized.startsWith('pl-')) {
return 'pl';
}
if (normalized === 'tr' || normalized.startsWith('tr-')) {
return 'tr';
}
return DEFAULT_LOCALE;
}
+3 -1
View File
@@ -46,7 +46,9 @@ async function loadDictionary(locale: Locale): Promise<I18nDictionary> {
? await import('./messages/de') as { dict: I18nDictionary }
: locale === 'ja'
? await import('./messages/ja') as { dict: I18nDictionary }
: { dict: enDict };
: locale === 'tr'
? await import('./messages/tr') as { dict: I18nDictionary }
: { dict: enDict };
dictionaries.set(locale, mod.dict);
return mod.dict;
}
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test';
import { resolveLinearMappedProjectPath } from './linearProjectMapping';
import type { LinearMappingResult } from './api/types';
const mapping = (): LinearMappingResult => ({
connected: true,
defaultProjectPath: '/default',
teams: [
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' },
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
],
});
describe('resolveLinearMappedProjectPath', () => {
test('prefers the team path over the default', () => {
expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-eng', key: 'ENG', name: 'Engineering' }))
.toBe('/eng');
});
test('falls back to the default when the team has no path', () => {
expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-des', key: 'DES', name: 'Design' }))
.toBe('/default');
});
test('matches a team by key when the id is missing', () => {
expect(resolveLinearMappedProjectPath(mapping(), { id: '', key: 'ENG', name: 'Engineering' }))
.toBe('/eng');
});
test('returns null when Linear is disconnected or unmapped', () => {
expect(resolveLinearMappedProjectPath({ connected: false }, { id: 'team-eng', key: 'ENG', name: 'Engineering' }))
.toBeNull();
expect(resolveLinearMappedProjectPath({
connected: true,
defaultProjectPath: null,
teams: [{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null }],
}, { id: 'team-des', key: 'DES', name: 'Design' })).toBeNull();
});
});
@@ -0,0 +1,24 @@
import type { LinearIssueTeam, LinearMappingResult } from '@/lib/api/types';
export function resolveLinearMappedProjectPath(
mapping: LinearMappingResult | null | undefined,
team: LinearIssueTeam | null | undefined,
): string | null {
if (!mapping || mapping.connected === false) {
return null;
}
const teams = mapping.teams ?? [];
if (team?.id) {
const byId = teams.find((entry) => entry.id === team.id);
if (byId?.projectPath) {
return byId.projectPath;
}
}
if (team?.key) {
const byKey = teams.find((entry) => entry.key === team.key);
if (byKey?.projectPath) {
return byKey.projectPath;
}
}
return mapping.defaultProjectPath?.trim() || null;
}
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { resolveLinearSessionOrigin } from './linearSessionStatus';
describe('resolveLinearSessionOrigin', () => {
const originalWindow = globalThis.window;
afterEach(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
});
});
test('uses the page origin on web', () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: { origin: 'https://app.example.com' },
},
});
expect(resolveLinearSessionOrigin()).toBe('https://app.example.com');
});
test('uses the desktop loopback origin instead of the packaged UI scheme', () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: { origin: 'openchamber-ui://app' },
__OPENCHAMBER_ELECTRON__: { runtime: 'electron' },
__OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:3001',
},
});
expect(resolveLinearSessionOrigin()).toBe('http://127.0.0.1:3001');
});
test('reports no origin when the desktop shell has no http loopback', () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: { origin: 'openchamber-ui://app' },
__OPENCHAMBER_ELECTRON__: { runtime: 'electron' },
__OPENCHAMBER_LOCAL_ORIGIN__: 'openchamber-ui://app',
},
});
// A deep link is unopenable for everyone but this machine, so the server
// gets no origin and posts no comment.
expect(resolveLinearSessionOrigin()).toBe(undefined);
});
});
@@ -0,0 +1,45 @@
import type { LinearAPI } from '@/lib/api/types';
import { isElectronShell } from '@/lib/desktop';
import { getLocalDesktopOrigin } from '@/lib/desktopCurrentHost';
function isHttpOrigin(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
/**
* Origin Linear comments should open. Packaged desktop UI lives on
* `openchamber-ui://`, which is not a URL a browser can load from Linear, so
* report the http origin the local server actually listens on instead. The
* server decides whether that origin is reachable by anyone else; a comment is
* only posted when it is.
*/
export function resolveLinearSessionOrigin(): string | undefined {
if (typeof window === 'undefined') return undefined;
if (isElectronShell()) {
const localOrigin = getLocalDesktopOrigin().trim();
if (localOrigin && isHttpOrigin(localOrigin)) {
return new URL(localOrigin).origin;
}
return undefined;
}
const origin = window.location.origin.trim();
return origin || undefined;
}
export function postLinearSessionStarted(
linear: LinearAPI | undefined,
args: { sessionId: string; issueIdentifier: string },
): void {
if (!linear?.sessionStatusPost) return;
void linear.sessionStatusPost({
kind: 'started',
sessionId: args.sessionId,
issueIdentifier: args.issueIdentifier,
sessionOrigin: resolveLinearSessionOrigin(),
}).catch(() => undefined);
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test';
import { buildIssueContextText } from './linearStartSession';
import type { LinearIssue } from '@/lib/api/types';
const issue: LinearIssue = {
id: 'issue-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
description: 'Users cannot sign in.',
comments: [],
};
describe('buildIssueContextText', () => {
test('serializes the issue and comments as JSON context', () => {
const text = buildIssueContextText({
issue,
comments: [{
id: 'comment-1',
body: 'Still broken',
createdAt: '2026-08-24T10:00:00.000Z',
user: { name: 'Ada', displayName: 'Ada Lovelace' },
}],
});
expect(text.startsWith('Linear issue context (JSON)\n')).toBe(true);
expect(text).toContain('"identifier": "ENG-12"');
expect(text).toContain('Still broken');
});
});
+235
View File
@@ -0,0 +1,235 @@
import { toast } from '@/components/ui';
import type { LinearAPI, LinearIssue, LinearIssueComment, LinearMappingResult } from '@/lib/api/types';
import type { I18nKey, I18nParams } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { modelVariantNames } from '@/lib/modelVariants';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { buildLinkedLinearIssue } from '@/lib/linkedIssues';
import { resolveLinearMappedProjectPath } from '@/lib/linearProjectMapping';
import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
type TranslateFn = (key: I18nKey, params?: I18nParams) => string;
export function buildIssueContextText(args: {
issue: LinearIssue;
comments: LinearIssueComment[];
}): string {
const payload = {
issue: args.issue,
comments: args.comments,
};
return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
}
function resolveDefaultAgentName(): string | undefined {
const configState = useConfigStore.getState();
const settingsDefaultAgent = configState.settingsDefaultAgent;
if (settingsDefaultAgent) {
return settingsDefaultAgent;
}
const visibleAgents = configState.agents.filter((agent) => !agent.hidden);
return (
configState.currentAgentName
|| visibleAgents.find((agent) => agent.mode === 'primary' || !agent.mode)?.name
|| visibleAgents[0]?.name
);
}
function resolveDefaultModelSelection(): { providerID: string; modelID: string } | null {
const configState = useConfigStore.getState();
const settingsDefaultModel = configState.settingsDefaultModel;
if (!settingsDefaultModel) {
return null;
}
const parsed = parseModelIdentifier(settingsDefaultModel);
if (!parsed) {
return null;
}
const { providerId: providerID, modelId: modelID } = parsed;
const modelMetadata = configState.getModelMetadata(providerID, modelID);
if (!modelMetadata) {
return null;
}
return { providerID, modelID };
}
function resolveDefaultVariant(providerID: string, modelID: string): string | undefined {
const configState = useConfigStore.getState();
const settingsDefaultVariant = configState.settingsDefaultVariant;
const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
? configState.currentVariant
: undefined;
const provider = configState.providers.find((entry) => entry.id === providerID);
const model = provider?.models.find((entry) => entry.id === modelID);
const variantNames = modelVariantNames(model);
if (variantNames.length === 0) {
return settingsDefaultVariant || currentVariant || undefined;
}
if (settingsDefaultVariant && variantNames.includes(settingsDefaultVariant)) {
return settingsDefaultVariant;
}
if (currentVariant && variantNames.includes(currentVariant)) {
return currentVariant;
}
return undefined;
}
export async function startLinearIssueSession(args: {
linear: LinearAPI | undefined;
issueKey: string;
createInWorktree: boolean;
mapping?: LinearMappingResult | null;
onMappingLoaded?: (mapping: LinearMappingResult) => void;
onSessionCreated?: () => void;
t: TranslateFn;
}): Promise<boolean> {
const { linear, issueKey, createInWorktree, t } = args;
if (!linear?.issueGet || !linear.mappingGet) {
toast.error(t('session.linearIssuePicker.error.runtimeUnavailable'));
return false;
}
try {
let mappingView = args.mapping;
if (!mappingView) {
mappingView = await linear.mappingGet();
args.onMappingLoaded?.(mappingView);
}
if (mappingView.connected === false) {
toast.error(t('session.linearIssuePicker.error.notConnected'));
return false;
}
const issueRes = await linear.issueGet(issueKey);
if (issueRes.connected === false) {
toast.error(t('session.linearIssuePicker.error.notConnected'));
return false;
}
const issue = issueRes.issue;
if (!issue) {
toast.error(t('session.linearIssuePicker.error.issueNotFound'));
return false;
}
const projectDirectory = resolveLinearMappedProjectPath(mappingView, issue.team);
if (!projectDirectory) {
toast.error(t('session.linearIssuePicker.error.noMappedProject'));
return false;
}
const comments = issue.comments ?? [];
const sessionTitle = `${issue.identifier} ${issue.title}`.trim();
const login = issue.assignee?.displayName || issue.assignee?.name;
const { sessionId, sessionDirectory } = await (async () => {
if (createInWorktree) {
const preferred = `issue-${issue.identifier}-${generateBranchSlug()}`;
const created = await createWorktreeSessionForNewBranch(
projectDirectory,
preferred,
undefined,
{ returnAfterDirectoryCreated: true },
);
if (!created?.id) {
throw new Error('Failed to create worktree session');
}
return { sessionId: created.id, sessionDirectory: created.path };
}
const session = await sessionActions.createSession(sessionTitle, projectDirectory, null);
if (!session?.id) {
throw new Error('Failed to create session');
}
return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory };
})();
void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
try {
useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
} catch {
// ignore
}
args.onSessionCreated?.();
useUIStore.getState().closeMainSurfaces();
useUIStore.getState().setSessionSwitcherOpen(false);
postLinearSessionStarted(linear, {
sessionId,
issueIdentifier: issue.identifier,
});
const configState = useConfigStore.getState();
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
const defaultModel = resolveDefaultModelSelection();
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
if (!providerID || !modelID) {
toast.error(t('session.linearIssuePicker.error.noModelSelected'));
return true;
}
const variant = resolveDefaultVariant(providerID, modelID);
const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
identifier: issue.identifier,
});
const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
const contextText = buildIssueContextText({ issue, comments });
void sessionActions.setLinkedIssue(
sessionId,
sessionDirectory,
buildLinkedLinearIssue({
identifier: issue.identifier,
title: issue.title,
url: issue.url,
author: login
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
: undefined,
linkedAt: Date.now(),
}),
true,
).catch(() => undefined);
void useSessionUIStore.getState().sendMessage(
visiblePromptText,
providerID,
modelID,
agentName,
undefined,
undefined,
[
{ text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true },
],
variant,
undefined,
{ sessionId, directory: sessionDirectory },
).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
toast.error(t('session.linearIssuePicker.toast.sendContextFailed'), {
description: message,
});
});
toast.success(t('session.linearIssuePicker.toast.sessionCreated'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(t('session.linearIssuePicker.toast.startSessionFailed'), { description: message });
return false;
}
}
+75 -2
View File
@@ -1,8 +1,10 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { buildLinkedIssue, buildLinkedIssueId, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues';
import { buildLinkedIssue, buildLinkedIssueId, buildLinkedLinearIssue, canOpenLinearIssueInContextPanel, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues';
const issue = (overrides: Partial<LinkedIssue> = {}): LinkedIssue => ({
type LinkedGitHubIssue = Exclude<LinkedIssue, { kind: 'linear' }>;
const issue = (overrides: Partial<LinkedGitHubIssue> = {}): LinkedGitHubIssue => ({
id: 'owner/repo#12',
number: 12,
title: 'Rail badge count',
@@ -76,6 +78,28 @@ describe('buildLinkedIssue', () => {
});
});
describe('buildLinkedLinearIssue', () => {
test('stores the Linear identifier without inventing a GitHub number', () => {
const built = buildLinkedLinearIssue({
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
author: { login: 'Ada', avatarUrl: 'https://avatars/1' },
linkedAt: 5,
});
expect(built).toEqual({
id: 'linear:ENG-12',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
kind: 'linear',
author: 'Ada',
authorAvatarUrl: 'https://avatars/1',
linkedAt: 5,
});
});
});
describe('getLinkedIssues', () => {
test('returns an empty list for a session with no metadata', () => {
expect(getLinkedIssues(undefined)).toEqual([]);
@@ -95,6 +119,17 @@ describe('getLinkedIssues', () => {
expect(getLinkedIssues(session)).toEqual([good]);
});
test('keeps Linear entries next to GitHub ones', () => {
const github = issue();
const linear = buildLinkedLinearIssue({
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
linkedAt: 2,
});
expect(getLinkedIssues(sessionWith([github, linear]))).toEqual([github, linear]);
});
test('survives a non-array payload', () => {
expect(getLinkedIssues(sessionWith({ nope: true }))).toEqual([]);
});
@@ -143,3 +178,41 @@ describe('withLinkedIssue', () => {
expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([issue()]);
});
});
describe('canOpenLinearIssueInContextPanel', () => {
test('opens the rail when Linear is connected, the shell has a context panel, and a directory is known', () => {
expect(canOpenLinearIssueInContextPanel({
linearAvailable: true,
linearConnected: true,
inDedicatedMobileShell: false,
directory: '/repo',
})).toBe(true);
});
test('falls back when Linear is missing, disconnected, the mobile shell is open, or the directory is blank', () => {
expect(canOpenLinearIssueInContextPanel({
linearAvailable: false,
linearConnected: true,
inDedicatedMobileShell: false,
directory: '/repo',
})).toBe(false);
expect(canOpenLinearIssueInContextPanel({
linearAvailable: true,
linearConnected: false,
inDedicatedMobileShell: false,
directory: '/repo',
})).toBe(false);
expect(canOpenLinearIssueInContextPanel({
linearAvailable: true,
linearConnected: true,
inDedicatedMobileShell: true,
directory: '/repo',
})).toBe(false);
expect(canOpenLinearIssueInContextPanel({
linearAvailable: true,
linearConnected: true,
inDedicatedMobileShell: false,
directory: ' ',
})).toBe(false);
});
});
+69 -9
View File
@@ -2,20 +2,17 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata';
/**
* GitHub issues and pull requests a user has linked to a session.
* Issues and pull requests a user has linked to a session.
*
* Stored as a **snapshot**, not a reference: number, title, author and avatar
* only. Enough to render a row and open the thing, and nothing more the body,
* comments and state of an issue belong to GitHub, and mirroring them here
* would mean owning their staleness. The stored title can drift from the real
* one; that is the accepted cost of a storage that never needs refreshing.
* Stored as a **snapshot**, not a reference: identifier or number, title, author
* and avatar only. Enough to render a row and open the thing, and nothing more.
*
* Rides the same session-metadata channel as pinned messages
* (`contextObligatoryMessages`), so it inherits their persistence and sync for
* free.
*/
export type LinkedIssue = {
export type LinkedGitHubIssue = {
/** `owner/repo#number`, unique per session and stable across renames. */
id: string;
number: number;
@@ -27,10 +24,24 @@ export type LinkedIssue = {
linkedAt: number;
};
export type LinkedLinearIssue = {
/** `linear:{identifier}`, unique per session. */
id: string;
identifier: string;
title: string;
url: string;
kind: 'linear';
author?: string;
authorAvatarUrl?: string;
linkedAt: number;
};
export type LinkedIssue = LinkedGitHubIssue | LinkedLinearIssue;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const isLinkedIssue = (value: unknown): value is LinkedIssue => (
const isLinkedGitHubIssue = (value: unknown): value is LinkedGitHubIssue => (
isRecord(value)
&& typeof value.id === 'string'
&& value.id.length > 0
@@ -43,9 +54,29 @@ const isLinkedIssue = (value: unknown): value is LinkedIssue => (
&& Number.isFinite(value.linkedAt)
);
const isLinkedLinearIssue = (value: unknown): value is LinkedLinearIssue => (
isRecord(value)
&& typeof value.id === 'string'
&& value.id.length > 0
&& typeof value.identifier === 'string'
&& value.identifier.length > 0
&& typeof value.title === 'string'
&& typeof value.url === 'string'
&& value.kind === 'linear'
&& typeof value.linkedAt === 'number'
&& Number.isFinite(value.linkedAt)
);
const isLinkedIssue = (value: unknown): value is LinkedIssue => (
isLinkedGitHubIssue(value) || isLinkedLinearIssue(value)
);
export const buildLinkedIssueId = (owner: string, repo: string, number: number): string =>
`${owner}/${repo}#${number}`;
const buildLinkedLinearIssueId = (identifier: string): string =>
`linear:${identifier}`;
/**
* Builds the stored snapshot from what an attach flow already has.
*
@@ -61,7 +92,7 @@ export const buildLinkedIssue = (input: {
kind: 'issue' | 'pull';
author?: { login?: string; avatarUrl?: string } | null;
linkedAt: number;
}): LinkedIssue => {
}): LinkedGitHubIssue => {
const match = /github\.com\/([^/]+)\/([^/]+)\//.exec(input.url);
const id = match
? buildLinkedIssueId(match[1], match[2], input.number)
@@ -79,6 +110,35 @@ export const buildLinkedIssue = (input: {
};
};
export const buildLinkedLinearIssue = (input: {
identifier: string;
title: string;
url: string;
author?: { login?: string; avatarUrl?: string } | null;
linkedAt: number;
}): LinkedLinearIssue => ({
id: buildLinkedLinearIssueId(input.identifier),
identifier: input.identifier,
title: input.title,
url: input.url,
kind: 'linear',
author: input.author?.login ?? undefined,
authorAvatarUrl: input.author?.avatarUrl ?? undefined,
linkedAt: input.linkedAt,
});
export const canOpenLinearIssueInContextPanel = (options: {
linearAvailable: boolean;
linearConnected: boolean;
inDedicatedMobileShell: boolean;
directory: string | null | undefined;
}): boolean => (
options.linearAvailable
&& options.linearConnected
&& !options.inDedicatedMobileShell
&& Boolean(options.directory?.trim())
);
export const getLinkedIssues = (session: Session | null | undefined): LinkedIssue[] => {
const openchamber = getSessionMetadata(session).openchamber;
if (!isRecord(openchamber) || !Array.isArray(openchamber.linked_issues)) return [];
+58 -1
View File
@@ -13,6 +13,8 @@ export type MagicPromptId =
| 'github.pr.review.instructions'
| 'github.issue.review.visible'
| 'github.issue.review.instructions'
| 'linear.issue.review.visible'
| 'linear.issue.review.instructions'
| 'github.pr.checks.review.visible'
| 'github.pr.checks.review.instructions'
| 'github.pr.comments.review.visible'
@@ -56,7 +58,7 @@ export interface MagicPromptDefinition {
id: MagicPromptId;
title: string;
description: string;
group: 'Git' | 'GitHub' | 'Planning' | 'Session';
group: 'Git' | 'GitHub' | 'Linear' | 'Planning' | 'Session';
template: string;
placeholders?: Array<{ key: string; description: string }>;
}
@@ -261,6 +263,61 @@ Question/Support:
- Answer/guidance (max 6 lines)
- Missing info (max 4)
Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`,
},
{
id: 'linear.issue.review.visible',
title: 'Linear Issue Review Visible Prompt',
group: 'Linear',
description: 'Visible user message when creating a session from a Linear issue.',
placeholders: [
{ key: 'identifier', description: 'Linear issue identifier, such as ENG-12.' },
],
template: 'Review this Linear issue {{identifier}} using the provided issue context',
},
{
id: 'linear.issue.review.instructions',
title: 'Linear Issue Review Instructions',
group: 'Linear',
description: 'Hidden instructions attached when generating a Linear issue review response.',
template: `Review this Linear issue using the provided issue context.
Process:
- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: <one label>.
- Gather any needed repository context (code, config, docs) to validate assumptions.
- After gathering, if anything is still unclear or cannot be verified, do not speculate state what's missing and ask targeted questions.
Mode selection by type:
- Bug / Question/Support / Ops: deliver the response directly using the matching template below. Do not bombard me with questions for straightforward diagnosis; use "Missing info" / "Repro/diagnostics needed" fields instead.
- Feature request / Refactor with substantive unknowns: this is effectively a planning session. Do not emit the Feature template on the first turn. Instead, ask me focused clarifying questions in batches of at most 3, one topic at a time (scope, constraints, tradeoffs, UX, etc.), wait for answers, drop questions that became irrelevant, and repeat until you have no more substantive questions. Only then emit the Feature template.
Output rules:
- Compact output; pick ONE template below and omit the others.
- No emojis. No code snippets. No fenced blocks.
- Short inline code identifiers allowed.
- Reference evidence with file paths and line ranges when applicable; if exact lines are not available, cite the file and say "approx" + why.
- Keep the entire response under ~300 words (applies to the final template output, not to clarifying-question turns).
Templates (choose one):
Bug:
- Summary (1-2 sentences)
- Likely cause (max 2)
- Repro/diagnostics needed (max 3)
- Fix approach (max 4 steps)
- Verification (max 3)
Feature:
- Summary (1-2 sentences)
- Requirements (max 4)
- Unknowns/questions (max 4)
- Proposed plan (max 5 steps)
- Verification (max 3)
Question/Support:
- Summary (1-2 sentences)
- Answer/guidance (max 6 lines)
- Missing info (max 4)
Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`,
},
{
@@ -6,6 +6,7 @@ import {
contextPayloadFromDraft,
createContextPart,
formatContextText,
hasContextParts,
readContextPart,
type ContextPartPayload,
} from './contextParts';
@@ -113,6 +114,13 @@ describe('round-trip through part metadata', () => {
expect(readContextPart(part)).toEqual(payload);
});
test('linear references carry picker-built text and the identifier', () => {
const payload: ContextPartPayload = { kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' };
const part = asPart(payload, 'Linear issue context (JSON)\n{}');
expect(part.text).toBe('Linear issue context (JSON)\n{}');
expect(readContextPart(part)).toEqual(payload);
});
test('non-text parts, missing metadata, and malformed payloads read as null', () => {
expect(readContextPart({ type: 'file', metadata: {} })).toBeNull();
expect(readContextPart({ type: 'text' })).toBeNull();
@@ -126,4 +134,11 @@ describe('round-trip through part metadata', () => {
metadata: { [CONTEXT_METADATA_KEY]: { kind: 'github-issue', number: 0, title: 't', url: 'u' } },
})).toBeNull();
});
test('hasContextParts detects user-attached context in a message', () => {
const quote = asPart(contextPayloadFromDraft(draft({ source: 'chat-quote', fileLabel: 'msg_1' })));
expect(hasContextParts([quote])).toBe(true);
expect(hasContextParts([{ type: 'text' }])).toBe(false);
expect(hasContextParts([])).toBe(false);
});
});
+24 -3
View File
@@ -96,6 +96,13 @@ type GitHubPrContext = {
url: string;
};
type LinearIssueContext = {
kind: 'linear-issue';
identifier: string;
title: string;
url: string;
};
export type ContextPartPayload =
| CodeCommentContext
| TerminalContextPayload
@@ -105,7 +112,8 @@ export type ContextPartPayload =
| FileQuoteContext
| ChatQuoteContext
| GitHubIssueContext
| GitHubPrContext;
| GitHubPrContext
| LinearIssueContext;
export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload };
@@ -154,6 +162,7 @@ export function formatContextText(payload: ContextPartPayload): string {
return `Attached failed GitHub PR check (${payload.label}):\n\`\`\`\n${payload.output}\n\`\`\`${payload.text ? `\n\n${payload.text}` : ''}`;
case 'github-issue':
case 'github-pr':
case 'linear-issue':
// Linked issues/PRs carry server-fetched context text built by
// their pickers; there is no default text to derive here.
return '';
@@ -162,8 +171,9 @@ export function formatContextText(payload: ContextPartPayload): string {
/**
* Build the synthetic part for one context payload. `text` overrides the
* derived text; github-issue/github-pr payloads require it because their
* model-facing context is fetched by the picker, not derived from metadata.
* derived text; github-issue/github-pr/linear-issue payloads require it
* because their model-facing context is fetched by the picker, not derived
* from metadata.
*/
export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart {
const resolvedText = text ?? formatContextText(payload);
@@ -297,6 +307,12 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [
title: z.string(),
url: z.string(),
}),
z.object({
kind: z.literal('linear-issue'),
identifier: z.string().min(1),
title: z.string(),
url: z.string(),
}),
]);
/** The subset of a message part that context read-back inspects. */
@@ -312,3 +328,8 @@ export function readContextPart(part: ContextCarrierPart): ContextPartPayload |
const parsed = contextPayloadSchema.safeParse(part.metadata?.[CONTEXT_METADATA_KEY]);
return parsed.success ? parsed.data : null;
}
/** Whether a message carries any user-attached context part. */
export function hasContextParts(parts: ContextCarrierPart[]): boolean {
return parts.some((part) => readContextPart(part) !== null);
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from 'bun:test';
import type { Part } from '@opencode-ai/sdk/v2';
import { flattenAssistantTextParts, flattenUserTextParts } from './messageText';
// Regression tests for https://github.com/openchamber/openchamber/issues/2867
//
// `flattenAssistantTextParts` used to collapse every blank line into a single
// `\n`. Markdown block structure (paragraphs, lists, fenced code blocks)
// requires a blank line (`\n\n`); a single `\n` is a CommonMark soft break.
// `ChatMessage.tsx`'s `handleCopyMessage` feeds the flattened string into
// `copyMarkdownToClipboard`, which writes it to `text/plain`, `text/markdown`
// and its markdown-rendered HTML into `text/html`.
const basePart = (overrides: Record<string, unknown>): Part =>
({
id: 'p1',
sessionID: 's',
messageID: 'm',
type: 'text',
text: '',
...overrides,
}) as Part;
const makeParts = (texts: string[]): Part[] =>
texts.map((text, index) => basePart({ id: `p${index}`, text }));
const makeUserParts = (
entries: Array<{ text?: string; shellAction?: { output?: unknown; command?: unknown } }>,
): Part[] =>
entries.map((entry, index) =>
basePart({ id: `u${index}`, text: entry.text ?? '', shellAction: entry.shellAction }),
);
describe('flattenAssistantTextParts', () => {
const parts = makeParts([
'第一段',
'第二段',
'```js\nconsole.log(1)\n```',
'第三段',
'- item 1\n- item 2',
]);
test('blank lines between paragraphs/code blocks/lists are preserved', () => {
expect(flattenAssistantTextParts(parts)).toBe(
'第一段\n\n第二段\n\n```js\nconsole.log(1)\n```\n\n第三段\n\n- item 1\n- item 2',
);
});
test('a code fence is not glued to the following paragraph', () => {
const flattened = flattenAssistantTextParts(parts);
expect(flattened).not.toContain('```\n第三段');
expect(flattened).toContain('```\n\n第三段');
});
test('list items keep single newlines inside their part', () => {
expect(flattenAssistantTextParts(parts)).toContain('\n\n- item 1\n- item 2');
});
test('internal blank-line runs are preserved', () => {
const text = 'a\n\n\n\nb\n \n \nd';
expect(flattenAssistantTextParts(makeParts([text]))).toBe(text);
});
test('multiple blank lines inside a fenced code block are preserved', () => {
const fenced = '```js\na\n\n\nb\n```';
expect(flattenAssistantTextParts(makeParts([fenced]))).toBe(fenced);
});
test('part boundaries produce block separators', () => {
expect(flattenAssistantTextParts(makeParts(['first', 'second']))).toBe('first\n\nsecond');
});
test('empty and whitespace-only parts are dropped', () => {
expect(flattenAssistantTextParts([])).toBe('');
expect(flattenAssistantTextParts(makeParts(['', ' ', '\n']))).toBe('');
});
test('single part without blank lines is returned unchanged', () => {
const single = 'only line\nsecond line';
expect(flattenAssistantTextParts(makeParts([single]))).toBe(single);
});
test('non-text parts are ignored', () => {
const partsWithTool: Part[] = [
...makeParts(['before']),
{ id: 't1', sessionID: 's', messageID: 'm', type: 'tool', tool: 'bash' } as Part,
...makeParts(['after']),
];
expect(flattenAssistantTextParts(partsWithTool)).toBe('before\n\nafter');
});
});
describe('flattenUserTextParts', () => {
test('plain text parts keep blank-line block separators', () => {
const parts = makeUserParts([{ text: '第一段\n\n\n第二段' }, { text: '下一段' }]);
expect(flattenUserTextParts(parts)).toBe('第一段\n\n\n第二段\n\n下一段');
});
test('shell outputs win over other content and are joined with blank lines', () => {
const parts = makeUserParts([
{ text: 'note', shellAction: { command: 'ls -la' } },
{ text: '', shellAction: { output: ' file-a\nfile-b ' } },
{ text: '', shellAction: { output: 'done' } },
]);
expect(flattenUserTextParts(parts)).toBe('file-a\nfile-b\n\ndone');
});
test('shell commands fall back to a single-newline command list', () => {
const parts = makeUserParts([
{ shellAction: { command: ' bun install ' } },
{ shellAction: { command: 'bun test' } },
{ text: 'ignored when commands exist' },
]);
expect(flattenUserTextParts(parts)).toBe('bun install\nbun test');
});
test('returns empty string for parts without text', () => {
expect(flattenUserTextParts([])).toBe('');
expect(flattenUserTextParts(makeUserParts([{ text: ' ' }]))).toBe('');
});
});
+31 -2
View File
@@ -1,6 +1,7 @@
import type { Part } from '@opencode-ai/sdk/v2';
type TextLikePart = Part & { text?: string; content?: string };
type UserTextPart = Part & { text?: string; content?: string; shellAction?: { output?: unknown; command?: unknown } };
export const flattenAssistantTextParts = (parts: Part[]): string => {
const textParts = parts
@@ -8,8 +9,36 @@ export const flattenAssistantTextParts = (parts: Part[]): string => {
.map((part) => (part.text || part.content || '').trim())
.filter((text) => text.length > 0);
const combined = textParts.join('\n');
return combined.replace(/\n\s*\n+/g, '\n');
return textParts.join('\n\n');
};
export const flattenUserTextParts = (parts: Part[]): string => {
const textParts = parts.filter((part): part is UserTextPart => part?.type === 'text');
const shellOutputs = textParts
.map((part) => {
const output = part.shellAction?.output;
return typeof output === 'string' ? output.trim() : '';
})
.filter((output) => output.length > 0);
if (shellOutputs.length > 0) {
return shellOutputs.join('\n\n');
}
const shellCommands = textParts
.map((part) => {
const command = part.shellAction?.command;
return typeof command === 'string' ? command.trim() : '';
})
.filter((command) => command.length > 0);
if (shellCommands.length > 0) {
return shellCommands.join('\n');
}
const plainTexts = textParts
.map((part) => (part.text || part.content || '').trim())
.filter((text) => text.length > 0);
return plainTexts.join('\n\n');
};
export const suggestPlanTitleFromText = (text: string): string => {
+96 -3
View File
@@ -4,6 +4,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { getRuntimeUrlResolver } from './runtime-url';
import { opencodeClient } from './opencode/client';
import { runtimeFetch } from './runtime-fetch';
import { getRecentSendFailures } from '@/sync/send-failure-log';
import { getRecentSessionErrors } from '@/sync/session-error-log';
declare const __APP_VERSION__: string | undefined;
@@ -21,6 +23,8 @@ type OpenChamberHealthSnapshot = {
openCodeAuthSource?: unknown;
isOpenCodeReady?: unknown;
lastOpenCodeError?: unknown;
lastOpenCodeHealthFailure?: unknown;
lastManagedOpenCodeProcess?: unknown;
lastOpenCodeLaunchDiagnostics?: unknown;
opencodeBinaryResolved?: unknown;
opencodeBinarySource?: unknown;
@@ -128,6 +132,15 @@ const normalizePort = (value: unknown): number | null => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const STDERR_TAIL_LINES = 12;
const RECENT_RECORD_LINES = 8;
const joinPath = (base: string, relative: string, windows: boolean): string => {
const separator = windows ? '\\' : '/';
const trimmed = base.replace(/[\\/]+$/, '');
return `${trimmed}${separator}${windows ? relative.replace(/\//g, '\\') : relative}`;
};
const formatUnknown = (value: unknown, fallback = '(n/a)'): string => {
if (typeof value === 'string') return value.trim() || fallback;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
@@ -148,7 +161,7 @@ const formatLaunchRuntime = (wrapperType: string, node: string, bun: string): st
return 'direct executable';
};
const buildOpenCodeStatusReport = async (): Promise<string> => {
export const buildOpenCodeStatusReport = async (): Promise<string> => {
const now = new Date();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)';
@@ -159,6 +172,7 @@ const buildOpenCodeStatusReport = async (): Promise<string> => {
const healthUrl = urls.health();
const apiBase = urls.api('/api/');
const openChamberHealth: OpenChamberHealthSnapshot | null = await (async () => {
if (!healthUrl) return null;
const controller = new AbortController();
@@ -227,15 +241,36 @@ const buildOpenCodeStatusReport = async (): Promise<string> => {
const buildProbeUrl = (pathname: string, includeDirectory = true): string | null => {
if (!apiBase) return null;
const url = new URL(pathname.replace(/^\/+/, ''), apiBase);
// A web runtime resolves its API base relative to the page; a relative
// base is not a valid URL base on its own.
const absoluteBase = /^[a-z][a-z0-9+.-]*:/i.test(apiBase) || !origin ? apiBase : new URL(apiBase, origin).toString();
const url = new URL(pathname.replace(/^\/+/, ''), absoluteBase);
if (includeDirectory && directory) {
url.searchParams.set('directory', directory);
}
return url.toString();
};
// OpenCode's own view of its directories; `home` anchors the log path below.
const pathInfo: { home?: unknown } | null = await (async () => {
const url = buildProbeUrl('/path', true);
if (!url) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const resp = await runtimeFetch(url, { signal: controller.signal, cache: 'no-store' });
if (!resp.ok) return null;
const json = (await resp.json().catch(() => null)) as unknown;
return isRecord(json) ? json : null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
})();
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
{ label: 'health', path: '/health', includeDirectory: false },
{ label: 'health', path: '/global/health', includeDirectory: false },
{ label: 'config', path: '/config', includeDirectory: true },
{ label: 'providers', path: '/config/providers', includeDirectory: true },
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
@@ -278,6 +313,64 @@ const buildOpenCodeStatusReport = async (): Promise<string> => {
lines.push(`OpenCode auth source: ${openChamberHealth.openCodeAuthSource}`);
}
// What the managed OpenCode process last said for itself. A turn that stops
// with nothing on screen usually left its reason here or in the session
// errors below, not in the UI.
const lastOpenCodeError = formatUnknown(openChamberHealth?.lastOpenCodeError, '');
const managedProcess = isRecord(openChamberHealth?.lastManagedOpenCodeProcess)
? openChamberHealth.lastManagedOpenCodeProcess
: null;
const stderrTail = managedProcess && typeof managedProcess.stderrTail === 'string'
? managedProcess.stderrTail.trim()
: '';
if (lastOpenCodeError || managedProcess) {
lines.push('');
lines.push('OpenCode process:');
if (lastOpenCodeError) lines.push(`- last error: ${lastOpenCodeError}`);
if (managedProcess) {
lines.push(`- pid: ${formatUnknown(managedProcess.pid, '(none)')} exit=${formatUnknown(managedProcess.exitCode, '(running)')} signal=${formatUnknown(managedProcess.signalCode, '(none)')}`);
}
if (stderrTail) {
const tailLines = stderrTail.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-STDERR_TAIL_LINES);
lines.push(`- stderr (last ${tailLines.length} lines):`);
for (const line of tailLines) lines.push(` ${line.slice(0, 300)}`);
}
}
const sessionErrors = getRecentSessionErrors();
lines.push('');
lines.push(`Recent OpenCode session errors: ${sessionErrors.length === 0 ? '(none this app session)' : ''}`.trimEnd());
for (const record of sessionErrors.slice(0, RECENT_RECORD_LINES)) {
const detail = record.message ?? '(no message)';
lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} ${record.name ? `${record.name}: ` : ''}${detail}`);
}
const sendFailures = getRecentSendFailures();
lines.push('');
lines.push(`Recent rejected sends: ${sendFailures.length === 0 ? '(none this app session)' : ''}`.trimEnd());
for (const record of sendFailures.slice(0, RECENT_RECORD_LINES)) {
lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} status=${record.status ?? 'transport'}${record.ambiguous ? ' ambiguous' : ''} ${record.reason}`);
}
// Where to look next. OpenCode keeps its own log under the XDG data
// directory (the same default on every platform, which is why Windows users
// do not find it under AppData); the desktop app writes the server console,
// including OpenCode lifecycle lines, through electron-log.
const opencodeHome = typeof pathInfo?.home === 'string' ? pathInfo.home : '';
const isWindows = /Windows NT/.test(platform);
const isDesktop = origin.startsWith('openchamber-ui://');
lines.push('');
lines.push('Log files:');
lines.push(`- OpenCode: ${opencodeHome ? joinPath(opencodeHome, '.local/share/opencode/log', isWindows) : '<home>/.local/share/opencode/log'} (or $XDG_DATA_HOME/opencode/log when set)`);
if (isDesktop) {
const isMacDesktop = /Mac OS X|Macintosh/.test(platform);
lines.push(`- OpenChamber desktop: ${isWindows
? '%APPDATA%\\OpenChamber\\logs\\main.log'
: isMacDesktop
? '~/Library/Logs/OpenChamber/main.log'
: '~/.config/OpenChamber/logs/main.log'}`);
}
if (typeof window !== 'undefined') {
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
+123 -4
View File
@@ -558,7 +558,7 @@ describe('updateDesktopSettings', () => {
});
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -584,7 +584,7 @@ describe('updateDesktopSettings', () => {
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -616,7 +616,7 @@ describe('updateDesktopSettings', () => {
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -647,7 +647,7 @@ describe('updateDesktopSettings', () => {
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -844,3 +844,122 @@ describe('updateDesktopSettings', () => {
expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true);
});
});
describe('unload lifecycle flush (#2197)', () => {
beforeEach(() => {
getWindow();
registerRuntimeAPIs(null);
invalidateSettingsCache();
});
test('flushes a pending debounced settings save on pagehide without a double write', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
const update = updateDesktopSettings({ showDeletionDialog: false });
expect(saveCalls).toEqual([]);
getWindow().dispatchEvent(new Event('pagehide'));
// The flush must hand the pending changes to the settings backend
// synchronously inside the lifecycle listener — an unloading window has
// no later turn for the debounce timer.
expect(saveCalls).toEqual([{ showDeletionDialog: false }]);
await update;
await delay(300);
// The canceled debounce timer must not replay the same write.
expect(saveCalls).toHaveLength(1);
});
test('flushes a pending debounced settings save on beforeunload without a double write', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
const update = updateDesktopSettings({ gitChangesViewMode: 'tree' });
expect(saveCalls).toEqual([]);
getWindow().dispatchEvent(new Event('beforeunload'));
expect(saveCalls).toEqual([{ gitChangesViewMode: 'tree' }]);
await update;
await delay(300);
expect(saveCalls).toHaveLength(1);
});
test('persists a showDeletionDialog toggle followed by an immediate unload', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
startAppearanceAutoSave();
try {
useUIStore.getState().setShowDeletionDialog(false);
getWindow().dispatchEvent(new Event('pagehide'));
expect(saveCalls.some((changes) => changes.showDeletionDialog === false)).toBe(true);
} finally {
useUIStore.getState().setShowDeletionDialog(true);
// Let the restore write drain so it cannot leak into other tests.
await delay(300);
}
});
test('sends the unload flush with keepalive so the browser cannot cancel it', async () => {
// No runtime settings API: the write has to take the HTTP branch, which is
// the one the browser cancels on unload without `keepalive`.
registerRuntimeAPIs(null);
const inits: RequestInit[] = [];
const previousFetch = globalThis.fetch;
// SAFETY: the mock receives only the (input, init) pair production code
// passes and always resolves to a Response; the assertion supplies the
// overload signatures a plain arrow function cannot declare.
globalThis.fetch = (async (_input, init) => {
inits.push(init ?? {});
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as typeof fetch;
try {
const update = updateDesktopSettings({ gitChangesViewMode: 'flat' });
getWindow().dispatchEvent(new Event('pagehide'));
await update;
await delay(50);
expect(inits).toHaveLength(1);
expect(inits[0].method).toBe('PUT');
expect(inits[0].keepalive).toBe(true);
// The ordinary debounced write stays a plain fetch.
inits.length = 0;
await updateDesktopSettings({ gitChangesViewMode: 'tree' });
await delay(300);
expect(inits).toHaveLength(1);
expect(inits[0].keepalive).toBe(false);
} finally {
globalThis.fetch = previousFetch;
}
});
test('ignores lifecycle events when no settings write is pending', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
getWindow().dispatchEvent(new Event('pagehide'));
getWindow().dispatchEvent(new Event('beforeunload'));
await delay(50);
expect(saveCalls).toEqual([]);
});
});
+71 -7
View File
@@ -17,6 +17,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sanitizeStarterRefs } from '@/lib/draftStarters';
import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isCapacitorApp } from '@/lib/platform';
import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
@@ -199,11 +200,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
};
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
export interface SettingsSyncedDetail {
settings: DesktopSettings;
/** Whether listeners may adopt cross-window workspace pointers
(activeProjectId / lastDirectory). True only for a bootstrap-grade sync:
the settings document is shared by every window of this server, so a
mid-session reconciliation adopting them would hijack this window's
workspace with another window's choice. */
adoptWorkspace: boolean;
}
const dispatchSettingsSynced = (settings: DesktopSettings, adoptWorkspace: boolean): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
detail: { settings, adoptWorkspace },
}));
};
type SettingsSaveState = 'idle' | 'saving' | 'error';
@@ -1760,6 +1773,26 @@ const isSettingsRuntimeContextCurrent = (context: SettingsRuntimeContext): boole
context.generation === _settingsRuntimeGeneration && context.runtimeKey === getRuntimeKey()
);
// Best-effort flush of the pending debounced settings write at a lifecycle
// boundary. Clearing the timer before flushing means the write happens exactly
// once — the flush consumes the pending changes, so a timer that already fired
// cannot double-write. A hard process kill (crash, task-manager kill) can
// still lose the in-flight request; this narrows the loss window to the
// request itself instead of the whole debounce interval (#2197).
const flushPendingSettingsBeforeSuspend = (): void => {
if (!_pendingSettingsChanges) return;
if (_settingsFlushTimer) {
clearTimeout(_settingsFlushTimer);
_settingsFlushTimer = null;
}
// `keepalive` is what makes this flush actually land: a plain fetch started
// from pagehide/beforeunload is cancelled with the document. Settings payloads
// are a few KB, far under the 64 KB keepalive budget. `navigator.sendBeacon`
// is not an option here — it cannot carry the runtime bearer header, so the
// write would be rejected as unauthenticated.
void _flushSettingsUpdate({ keepalive: true });
};
const ensureSettingsRuntimeLifecycle = (): void => {
if (_settingsLifecycleInitialized || typeof window === 'undefined') return;
_settingsLifecycleInitialized = true;
@@ -1777,6 +1810,33 @@ const ensureSettingsRuntimeLifecycle = (): void => {
_settingsCache = null;
_settingsInflight = null;
});
// Mirror the deferred safe-storage lifecycle: without these listeners, a
// settings change made within SETTINGS_DEBOUNCE_MS of closing the window is
// silently dropped, and the stale server snapshot wins on next startup.
try {
window.addEventListener('pagehide', flushPendingSettingsBeforeSuspend, { capture: true });
window.addEventListener('beforeunload', flushPendingSettingsBeforeSuspend, { capture: true });
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushPendingSettingsBeforeSuspend();
});
document.addEventListener('freeze', flushPendingSettingsBeforeSuspend);
}
// Capacitor: iOS/Android suspend the app without firing pagehide or
// beforeunload, and `visibilitychange` alone is not dependable in a
// WKWebView. `App.appStateChange` is the authoritative foreground signal on
// native (same source `usePushVisibilityBeacon` trusts), so flush there too.
if (isCapacitorApp()) {
void import('@capacitor/app')
.then(({ App }) => App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) flushPendingSettingsBeforeSuspend();
}))
.catch(() => undefined);
}
} catch {
// Restricted environments can reject listeners; the debounce timer still flushes.
}
};
const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Promise<DesktopSettings | null> => {
@@ -1841,7 +1901,8 @@ export const invalidateSettingsCache = (): void => {
_settingsCache = null;
};
export const syncDesktopSettings = async (): Promise<void> => {
export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }): Promise<void> => {
const adoptWorkspace = options?.adoptWorkspace !== false;
if (typeof window === 'undefined') {
return;
}
@@ -1970,7 +2031,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(authoritativeSettings);
dispatchSettingsSynced(authoritativeSettings, adoptWorkspace);
};
try {
@@ -1986,7 +2047,9 @@ export const syncDesktopSettings = async (): Promise<void> => {
};
// Coalesce rapid updateDesktopSettings calls into a single PUT
async function _flushSettingsUpdate(): Promise<void> {
// `keepalive` is set only on the lifecycle-suspend path, where the document may
// be torn down mid-request; the ordinary debounced write uses a plain fetch.
async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean } = {}): Promise<void> {
const changes = _pendingSettingsChanges;
const context = _pendingSettingsContext;
const revision = _pendingSettingsRevision;
@@ -2013,7 +2076,7 @@ async function _flushSettingsUpdate(): Promise<void> {
if (updated) {
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
dispatchSettingsSynced(reconciled, false);
_settingsCache = null;
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
@@ -2033,6 +2096,7 @@ async function _flushSettingsUpdate(): Promise<void> {
Accept: 'application/json',
},
body: JSON.stringify(changes),
keepalive,
});
if (!isSettingsRuntimeContextCurrent(context)) return;
@@ -2047,7 +2111,7 @@ async function _flushSettingsUpdate(): Promise<void> {
if (updated) {
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
dispatchSettingsSynced(reconciled, false);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, test } from 'bun:test';
import { createPlanSaveQueue } from './planSaveQueue';
type Deferred = { promise: Promise<void>; resolve: () => void; reject: () => void };
const deferred = (): Deferred => {
let resolve!: () => void;
let reject!: () => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe('planSaveQueue', () => {
test('runs writes for one document in schedule order even when they resolve out of order', async () => {
const queue = createPlanSaveQueue();
const order: string[] = [];
const first = deferred();
const second = deferred();
const firstDone = queue.schedule('doc', 1, async () => {
await first.promise;
order.push('first');
});
const secondDone = queue.schedule('doc', 2, async () => {
order.push('second');
});
// Second started only after first settles, regardless of timing.
first.resolve();
await firstDone;
second.resolve();
await secondDone;
expect(order).toEqual(['first', 'second']);
});
test('skips a revision at or below the last queued revision for the same document', async () => {
const queue = createPlanSaveQueue();
let writes = 0;
await queue.schedule('doc', 3, async () => {
writes += 1;
});
await queue.schedule('doc', 3, async () => {
writes += 1;
});
await queue.schedule('doc', 2, async () => {
writes += 1;
});
expect(writes).toBe(1);
});
test('never lets a write for one document block another document', async () => {
const queue = createPlanSaveQueue();
const blocked = deferred();
const blockedDone = queue.schedule('a', 1, async () => {
await blocked.promise;
});
let otherRan = false;
await queue.schedule('b', 1, async () => {
otherRan = true;
});
expect(otherRan).toBe(true);
blocked.resolve();
await blockedDone;
});
test('pendingFor waits for the outstanding chain of that document only', async () => {
const queue = createPlanSaveQueue();
const slow = deferred();
let slowSettled = false;
void queue.schedule('a', 1, async () => {
await slow.promise;
slowSettled = true;
});
await queue.schedule('b', 1, async () => {});
await queue.pendingFor('b');
expect(slowSettled).toBe(false);
slow.resolve();
await queue.pendingFor('a');
expect(slowSettled).toBe(true);
});
test('reset clears the revision watermark so a reloaded document can save again', async () => {
const queue = createPlanSaveQueue();
let writes = 0;
await queue.schedule('doc', 5, async () => {
writes += 1;
});
queue.reset('doc');
await queue.schedule('doc', 1, async () => {
writes += 1;
});
expect(writes).toBe(2);
});
test('a failed write does not poison the chain for later writes', async () => {
const queue = createPlanSaveQueue();
const failing = queue.schedule('doc', 1, async () => {
throw new Error('write failed');
});
let secondRan = false;
const second = queue.schedule('doc', 2, async () => {
secondRan = true;
});
await expect(failing).rejects.toThrow('write failed');
await second;
expect(secondRan).toBe(true);
await queue.pendingFor('doc');
});
test('allows the same revision to retry after its write fails', async () => {
const queue = createPlanSaveQueue();
let attempts = 0;
const failing = queue.schedule('doc', 1, async () => {
attempts += 1;
throw new Error('write failed');
});
await expect(failing).rejects.toThrow('write failed');
await queue.schedule('doc', 1, async () => {
attempts += 1;
});
expect(attempts).toBe(2);
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Write queue for open plan documents.
*
* Debounced autosave and close-time flushes must reach the disk in edit order,
* and a document re-opened while its own write is still in flight must read
* the post-write state, not race it. The queue serializes writes per logical
* document key and deduplicates revisions so a flush of revision N can never
* run behind, or twice behind, a debounced save of the same revision.
*/
interface PlanSaveQueue {
/**
* Queue one write for `key`. Writes for the same key run in schedule order;
* writes for different keys never block each other. A revision at or below
* the last queued revision for that key is skipped the queued write
* already carries newer content and the returned promise tracks the
* outstanding chain so callers can still await it.
*/
schedule: (key: string, revision: number, write: () => Promise<void>) => Promise<void>;
/** Resolves when every write queued for `key` has settled. */
pendingFor: (key: string) => Promise<void>;
/**
* Forgets the revision watermark for `key`. Call when a document is freshly
* loaded: its revision counter restarts, and stale watermarks from a
* previous open must not swallow the first real edit.
*/
reset: (key: string) => void;
}
export const createPlanSaveQueue = (): PlanSaveQueue => {
const chains = new Map<string, Promise<void>>();
const lastRevision = new Map<string, number>();
return {
schedule: (key, revision, write) => {
if (revision <= (lastRevision.get(key) ?? Number.NEGATIVE_INFINITY)) {
return chains.get(key) ?? Promise.resolve();
}
lastRevision.set(key, revision);
const previous = chains.get(key) ?? Promise.resolve();
// A failed write must not poison the chain: the next write for this
// document is still safe to attempt, and error surfacing belongs to the
// caller that owns UI state.
const next = previous.then(write, write);
chains.set(key, next.catch(() => {
// Keep newer queued revisions deduplicated, but let the caller retry
// this exact revision after its write has failed.
if (lastRevision.get(key) === revision) {
lastRevision.delete(key);
}
}));
return next;
},
pendingFor: async (key) => {
await chains.get(key);
},
reset: (key) => {
lastRevision.delete(key);
},
};
};
+11
View File
@@ -57,6 +57,17 @@ export interface ProjectRef {
path: string;
}
/**
* A saved project plan plus the project that owns it, carried as one value so
* a viewer can never end up with a plan id whose owner it has to guess.
* PlanView resolves no owner on its own: the panel (or the persisted tab,
* or the mobile surface) that opened the plan knows the owner exactly.
*/
export interface SavedProjectPlanTarget {
projectRef: ProjectRef;
planId: string;
}
export const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
export const PROJECT_TODO_TEXT_MAX_LENGTH = 120;
@@ -8,7 +8,6 @@ export interface QuotaProviderMeta {
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'claude', name: 'Claude' },
{ id: 'codex', name: 'Codex' },
{ id: 'command-code', name: 'Command Code' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'github-copilot', name: 'GitHub Copilot' },
{ id: 'google', name: 'Google' },
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { clampPercent, formatPercent } from './utils';
import { clampPercent, formatPercent, formatWindowLabel } from './utils';
describe('quota utils', () => {
test('treats non-finite percentages as missing', () => {
@@ -10,4 +10,9 @@ describe('quota utils', () => {
expect(formatPercent(Infinity)).toBe('-');
expect(formatPercent(-Infinity)).toBe('-');
});
test('labels Copilot usage as AI Credits without changing generic premium usage', () => {
expect(formatWindowLabel('premium')).toBe('Premium Interactions');
expect(formatWindowLabel('premium_interactions')).toBe('AI Credits');
});
});
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { openSessionFromRoute } from './openSessionFromRoute';
const SESSION_ID = 'ses_linear_open';
const PROJECT_DIR = '/projects/linear-from-url';
const OTHER_DIR = '/projects/linear-from-url-other';
const buildSession = (id: string, directory: string): Session => ({
id,
title: id,
directory,
time: { created: 1, updated: 2 },
} as Session);
describe('openSessionFromRoute', () => {
beforeEach(() => {
useSessionUIStore.getState().setCurrentSession(null);
useGlobalSessionsStore.setState({
activeSessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
hasLoaded: true,
status: 'ready',
});
});
test('selects the routed session once the global list knows its directory', async () => {
useGlobalSessionsStore.setState({
activeSessions: [buildSession(SESSION_ID, PROJECT_DIR)],
archivedSessions: [],
hasLoaded: true,
status: 'ready',
});
await openSessionFromRoute(SESSION_ID);
expect(useSessionUIStore.getState().currentSessionId).toBe(SESSION_ID);
expect(useSessionUIStore.getState().currentSessionDirectory).toBe(PROJECT_DIR);
});
test('replaces a guessed directory once the global list knows the owner', async () => {
const id = 'ses_linear_guessed';
useSessionUIStore.getState().setCurrentSession(id);
const guessed = useSessionUIStore.getState().currentSessionDirectory;
useGlobalSessionsStore.setState({
activeSessions: [buildSession(id, OTHER_DIR)],
archivedSessions: [],
hasLoaded: true,
status: 'ready',
});
await openSessionFromRoute(id);
expect(useSessionUIStore.getState().currentSessionId).toBe(id);
expect(useSessionUIStore.getState().currentSessionDirectory).toBe(OTHER_DIR);
expect(guessed).not.toBe(OTHER_DIR);
});
});
@@ -0,0 +1,33 @@
import { ensureGlobalSessionsLoaded, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
/**
* Select a session named by `/?session=`. Cold loads often do not know the
* owning directory yet, so a first selection may guess the active project.
* After the global session list is available, re-select with that directory
* unless the user already moved to a different session.
*/
export async function openSessionFromRoute(sessionId: string): Promise<void> {
const id = sessionId.trim();
if (!id) return;
const initial = useSessionUIStore.getState();
if (initial.currentSessionId !== id) {
initial.setCurrentSession(id, initial.getDirectoryForSession(id));
}
const snapshot = await ensureGlobalSessionsLoaded().catch(() => null);
if (!snapshot) return;
const latest = useSessionUIStore.getState();
if (latest.currentSessionId !== id) return;
const session = [...snapshot.activeSessions, ...snapshot.archivedSessions]
.find((entry) => entry.id === id);
if (!session) return;
const directory = resolveGlobalSessionDirectory(session);
if (!directory || directory === latest.currentSessionDirectory) return;
latest.setCurrentSession(id, directory);
}
@@ -0,0 +1,20 @@
import { describe, expect, test } from 'bun:test';
import { parseRoute } from './parseRoute';
describe('parseRoute session', () => {
test('reads a session id including OpenCode underscores', () => {
const route = parseRoute(new URLSearchParams('session=ses_abc123'));
expect(route.sessionId).toBe('ses_abc123');
});
test('decodes a percent-encoded session id', () => {
const route = parseRoute(new URLSearchParams('session=ses%5Fabc123'));
expect(route.sessionId).toBe('ses_abc123');
});
test('ignores a blank session param', () => {
const route = parseRoute(new URLSearchParams('session='));
expect(route.sessionId).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
import { create } from 'zustand';
// Proactive detection of an expired OpenChamber client session (cookie or
// bearer). There is no polling: every HTTP response already funnels through
// runtimeFetch, and this module only classifies what passes by. A 401 alone
// is NOT proof — OpenCode proxies provider errors through the same routes, so
// a dead Anthropic key also surfaces as 401. Every suspicion is therefore
// confirmed with one debounced GET /auth/session before the state flips.
//
// Consumers: the web/hosted banner (AuthExpiredBanner), the send guard in the
// composer, and the native mobile app, which feeds the signal into its own
// connection orchestration instead of showing the shared banner.
export type AuthSessionState = 'ok' | 'expired' | 'reauthenticating';
interface AuthSessionStore {
state: AuthSessionState;
/** Set only by the confirmed classifier or an explicit auth failure. */
markExpired: () => void;
markReauthenticating: () => void;
markAuthenticated: () => void;
}
export const useAuthSessionStore = create<AuthSessionStore>((set) => ({
state: 'ok',
markExpired: () => set((current) => (current.state === 'expired' ? current : { state: 'expired' })),
markReauthenticating: () => set({ state: 'reauthenticating' }),
markAuthenticated: () => set({ state: 'ok' }),
}));
// One confirm probe per window: parallel 401s from a burst of requests must
// not turn into a probe storm, and a provider-side 401 that keeps repeating
// must not re-probe on every retry.
const CONFIRM_PROBE_MIN_INTERVAL_MS = 15_000;
// Focus revalidation only bothers the server when the tab was away long
// enough for a 12h/7d session to plausibly have died.
const FOCUS_REVALIDATE_MIN_INTERVAL_MS = 5 * 60_000;
let lastProbeAt = 0;
let probeInFlight = false;
// Paths where a 401 is part of a normal flow (wrong password on login, a
// pairing redeem, the confirm probe itself) rather than evidence of expiry.
const isExcludedAuthPath = (url: string): boolean => (
url.includes('/auth/session') || url.includes('/api/client-auth/')
);
const isClassifiablePath = (url: string): boolean => {
const path = url.startsWith('/') ? url : (() => {
try {
return new URL(url).pathname;
} catch {
return '';
}
})();
if (!path.startsWith('/api/') && !path.startsWith('/auth/')) return false;
return !isExcludedAuthPath(path);
};
const confirmSessionExpired = async (): Promise<void> => {
if (probeInFlight) return;
probeInFlight = true;
try {
// Deferred import: runtime-fetch classifies through this module, and the
// probe deliberately re-enters it (its /auth/session path is excluded).
const { runtimeFetch } = await import('./runtime-fetch');
const response = await runtimeFetch('/auth/session', { credentials: 'include' });
if (response.status === 401) {
useAuthSessionStore.getState().markExpired();
return;
}
if (response.ok) {
// The suspicious 401 came from deeper in the chain (a provider key, an
// upstream OpenCode instance) — the OpenChamber session is alive.
const { state, markAuthenticated } = useAuthSessionStore.getState();
if (state === 'expired') markAuthenticated();
}
} catch {
// Transport failure is connectivity, not authentication; the connection
// status machinery owns that story.
} finally {
probeInFlight = false;
}
};
/**
* Called by runtimeFetch for every response. Cheap by design: everything but
* a 401 on a classifiable path returns immediately.
*/
export const observeRuntimeAuthResponse = (url: string, status: number): void => {
if (status !== 401) return;
if (useAuthSessionStore.getState().state === 'expired') return;
if (!isClassifiablePath(url)) return;
const now = Date.now();
if (now - lastProbeAt < CONFIRM_PROBE_MIN_INTERVAL_MS) return;
lastProbeAt = now;
void confirmSessionExpired();
};
let watchInstalled = false;
/**
* Revalidates the session when the tab regains visibility after a long
* absence the "laptop woke up, everything looks alive, first click fails"
* case. One request per wake, nothing periodic.
*/
export const installAuthSessionFocusWatch = (): void => {
// Callers are React effects, so a document always exists here.
if (watchInstalled) return;
watchInstalled = true;
let lastConfirmedAt = Date.now();
const revalidate = () => {
if (useAuthSessionStore.getState().state !== 'ok') return;
const now = Date.now();
if (now - lastConfirmedAt < FOCUS_REVALIDATE_MIN_INTERVAL_MS) return;
lastConfirmedAt = now;
lastProbeAt = now;
void confirmSessionExpired();
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') revalidate();
});
// App switches on desktop can refocus the window without a visibility
// change; both signals share one throttle, so a wake costs one request.
window.addEventListener('focus', revalidate);
};
+9
View File
@@ -1,6 +1,7 @@
import { getActiveRelayTunnel } from './relay/runtime-tunnel';
import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads';
import { buildRuntimeAuthHeaders } from './runtime-auth';
import { observeRuntimeAuthResponse } from './runtime-auth-expiry';
import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url';
export interface RuntimeFetchOptions extends RequestInit {
@@ -294,6 +295,14 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF
).toUpperCase();
}
// Session-expiry classification rides on responses that already flow
// through here; only the status is read, never the body.
const rawFetch = doFetch;
doFetch = () => rawFetch().then((response) => {
observeRuntimeAuthResponse(url, response.status);
return response;
});
// A Request always carries a (possibly default) signal; treat any Request, or
// an explicit init.signal, as "has signal" and skip coalescing for safety.
const hasSignal = requestInit.signal != null || input instanceof Request;
+13 -3
View File
@@ -8,6 +8,7 @@ import {
withBtwSessionLink,
withBtwSessionMarker,
withoutBtwSessionLink,
wasPromotedBtwSession,
withoutBtwSessionMarker,
} from './sessionBtwMetadata';
@@ -64,11 +65,20 @@ describe('fork marker', () => {
expect(getBtwBoundaryMessageID(review)).toBeNull();
});
test('withoutBtwSessionMarker strips the marker and keeps other keys', () => {
test('withoutBtwSessionMarker strips the marker, keeps other keys, and records the promotion', () => {
const marked = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9', btwSessionID: 'nested' } };
expect(withoutBtwSessionMarker(marked)).toEqual({ openchamber: { btwSessionID: 'nested' } });
expect(withoutBtwSessionMarker({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } })).toEqual({});
expect(withoutBtwSessionMarker(marked)).toEqual({ openchamber: { btwSessionID: 'nested', btwPromoted: true } });
expect(withoutBtwSessionMarker({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } })).toEqual({ openchamber: { btwPromoted: true } });
const plain = { openchamber: { kind: 'review' } };
expect(withoutBtwSessionMarker(plain)).toBe(plain);
});
test('wasPromotedBtwSession only reports a session that went through promotion', () => {
expect(wasPromotedBtwSession(sessionWith({ openchamber: { btwPromoted: true } }))).toBe(true);
// Still a live btw fork: the boundary applies, the notice must not.
expect(wasPromotedBtwSession(sessionWith({ openchamber: { kind: 'btw', originalSessionID: 'p-1' } }))).toBe(false);
expect(wasPromotedBtwSession(sessionWith({ openchamber: {} }))).toBe(false);
expect(wasPromotedBtwSession(sessionWith(undefined))).toBe(false);
expect(wasPromotedBtwSession(null)).toBe(false);
});
});
+22 -8
View File
@@ -21,6 +21,7 @@ type BtwMetadata = {
originalSessionID?: string;
btwSessionID?: string;
btwBoundaryMessageID?: string;
btwPromoted?: boolean;
};
const getOpenChamberMetadata = (metadata: SessionMetadataRecord): BtwMetadata => {
@@ -39,6 +40,18 @@ const nonEmpty = (value: string | undefined): string | null =>
export const getBtwSessionID = (session: Session | null | undefined): string | null =>
nonEmpty(getOpenChamberMetadata(getSessionMetadata(session)).btwSessionID);
/**
* The session was once a btw fork and was promoted to a normal session.
*
* Its transcript still contains the btw boundary instruction on every message
* sent while it was a side conversation, and there is no API to remove a
* message part after the fact. The flag lets the composer send a notice that
* those constraints have been lifted, so they cannot keep steering a session
* that is no longer a side conversation.
*/
export const wasPromotedBtwSession = (session: Session | null | undefined): boolean =>
getOpenChamberMetadata(getSessionMetadata(session)).btwPromoted === true;
export const isBtwSession = (session: Session | null | undefined): boolean =>
getOpenChamberMetadata(getSessionMetadata(session)).kind === 'btw'
&& Boolean(getBtwOriginalSessionID(session));
@@ -84,7 +97,13 @@ export const withBtwSessionMarker = (
return { ...metadata, openchamber };
};
/** Remove the btw marker so a promoted fork becomes a plain session. */
/**
* Remove the btw marker so a promoted fork becomes a plain session.
*
* `btwPromoted` replaces it rather than leaving nothing behind: the btw
* boundary instructions stay in the transcript forever, so the session has to
* remain distinguishable from one that was never a side conversation.
*/
export const withoutBtwSessionMarker = (metadata: SessionMetadataRecord): SessionMetadataRecord => {
const openchamber = getOpenChamberMetadata(metadata);
if (openchamber.kind !== 'btw') return metadata;
@@ -92,13 +111,8 @@ export const withoutBtwSessionMarker = (metadata: SessionMetadataRecord): Sessio
delete rest.kind;
delete rest.originalSessionID;
delete rest.btwBoundaryMessageID;
const next: SessionMetadataRecord = { ...metadata };
if (Object.keys(rest).length > 0) {
next.openchamber = rest;
} else {
delete next.openchamber;
}
return next;
rest.btwPromoted = true;
return { ...metadata, openchamber: rest };
};
/** Unlink the parent, but only if it still points at this fork. */
Binary file not shown.
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { navigateSessionHistory } from './sessionNavigationHistory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
// SAFETY: the history module only reads a session's id and directory metadata.
const session = (id: string): Session => ({
id,
title: id,
directory: '/repo',
projectID: 'p1',
version: '1',
time: { created: 1, updated: 1 },
} as Session);
describe('sessionNavigationHistory', () => {
test('steps back and forward through the visit order', () => {
useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s2'), session('s3')] });
useSessionUIStore.setState({ currentSessionId: 's1' });
useSessionUIStore.setState({ currentSessionId: 's2' });
useSessionUIStore.setState({ currentSessionId: 's3' });
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s1');
expect(navigateSessionHistory(-1)).toBe(false);
expect(navigateSessionHistory(1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
});
test('a fresh visit truncates the forward branch', () => {
// Continues from the previous test's state: at s2 with s3 forward.
useSessionUIStore.setState({ currentSessionId: 's1' });
expect(navigateSessionHistory(1)).toBe(false);
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
});
test('skips and drops entries whose session no longer exists', () => {
useSessionUIStore.setState({ currentSessionId: 's3' });
useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s3')] });
// History behind s3 contains s2 (dead) then s1 (alive).
expect(navigateSessionHistory(-1)).toBe(true);
expect(useSessionUIStore.getState().currentSessionId).toBe('s1');
});
});
@@ -0,0 +1,61 @@
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
// Browser-style back/forward over the order sessions were opened in this
// window. A normal session switch truncates the forward part and appends;
// stepping through history moves only the cursor, so back stays back even
// after several presses. In-memory by design: the stack describes this
// window's journey, not durable state.
const MAX_HISTORY = 100;
let visitedSessionIds: string[] = [];
let cursor = -1;
let navigating = false;
const recordVisit = (sessionId: string): void => {
if (visitedSessionIds[cursor] === sessionId) return;
visitedSessionIds = [...visitedSessionIds.slice(0, cursor + 1), sessionId].slice(-MAX_HISTORY);
cursor = visitedSessionIds.length - 1;
};
useSessionUIStore.subscribe((state, previousState) => {
if (state.currentSessionId === previousState.currentSessionId) return;
if (!state.currentSessionId || navigating) return;
recordVisit(state.currentSessionId);
});
/**
* Steps the current session back (-1) or forward (+1) through this window's
* open history. Entries whose session no longer exists in the loaded list are
* skipped and dropped. Returns false when there is nowhere to go.
*/
export const navigateSessionHistory = (delta: -1 | 1): boolean => {
const sessionsById = new Map(
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
);
let nextCursor = cursor + delta;
while (nextCursor >= 0 && nextCursor < visitedSessionIds.length) {
const session = sessionsById.get(visitedSessionIds[nextCursor]);
if (session) {
cursor = nextCursor;
navigating = true;
try {
useSessionUIStore.getState().setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
} finally {
navigating = false;
}
return true;
}
// Drop the dead entry at nextCursor and keep scanning in the same
// direction: a removal shifts later entries one index down, so the next
// forward candidate lands on the same index while a backward scan steps.
visitedSessionIds = [
...visitedSessionIds.slice(0, nextCursor),
...visitedSessionIds.slice(nextCursor + 1),
];
if (nextCursor < cursor) cursor -= 1;
if (delta < 0) nextCursor -= 1;
}
return false;
};
+39
View File
@@ -9,6 +9,45 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
* count as neighbours the same rule the strip uses for rendering. The
* session itself is never touched.
*/
/**
* Activate the nth (0-based) header session tab, counting only tabs whose
* session is present in the loaded session list the same rule the strip
* uses for rendering, so the digit matches what the user sees.
*/
export const activateSessionTabByIndex = (index: number): boolean => {
const { tabIds } = useSessionTabsStore.getState();
const sessionsById = new Map(
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
);
const renderable = tabIds.filter((id) => sessionsById.has(id));
const session = renderable[index] ? sessionsById.get(renderable[index]) : null;
if (!session) return false;
useSessionUIStore.getState().setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
return true;
};
/**
* Activate the tab one step right (+1) or left (-1) of the current session
* in the rendered strip order, wrapping around the ends. Returns false when
* the current session has no tab or there is nothing to move to.
*/
export const activateAdjacentSessionTab = (delta: -1 | 1): boolean => {
const { tabIds } = useSessionTabsStore.getState();
const { currentSessionId, setCurrentSession } = useSessionUIStore.getState();
const sessionsById = new Map(
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
);
const renderable = tabIds.filter((id) => sessionsById.has(id));
if (!currentSessionId || renderable.length < 2) return false;
const index = renderable.indexOf(currentSessionId);
if (index === -1) return false;
const nextId = renderable[(index + delta + renderable.length) % renderable.length];
const next = sessionsById.get(nextId);
if (!next) return false;
setCurrentSession(next.id, resolveGlobalSessionDirectory(next));
return true;
};
export const closeSessionTabAndActivateNeighbour = (sessionId: string): void => {
const { tabIds, closeTab } = useSessionTabsStore.getState();
if (!tabIds.includes(sessionId)) return;
+1 -1
View File
@@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
{ slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger'] },
{ slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'linear'] },
] as const;
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
@@ -38,4 +38,30 @@ describe('settings search', () => {
expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true);
});
test('finds Linear connect on the integrations page', () => {
const results = buildSettingsSearchResults({
query: 'linear',
runtimeCtx,
t,
getPageTitle: (page) => page,
});
expect(results.some((result) => result.id === 'integrations.linear')).toBe(true);
expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(true);
expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(true);
});
test('hides Linear connect in VS Code', () => {
const results = buildSettingsSearchResults({
query: 'linear',
runtimeCtx: { ...runtimeCtx, isVSCode: true },
t,
getPageTitle: (page) => page,
});
expect(results.some((result) => result.id === 'integrations.linear')).toBe(false);
expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(false);
expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(false);
});
});
+40 -1
View File
@@ -351,7 +351,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
id: 'chat.composer',
page: 'chat',
titleKey: 'settings.openchamber.visual.section.composer',
keywords: ['input', 'draft', 'spellcheck'],
keywords: ['input', 'draft', 'spellcheck', 'paste'],
},
{
id: 'chat.spellcheck',
@@ -360,6 +360,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['spelling', 'input'],
isAvailable: (ctx) => !ctx.isMobile,
},
{
id: 'chat.large-text-paste',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.largeTextPaste',
descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint',
keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'],
},
{
id: 'sessions.default-model',
page: 'sessions',
@@ -977,6 +984,38 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
},
{
id: 'integrations.first-party',
page: 'integrations',
titleKey: 'settings.integrations.firstParty.title',
descriptionKey: 'settings.integrations.firstParty.info',
keywords: ['built-in', 'first-party', 'native', 'linear'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'integrations.linear',
page: 'integrations',
titleKey: 'settings.integrations.linear.title',
descriptionKey: 'settings.integrations.linear.description',
keywords: ['linear', 'issues', 'oauth', 'connect', 'workspace'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'integrations.linear.add-workspace',
page: 'integrations',
titleKey: 'settings.integrations.linear.actions.addWorkspace',
descriptionKey: 'settings.integrations.linear.description',
keywords: ['linear', 'workspace', 'add', 'connect', 'oauth'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'integrations.linear.mapping',
page: 'integrations',
titleKey: 'settings.integrations.linear.mapping.defaultProject',
descriptionKey: 'settings.integrations.linear.mapping.defaultProject.info',
keywords: ['linear', 'project', 'team', 'map', 'workspace', 'directory'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'integrations.third-party',
page: 'integrations',
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { bundledLanguages, type BundledLanguage, type LanguageRegistration } from 'shiki';
import { hasCatastrophicTemplateCall, sanitizeTemplateCallGrammar } from './sanitizeTemplateCallGrammar';
type BundledLanguageModule = { default: LanguageRegistration[] };
const loadBundledGrammars = async (id: BundledLanguage): Promise<LanguageRegistration[]> => {
// SAFETY: `id` is a Shiki bundled-language key and every bundled language
// module default-exports its grammar array.
const mod = (await bundledLanguages[id]()) as BundledLanguageModule;
return mod.default;
};
describe('sanitizeTemplateCallGrammar', () => {
test('detects template-call on bundled JS/TS grammars', async () => {
for (const id of ['javascript', 'typescript', 'jsx', 'tsx'] as const) {
const [grammar] = await loadBundledGrammars(id);
expect(hasCatastrophicTemplateCall(grammar)).toBe(true);
}
});
test('a bundled alias request yields sanitized grammars too', async () => {
// `js` is a separate key in bundledLanguages resolving to the same grammar
// module; the worker sanitizes whatever id was requested, so the alias must
// come out clean as well.
const grammars = await loadBundledGrammars('js');
const patched = grammars.map((grammar) => sanitizeTemplateCallGrammar(grammar));
expect(grammars.some((grammar) => hasCatastrophicTemplateCall(grammar))).toBe(true);
expect(patched.some((grammar) => hasCatastrophicTemplateCall(grammar))).toBe(false);
});
test('an embedding grammar carries JS/TS entries that are sanitized as well', async () => {
// `vue` ships the JS/TS grammars alongside its own, so gating on the
// requested id alone would leave them unpatched.
const grammars = await loadBundledGrammars('vue');
const affected = grammars.filter((grammar) => hasCatastrophicTemplateCall(grammar));
expect(affected.length).toBeGreaterThan(0);
const patched = grammars.map((grammar) => sanitizeTemplateCallGrammar(grammar));
expect(patched.some((grammar) => hasCatastrophicTemplateCall(grammar))).toBe(false);
});
test('clears template-call patterns without dropping the repository key', async () => {
const [grammar] = await loadBundledGrammars('javascript');
const patched = sanitizeTemplateCallGrammar(grammar);
expect(hasCatastrophicTemplateCall(patched)).toBe(false);
expect(patched.repository?.['template-call']).toEqual({ patterns: [] });
// Original left intact (spread, not mutate-in-place).
expect(hasCatastrophicTemplateCall(grammar)).toBe(true);
});
test('is a no-op when template-call is already empty', () => {
const grammar = {
name: 'javascript',
scopeName: 'source.js',
patterns: [],
repository: { 'template-call': { patterns: [] } },
} satisfies LanguageRegistration;
expect(sanitizeTemplateCallGrammar(grammar)).toBe(grammar);
});
});
@@ -0,0 +1,37 @@
/**
* Neutralize the JavaScript/TypeScript TextMate `template-call` rule.
*
* Upstream grammars use a triple-nested `{()[]}` lookahead to detect tagged
* templates with type arguments (`foo<T>\`...\``). On the Oniguruma WASM engine
* shipped with Shiki which does not expose `setRetryLimit` / match-stack
* limits that pattern can enter exponential backtracking on ordinary
* backtick template literals, grow the WASM heap without bound, and OOM the
* renderer (openchamber/openchamber#2587).
*
* Clearing `template-call` is safe: the plain `#template` rule still highlights
* backticks and simple tagged templates. Only the rare `ident<TypeArgs>\`...\``
* form loses its specialized type-argument coloring and falls through to
* normal tokenization.
*/
type GrammarRepository = Record<string, { patterns?: unknown[] } | undefined>;
export type TemplateCallGrammar = {
name?: string;
repository?: GrammarRepository;
};
const TEMPLATE_CALL_KEY = 'template-call';
export const hasCatastrophicTemplateCall = (grammar: TemplateCallGrammar): boolean => {
const patterns = grammar.repository?.[TEMPLATE_CALL_KEY]?.patterns;
return Array.isArray(patterns) && patterns.length > 0;
};
export const sanitizeTemplateCallGrammar = <T extends TemplateCallGrammar>(grammar: T): T => {
if (!hasCatastrophicTemplateCall(grammar)) return grammar;
const repository = { ...grammar.repository };
repository[TEMPLATE_CALL_KEY] = { patterns: [] };
return { ...grammar, repository };
};
+2 -2
View File
@@ -8,8 +8,8 @@ import {
} from './shortcuts';
describe('getEffectiveShortcutPrefix', () => {
test('falls back to the action default (bare mod) when unset', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod');
test('falls back to the action default (bare mod+alt) when unset', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod+alt');
});
test('honors modifier + key overrides', () => {
-688
View File
@@ -1,688 +0,0 @@
import { isMacOS } from '@/lib/utils';
import { isDesktopShell } from '@/lib/desktop';
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl';
type ShortcutKey = string;
export type ShortcutCombo = string;
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
export interface ShortcutAction {
id: string;
defaultCombo: ShortcutCombo;
label: string;
description?: string;
customizable?: boolean;
}
interface ParsedShortcut {
modifiers: Set<ShortcutModifier>;
key: ShortcutKey;
}
const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
'mod': 'mod',
'shift': 'shift',
'alt': 'alt',
'option': 'alt',
'ctrl': 'ctrl',
'meta': 'mod',
'cmd': 'mod',
'command': 'mod',
};
const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl',
'shift': '⇧',
'alt': '⌥',
'option': '⌥',
'ctrl': '⌃',
};
// Physical `event.key` values (lowercased) that satisfy each modifier while a
// chord is being held. `mod` maps to the platform primary key; on web macOS it
// accepts either Meta or Ctrl, matching eventMatchesShortcut.
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
'shift': ['shift'],
'alt': ['alt'],
'option': ['alt'],
'ctrl': ['control'],
};
const KEY_LABEL_MAP: Record<string, string> = {
'comma': ',',
'period': '.',
'enter': 'Enter',
'escape': 'Esc',
'tab': 'Tab',
'space': 'Space',
'backspace': '⌫',
'delete': '⌦',
'arrowup': '↑',
'arrowdown': '↓',
'arrowleft': '←',
'arrowright': '→',
'home': 'Home',
'end': 'End',
'pageup': 'Page Up',
'pagedown': 'Page Down',
};
const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt'];
const SHIFTED_KEY_BASE_MAP: Record<string, string> = {
'{': '[',
'}': ']',
':': ';',
'"': "'",
'<': ',',
'>': '.',
'?': '/',
'|': '\\',
'~': '`',
'!': '1',
'@': '2',
'#': '3',
'$': '4',
'%': '5',
'^': '6',
'&': '7',
'*': '8',
'(': '9',
')': '0',
};
function isUnassignedShortcut(combo: ShortcutCombo): boolean {
return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT;
}
export function keyToShortcutToken(key: string): string {
const lowered = key.toLowerCase();
if (lowered === ',') return 'comma';
if (lowered === '.') return 'period';
if (lowered === ' ') return 'space';
if (lowered === 'esc') return 'escape';
if (lowered === '+') return 'plus';
if (lowered === '-' || lowered === '_') return 'minus';
if (lowered === 'arrowup') return 'arrowup';
if (lowered === 'arrowdown') return 'arrowdown';
if (lowered === 'arrowleft') return 'arrowleft';
if (lowered === 'arrowright') return 'arrowright';
return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered;
}
const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
{
id: 'open_go_to_line',
defaultCombo: 'alt+g',
label: 'Go to line (files editor)',
description: 'Open go to line in the files editor',
customizable: true,
},
{
id: 'open_command_palette',
defaultCombo: 'mod+p',
label: 'Open command palette',
description: 'Open the command palette',
customizable: true,
},
{
id: 'focus_input',
defaultCombo: 'mod+i',
label: 'Focus input',
description: 'Focus the chat input field',
customizable: true,
},
{
id: 'open_status',
defaultCombo: 'mod+shift+o',
label: 'Open OpenCode status',
description: 'Open the OpenCode status dialog',
},
{
id: 'open_settings',
defaultCombo: 'mod+comma',
label: 'Open settings',
description: 'Open the settings panel',
customizable: true,
},
{
id: 'toggle_terminal',
defaultCombo: 'mod+j',
label: 'Toggle terminal dock',
description: 'Toggle the bottom terminal dock',
customizable: true,
},
{
id: 'toggle_terminal_expanded',
defaultCombo: 'mod+shift+j',
label: 'Toggle terminal expanded',
description: 'Toggle terminal expanded or collapsed',
customizable: true,
},
{
id: 'toggle_files',
defaultCombo: 'mod+shift+f',
label: 'Toggle files',
description: 'Toggle the files panel',
},
{
id: 'add_selection_to_chat',
defaultCombo: 'mod+l',
label: 'Add selection to chat',
description: 'Add the selected text to the chat input',
customizable: true,
},
{
id: 'toggle_sidebar',
defaultCombo: 'mod+alt+l',
label: 'Toggle sidebar',
description: 'Toggle the session sidebar',
customizable: true,
},
{
id: 'open_timeline_dialog',
defaultCombo: 'mod+t',
label: 'Open conversation timeline',
description: 'Search and navigate within current conversation',
customizable: true,
},
{
id: 'toggle_prompt_navigator',
defaultCombo: 'mod+alt+p',
label: 'Toggle prompt navigator',
description: 'Show or hide the prompt navigator panel in chat',
customizable: true,
},
{
id: 'toggle_right_sidebar',
defaultCombo: 'mod+b',
label: 'Toggle right sidebar',
description: 'Toggle the right sidebar',
customizable: true,
},
{
id: 'open_right_sidebar_git',
defaultCombo: 'mod+shift+g',
label: 'Open right sidebar Git tab',
description: 'Open right sidebar and select Git',
customizable: true,
},
{
id: 'open_right_sidebar_files',
defaultCombo: 'mod+shift+f',
label: 'Open right sidebar Files tab',
description: 'Open right sidebar and select Files',
customizable: true,
},
{
id: 'switch_context_surface',
defaultCombo: 'mod',
label: 'Switch context panel surface',
description: 'Hold the modifier and press a number to open or close the matching rail icon',
customizable: true,
},
{
id: 'new_chat',
defaultCombo: 'mod+n',
label: 'New session',
description: 'Start a new session',
customizable: true,
},
{
id: 'new_chat_worktree',
defaultCombo: 'mod+shift+n',
label: 'New worktree draft',
description: 'Create a new worktree and open a draft in it',
customizable: true,
},
{
id: 'close_session_tab',
defaultCombo: 'alt+w',
label: 'Close session tab',
description: 'Close the active session tab in the header (the session itself stays)',
customizable: true,
},
{
id: 'new_mini_chat',
defaultCombo: 'mod+alt+n',
label: 'New Mini Chat window',
description: 'Open a new Mini Chat draft window',
customizable: true,
},
{
id: 'submit_message',
defaultCombo: 'mod+enter',
label: 'Submit message',
description: 'Submit the current message',
},
{
id: 'clear_input',
defaultCombo: 'escape',
label: 'Clear input',
description: 'Clear the input field',
},
{
id: 'open_help',
defaultCombo: 'mod+.',
label: 'Open keyboard shortcuts',
description: 'Show the keyboard shortcuts help',
customizable: true,
},
{
id: 'toggle_context_plan',
defaultCombo: 'mod+shift+p',
label: 'Toggle plan context panel',
description: 'Open or close plan in the context panel',
customizable: true,
},
{
id: 'toggle_services_menu',
defaultCombo: 'mod+shift+s',
label: 'Toggle services menu',
description: 'Open or close the services menu',
customizable: true,
},
{
id: 'cycle_services_tab',
defaultCombo: 'mod+shift+[',
label: 'Cycle services tab',
description: 'Cycle through tabs in the services menu',
customizable: true,
},
{
id: 'cycle_theme',
defaultCombo: 'mod+/',
label: 'Cycle theme',
description: 'Cycle between light, dark, and system theme',
customizable: true,
},
{
id: 'open_model_selector',
defaultCombo: 'mod+shift+m',
label: 'Open model selector',
description: 'Open model selector while in chat',
customizable: true,
},
{
id: 'cycle_thinking_variant',
defaultCombo: 'mod+shift+t',
label: 'Cycle thinking variant',
description: 'Cycle thinking variant while in chat',
},
{
id: 'cycle_agent',
defaultCombo: 'tab',
label: 'Cycle agent',
description: 'Cycle agent while the model selector is open',
customizable: true,
},
{
id: 'cycle_favorite_model_forward',
defaultCombo: 'ctrl+]',
label: 'Cycle favorite model forward',
description: 'Cycle forward through starred models without opening the picker',
customizable: true,
},
{
id: 'cycle_favorite_model_backward',
defaultCombo: 'ctrl+[',
label: 'Cycle favorite model backward',
description: 'Cycle backward through starred models without opening the picker',
customizable: true,
},
{
id: 'expand_input',
defaultCombo: 'mod+shift+e',
label: 'Expand input',
description: 'Toggle focus mode for the chat input',
customizable: true,
},
{
id: 'toggle_dictation',
defaultCombo: 'mod+alt+v',
label: 'Voice input',
description: 'Start dictation; press again to confirm and insert the transcript',
customizable: true,
},
{
id: 'abort_run',
defaultCombo: 'escape',
label: 'Abort active run',
description: 'Abort the currently running task (double press)',
},
] as const;
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
if (isUnassignedShortcut(combo)) {
return UNASSIGNED_SHORTCUT;
}
const rawParts = combo
.toLowerCase()
.trim()
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const modifiers = new Set<ShortcutModifier>();
let key = '';
for (const rawPart of rawParts) {
const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart;
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
continue;
}
key = part;
}
const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier));
return [...orderedModifiers, key].filter(Boolean).join('+');
}
function isValidShortcutCombo(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) {
return true;
}
const parsed = parseShortcut(combo);
return parsed.key.trim().length > 0;
}
function parseShortcut(combo: ShortcutCombo): ParsedShortcut {
if (isUnassignedShortcut(combo)) {
return { modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT };
}
const normalized = normalizeCombo(combo);
const parts = normalized.split('+');
const modifiers: Set<ShortcutModifier> = new Set();
let key: ShortcutKey = '';
for (const part of parts) {
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
} else {
key = part;
}
}
return { modifiers, key };
}
export function formatShortcutForDisplay(combo: ShortcutCombo): string {
if (isUnassignedShortcut(combo)) {
return 'Unassigned';
}
const parsed = parseShortcut(combo);
if (!parsed.key && parsed.modifiers.size === 0) {
return 'Unassigned';
}
const parts: string[] = [];
for (const modifier of MODIFIER_PRIORITY) {
if (parsed.modifiers.has(modifier)) {
parts.push(DISPLAY_LABEL_MAP[modifier]);
}
}
if (parsed.key) {
const keyLabel = KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase();
parts.push(keyLabel);
}
return parts.join(' + ');
}
export function getShortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_ACTIONS.find((action) => action.id === id);
}
export function getCustomizableShortcutActions(): ReadonlyArray<ShortcutAction> {
return SHORTCUT_ACTIONS.filter((action) => action.customizable === true);
}
export function getEffectiveShortcutCombo(
actionId: string,
overrides?: Record<string, ShortcutCombo>
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) {
return '';
}
const override = overrides?.[actionId];
if (typeof override === 'string') {
if (override.trim().toLowerCase() === UNASSIGNED_SHORTCUT) {
return '';
}
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) {
return UNASSIGNED_SHORTCUT;
}
if (isValidShortcutCombo(normalized)) {
return normalized;
}
}
return action.defaultCombo;
}
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) {
return false;
}
const parsed = parseShortcut(combo);
if (!parsed.modifiers.has('mod')) {
return false;
}
const key = parsed.key.toLowerCase();
const dangerousPrimary = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']);
return dangerousPrimary.has(key) && !parsed.modifiers.has('shift') && !parsed.modifiers.has('alt');
}
export function eventMatchesShortcut(
event: KeyboardEvent | React.KeyboardEvent,
shortcut: ShortcutAction | ShortcutCombo
): boolean {
const combo = typeof shortcut === 'string' ? shortcut : shortcut.defaultCombo;
if (isUnassignedShortcut(combo)) {
return false;
}
const parsed = parseShortcut(combo);
const expectedMod = parsed.modifiers.has('mod');
const expectedShift = parsed.modifiers.has('shift');
const expectedAlt = parsed.modifiers.has('alt');
const expectedCtrl = parsed.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac
? event.metaKey
: isMac
? (event.metaKey || event.ctrlKey)
: event.ctrlKey;
if (expectedMod && !modMatches) {
return false;
}
if (!expectedMod && event.metaKey) {
return false;
}
if (expectedShift !== event.shiftKey) {
return false;
}
if (expectedAlt !== event.altKey) {
return false;
}
if (expectedCtrl) {
if (!event.ctrlKey) {
return false;
}
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) {
return false;
}
}
let eventKeyRaw = event.key;
if (event.altKey) {
if (event.code.startsWith('Key') && event.code.length === 4) {
eventKeyRaw = event.code.slice(3).toLowerCase();
} else if (event.code.startsWith('Digit') && event.code.length === 6) {
eventKeyRaw = event.code.slice(5);
}
}
const eventKey = keyToShortcutToken(eventKeyRaw);
const expectedKey = keyToShortcutToken(parsed.key);
return eventKey === expectedKey;
}
export function getModifierLabel(): string {
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
}
/**
* Resolves the configurable prefix for chord-style shortcuts such as
* "switch context panel surface", where a trailing digit key completes the
* combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the
* bare `mod` primary key) are honored so the prefix can omit a primary key.
* Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix.
*/
export function getEffectiveShortcutPrefix(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) {
return '';
}
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) {
return UNASSIGNED_SHORTCUT;
}
if (normalized) {
const parsed = parseShortcut(normalized);
if (parsed.modifiers.size > 0 || parsed.key) {
return normalized;
}
}
}
return action.defaultCombo;
}
/**
* True when the physical keys required to "arm" a prefix combo are currently
* held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at
* least one alias must be held.
*/
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
for (const modifier of parsed.modifiers) {
const aliases = MODIFIER_KEY_ALIASES[modifier];
if (!aliases.some((alias) => heldKeys.has(alias))) {
return false;
}
}
if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) {
return false;
}
return true;
}
/**
* Matches an activating keydown (the caller checks the event's own key, e.g. a
* digit) against a chord prefix: the event's modifier state must match the
* prefix's modifiers, and when the prefix has a primary key that key must
* currently be held.
*/
export function eventMatchesShortcutPrefix(
event: KeyboardEvent | React.KeyboardEvent,
prefixCombo: ShortcutCombo,
heldKeys?: ReadonlySet<string>,
): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
const expectedMod = parsed.modifiers.has('mod');
const expectedShift = parsed.modifiers.has('shift');
const expectedAlt = parsed.modifiers.has('alt');
const expectedCtrl = parsed.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac
? event.metaKey
: isMac
? (event.metaKey || event.ctrlKey)
: event.ctrlKey;
if (expectedMod && !modMatches) {
return false;
}
if (!expectedMod && event.metaKey) {
return false;
}
if (expectedShift !== event.shiftKey) {
return false;
}
if (expectedAlt !== event.altKey) {
return false;
}
if (expectedCtrl) {
if (!event.ctrlKey) {
return false;
}
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) {
return false;
}
}
if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) {
return false;
}
return true;
}
@@ -0,0 +1,67 @@
# Registration boundary
Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both accept only action IDs derived from `SHORTCUT_SCHEMA`. Batch registration also rejects undeclared keys in prebuilt objects, including objects that mix valid and misspelled IDs. Both hooks use the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command.
Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `config.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components.
# Schema contract
`config.ts` is the declaration-only source for application commands. It organizes entries into `session`, `models`, `panels`, `navigation`, and `application` groups, then explicitly concatenates them into `SHORTCUT_SCHEMA`. Every entry declares an ID, default binding, and whether users can customize it. Customizable entries also declare their Settings translation key, so Settings must not maintain an action-ID switch or English fallback labels.
Configuration must not contain lookup functions, override resolution, event matching, registry state, or runtime handlers. Those concerns belong to the owning modules below. Keeping configuration declarative makes the complete shortcut inventory reviewable without reading execution code.
Component interaction keys that are not application commands, such as list navigation or text editing, do not belong in the schema. Contextual application commands do belong there even when they are not customizable; `save_file` and `find_in_file` are examples.
# Module roles
- `index.ts` is the only public import surface, exposed as `@/lib/shortcuts`.
- `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`.
- `schema.ts` derives action and category types and provides schema lookup and effective binding resolution.
- `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules.
- `registry.ts` owns the active handler for each action ID and stack-safe temporary suspension of all application handlers.
- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
- `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render.
- Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts.
# Binding rules
Bindings remain persisted as `Record<string, string>`. Each binding has one chord or at most two space-separated chords, such as `mod+k p`. `mod` is the platform-neutral primary modifier (Command on macOS, Control elsewhere), while `alt` is the platform-neutral alternate modifier (Option on macOS, Alt elsewhere); `command`, `cmd`, `meta`, and `option` are accepted input aliases but normalize to those canonical tokens. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. Display formatting uses macOS keyboard symbols (`⌘`, `⌥`, `⌃`, `⇧`) on macOS and named modifiers (`Ctrl`, `Alt`, `Shift`) elsewhere, including tooltip and accessible text consumers. A single chord conflicts with a sequence sharing its first chord; sibling sequences are valid.
The default layout follows three modes: single chords for everyday actions, the `mod+k` leader for open/go actions (`mod+k p`, `mod+k g`, `mod+k l`, `mod+k t`, `mod+k n`, `mod+k i`, `mod+k h`), and held digit prefixes — held `mod` + digit switches header session tabs, held `mod+alt` + digit switches context panel surfaces. Every schema action ships with a default binding; palette-only commands (context surfaces, OpenCode status, memory debug) live outside the schema and the palette invokes their owning modules directly. Single-chord handlers still get the first chance at a leader's chord; returning `false` lets the dispatcher arm the sequence.
The internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile.
Both digit prefixes yield when the event target is editable: an input, textarea, select, or contenteditable element. `switch_session_tab` defaults to a bare `mod` prefix, so without that guard plain ctrl/cmd+digit would switch tabs while the user is typing in the composer.
The settings recorder captures up to two chords with at most three simultaneous physical keys per chord and checks the complete schema, not only customizable actions. After the first chord it waits up to 3000ms for a second; conflict and browser-risk feedback appears only when the second chord, timeout, or Confirm settles the recording. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts unless the single-chord action explicitly allows sequence fallback. Those contextual prefixes remain saveable with a warning because their handler yields outside its owning context. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
`add_selection_to_chat` is contextual. A visible text-selection toolbar publishes its Add to chat and dismiss actions, suspends the shared application registry, and clears both synchronously when hidden or unmounted. The main application route also gates directly on active toolbar ownership before global dispatch, so unrelated shortcuts cannot escape the scoped interaction even if runtime bundling isolates registry state. The newest visible toolbar owns a dedicated scoped dispatcher; it ignores IME composition, stops IME Escape before the global Escape route without preventing its native default, handles non-IME Escape and the configured Add to chat binding (including a two-chord binding), and lets native input continue for unrelated keys. The application handler returns `false` when no toolbar action is active, so an unselected or stale DOM range can instead become a sequence leader. Opening, closing, or replacing a toolbar invalidates any pending scoped or global prefix.
# Dispatching
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 3000ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; an exact second key remains eligible during IME composition and is prevented when handled, while an IME mismatch clears the prefix and retains normal composition input. Normal application shortcuts remain window-bubble listeners.
`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending global dispatcher prefix, so stale second keys and Escape cannot consume it. Interaction surfaces that need shortcuts while suspended must own a dedicated scoped dispatcher and process it before the global route.
Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGlobalShortcuts`; they suspend while open for both controlled and uncontrolled popups and resume on close or unmount. Exact `Ctrl+N` and `Ctrl+P` chords are translated to menu navigation even when the native event reports IME composition; no other composing key is intercepted. Window capture stops an IME Escape before Base UI's document-level dismiss listener without preventing the native IME action. Controlled draft project and worktree pickers close on non-IME Escape from either the trigger or portaled popup.
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
# Adding shortcuts
1. Add the command to the matching group in `config.ts`. Use a stable action ID and a normalized default binding. Keep sequences to at most two chords.
2. Mark the command `customizable: true` only when it should appear in Settings. Add its `settingsLabelKey` and provide that key in every locale in the same change.
3. Register the handler with `useKeybind` or `useKeybinds` near the state or UI that owns the behavior. Do not pass shortcut callbacks through unrelated components or move local UI state into a global store.
4. Return `false` when the mounted handler is not applicable in the current runtime or focus context. This lets another command sharing the binding or prefix continue dispatching.
5. Add or update schema, binding, registry, or dispatcher tests for the changed contract. Update Help Dialog metadata when the command should be discoverable there.
# Best practices
- Import production APIs only from `@/lib/shortcuts`; deep imports are reserved for files and tests inside this module.
- Keep `config.ts` declarative and grouped. Do not add helpers there for querying state or executing behavior.
- Every application command must appear exactly once in `SHORTCUT_SCHEMA`, including internal and debug commands. Component-only editing and navigation keys stay local and out of the schema.
- Avoid exact default-binding conflicts. When runtime-exclusive commands intentionally share one, document the reason beside both declarations and make each handler return `false` outside its runtime.
- Persist bindings as normalized strings. Never change the `Record<string, string>` override contract without an explicit migration and compatibility tests.
- Preserve the two-chord maximum in configuration, recording UI, parsing, conflict detection, display, and tests.
@@ -0,0 +1,158 @@
import { describe, expect, test } from 'bun:test';
import {
eventMatchesShortcut,
eventMatchesShortcutPrefix,
formatShortcutForDisplay,
getEffectiveShortcutPrefix,
getShortcutConflict,
isRiskyBrowserShortcut,
isShortcutPrefixHeld,
normalizeCombo,
parseShortcut,
resolveShortcutEventDigit,
UNASSIGNED_SHORTCUT,
} from './index';
describe('getEffectiveShortcutPrefix', () => {
test('falls back to the action default (bare mod+alt) when unset', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod+alt');
});
test('honors modifier + key overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p');
});
test('honors modifier-only overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift');
});
test('returns UNASSIGNED for an explicit unassignment', () => {
expect(
getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }),
).toBe(UNASSIGNED_SHORTCUT);
});
test('returns empty string for an unknown action', () => {
expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe('');
});
});
describe('isShortcutPrefixHeld', () => {
test('false for an unassigned prefix', () => {
expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false);
});
test('requires the prefix primary key to be held', () => {
expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true);
});
test('requires every prefix modifier to be held', () => {
expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true);
});
});
const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent =>
({
key,
metaKey: mods.meta ?? false,
ctrlKey: mods.ctrl ?? false,
shiftKey: mods.shift ?? false,
altKey: mods.alt ?? false,
}) as KeyboardEvent;
describe('eventMatchesShortcutPrefix', () => {
test('matches a bare mod prefix when the primary modifier is held', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true);
});
test('rejects a bare mod prefix without the primary modifier', () => {
expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false);
});
test('rejects when the event carries modifiers the prefix does not expect', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false);
});
test('requires the prefix primary key to be held at match time', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false);
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true);
});
test('false for an unassigned prefix', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false);
});
});
describe('shortcut sequences', () => {
test('normalizes, parses, and formats up to two chords', () => {
expect(normalizeCombo(' command + S P ')).toBe('mod+s p');
expect(parseShortcut('mod+s p')?.chords).toHaveLength(2);
expect(formatShortcutForDisplay('mod+s p')).toBe('Ctrl + S, P');
});
test('rejects bindings with more than two chords', () => {
expect(normalizeCombo('mod+s p q')).toBe('');
expect(parseShortcut('mod+s p q')).toBe(undefined);
});
test('reports exact and prefix conflicts but allows sibling sequences', () => {
expect(getShortcutConflict('mod+s', 'mod+s')).toBe('exact');
expect(getShortcutConflict('mod+s', 'mod+s p')).toBe('prefix');
expect(getShortcutConflict('mod+s p', 'mod+s q')).toBe(undefined);
});
test('warns when a sequence leader conflicts with a browser shortcut', () => {
expect(isRiskyBrowserShortcut('mod+s p')).toBe(true);
});
});
describe('platform shortcut labels', () => {
test('normalizes Command and Option to platform-neutral modifiers', () => {
expect(normalizeCombo('command+option+n')).toBe('mod+alt+n');
});
test('uses macOS modifier symbols', () => {
expect(formatShortcutForDisplay('mod+ctrl+shift+alt+n', 'Unassigned', 'macos')).toBe(
'⌘ + ⌃ + ⇧ + ⌥ + N',
);
expect(formatShortcutForDisplay('alt', 'Unassigned', 'macos')).toBe('⌥');
});
test('uses named modifiers on other platforms', () => {
expect(formatShortcutForDisplay('mod+shift+alt+n', 'Unassigned', 'other')).toBe(
'Ctrl + Shift + Alt + N',
);
expect(formatShortcutForDisplay('alt', 'Unassigned', 'other')).toBe('Alt');
});
});
describe('layout-independent key matching', () => {
const event = (overrides: Partial<KeyboardEvent>): KeyboardEvent =>
// SAFETY: the matcher only reads the modifier flags, key, and code
// provided here; a full KeyboardEvent is not constructible in bun tests.
({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, key: '', code: '', ...overrides }) as KeyboardEvent;
test('a non-Latin layout letter matches through the physical key code', () => {
expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'л', code: 'KeyK' }), 'mod+k')).toBe(true);
expect(eventMatchesShortcut(event({ key: 'з', code: 'KeyP' }), 'p')).toBe(true);
});
test('macOS Option symbol substitution matches through the digit code', () => {
expect(eventMatchesShortcut(event({ ctrlKey: true, altKey: true, key: '¡', code: 'Digit1' }), 'mod+alt+1')).toBe(true);
});
test('Latin layouts that move keys keep their key-based meaning', () => {
// Dvorak: physical KeyT produces "y"; the binding follows the character.
expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'y', code: 'KeyT' }), 'mod+y')).toBe(true);
expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'y', code: 'KeyT' }), 'mod+t')).toBe(false);
});
test('resolveShortcutEventDigit reads the digit from the code under Option', () => {
expect(resolveShortcutEventDigit({ key: '¡', code: 'Digit1' })).toBe('1');
expect(resolveShortcutEventDigit({ key: '5', code: 'Digit5' })).toBe('5');
expect(resolveShortcutEventDigit({ key: 'a', code: 'KeyA' })).toBe(null);
});
});
+370
View File
@@ -0,0 +1,370 @@
import type React from 'react';
import { isDesktopShell } from '@/lib/desktop';
import { isMacOS } from '@/lib/utils';
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'ctrl';
type ShortcutDisplayPlatform = 'macos' | 'other';
type ShortcutKey = string;
export type ShortcutCombo = string;
export type ShortcutConflict = 'exact' | 'prefix';
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
interface ParsedShortcutChord {
modifiers: Set<ShortcutModifier>;
key: ShortcutKey;
}
export interface ParsedShortcut {
chords: ReadonlyArray<ParsedShortcutChord>;
}
const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
mod: 'mod',
shift: 'shift',
alt: 'alt',
option: 'alt',
ctrl: 'ctrl',
meta: 'mod',
cmd: 'mod',
command: 'mod',
};
const MODIFIER_LABELS: Record<ShortcutDisplayPlatform, Record<ShortcutModifier, string>> = {
macos: {
mod: '⌘',
shift: '⇧',
alt: '⌥',
ctrl: '⌃',
},
other: {
mod: 'Ctrl',
shift: 'Shift',
alt: 'Alt',
ctrl: 'Ctrl',
},
};
const KEY_LABEL_MAP: Record<string, string> = {
comma: ',',
period: '.',
enter: 'Enter',
escape: 'Esc',
tab: 'Tab',
space: 'Space',
backspace: '⌫',
delete: '⌦',
arrowup: '↑',
arrowdown: '↓',
arrowleft: '←',
arrowright: '→',
home: 'Home',
end: 'End',
pageup: 'Page Up',
pagedown: 'Page Down',
};
const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt'];
const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n', 'q', 'd', 'h', 'j', 'o', 'u']);
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
mod: isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
shift: ['shift'],
alt: ['alt'],
ctrl: ['control'],
};
const SHIFTED_KEY_BASE_MAP: Record<string, string> = {
'{': '[',
'}': ']',
':': ';',
'"': "'",
'<': ',',
'>': '.',
'?': '/',
'|': '\\',
'~': '`',
'!': '1',
'@': '2',
'#': '3',
'$': '4',
'%': '5',
'^': '6',
'&': '7',
'*': '8',
'(': '9',
')': '0',
};
function isUnassignedShortcut(combo: ShortcutCombo): boolean {
return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT;
}
export function keyToShortcutToken(key: string): string {
const lowered = key.toLowerCase();
if (lowered === ',') return 'comma';
if (lowered === '.') return 'period';
if (lowered === ' ') return 'space';
if (lowered === 'esc') return 'escape';
if (lowered === '+') return 'plus';
if (lowered === '-' || lowered === '_') return 'minus';
if (lowered === 'arrowup') return 'arrowup';
if (lowered === 'arrowdown') return 'arrowdown';
if (lowered === 'arrowleft') return 'arrowleft';
if (lowered === 'arrowright') return 'arrowright';
return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered;
}
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
if (isUnassignedShortcut(combo)) return UNASSIGNED_SHORTCUT;
const chords = combo
.trim()
.replace(/\s*\+\s*/g, '+')
.split(/\s+/)
.filter(Boolean);
if (chords.length === 0 || chords.length > 2) return '';
return chords.map(normalizeChord).join(' ');
}
function normalizeChord(combo: ShortcutCombo): ShortcutCombo {
const rawParts = combo
.toLowerCase()
.trim()
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const modifiers = new Set<ShortcutModifier>();
let key = '';
for (const rawPart of rawParts) {
const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart;
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
} else {
key = part;
}
}
const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier));
return [...orderedModifiers, key].filter(Boolean).join('+');
}
export function isValidShortcutCombo(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) return true;
const parsed = parseShortcut(combo);
return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0);
}
export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined {
if (isUnassignedShortcut(combo)) {
return { chords: [{ modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT }] };
}
const normalized = normalizeCombo(combo);
if (!normalized) return undefined;
return {
chords: normalized.split(' ').map((chord) => {
const modifiers = new Set<ShortcutModifier>();
let key: ShortcutKey = '';
for (const part of chord.split('+')) {
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
} else {
key = part;
}
}
return { modifiers, key };
}),
};
}
function getShortcutDisplayPlatform(): ShortcutDisplayPlatform {
return isMacOS() ? 'macos' : 'other';
}
export function formatShortcutForDisplay(
combo: ShortcutCombo,
unassignedLabel = 'Unassigned',
platform = getShortcutDisplayPlatform(),
): string {
if (isUnassignedShortcut(combo)) return unassignedLabel;
const parsed = parseShortcut(combo);
if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) {
return unassignedLabel;
}
return parsed.chords.map((chord) => formatChordForDisplay(chord, platform)).join(', ');
}
function formatChordForDisplay(
parsed: ParsedShortcutChord,
platform: ShortcutDisplayPlatform,
): string {
const modifierLabels = MODIFIER_LABELS[platform];
const parts = MODIFIER_PRIORITY
.filter((modifier) => parsed.modifiers.has(modifier))
.map((modifier) => modifierLabels[modifier]);
if (parsed.key) {
parts.push(KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase());
}
return parts.join(' + ');
}
export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined {
const normalizedLeft = normalizeCombo(left);
const normalizedRight = normalizeCombo(right);
const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) || !isValidShortcutCombo(normalizedRight);
const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT
|| normalizedRight === UNASSIGNED_SHORTCUT;
if (hasInvalidBinding || hasUnassignedBinding) return undefined;
if (normalizedLeft === normalizedRight) return 'exact';
const leftChords = normalizedLeft.split(' ');
const rightChords = normalizedRight.split(' ');
const sharesLeader = leftChords[0] === rightChords[0];
return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined;
}
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) return false;
const parsed = parseShortcut(combo);
if (!parsed) return false;
// Every chord counts: a second chord like "mod+w" is just as capable of
// closing the tab as a first one, and mod+shift+w closes a window.
return parsed.chords.some((chord) => {
if (!chord.modifiers.has('mod')) return false;
if (chord.modifiers.has('alt')) return false;
if (chord.modifiers.has('shift')) {
return chord.key.toLowerCase() === 'w' || chord.key.toLowerCase() === 'q';
}
return RISKY_BROWSER_SHORTCUT_KEYS.has(chord.key.toLowerCase());
});
}
const CODE_KEY_MAP = new Map<string, string>([
['Comma', ','],
['Period', '.'],
['Slash', '/'],
['Backquote', '`'],
['BracketLeft', '['],
['BracketRight', ']'],
['Semicolon', ';'],
['Quote', "'"],
['Minus', '-'],
['Equal', '='],
]);
function keyFromEventCode(code: string): string | null {
if (code.startsWith('Key') && code.length === 4) return code.slice(3).toLowerCase();
if (code.startsWith('Digit') && code.length === 6) return code.slice(5);
return CODE_KEY_MAP.get(code) ?? null;
}
/**
* The character a physical key press should match against bindings. `key`
* carries the layout-produced character: Option on macOS substitutes symbols
* ("¡" for 1) and non-Latin layouts substitute their own alphabet ("л" for
* K). Both keep the physical key in `code`, so those two cases fall back to
* it; Latin layouts that MOVE keys (Dvorak, AZERTY) keep their `key`-based
* meaning untouched.
*/
export function resolveShortcutEventKey(
event: Pick<KeyboardEvent, 'key' | 'code' | 'altKey'>,
): string {
const raw = event.key;
if (event.altKey) return keyFromEventCode(event.code) ?? raw;
if (raw.length === 1 && raw.charCodeAt(0) > 127) return keyFromEventCode(event.code) ?? raw;
return raw;
}
/** The digit a press addresses, layout- and Option-proof via `code`. */
export function resolveShortcutEventDigit(
event: Pick<KeyboardEvent, 'key' | 'code'>,
): string | null {
if (event.code.startsWith('Digit') && event.code.length === 6) return event.code.slice(5);
return event.key.length === 1 && event.key >= '0' && event.key <= '9' ? event.key : null;
}
export function eventMatchesShortcut(
event: KeyboardEvent | React.KeyboardEvent,
combo: ShortcutCombo,
): boolean {
if (isUnassignedShortcut(combo)) return false;
const parsed = parseShortcut(combo);
if (!parsed || parsed.chords.length !== 1) return false;
const chord = parsed.chords[0];
const expectedMod = chord.modifiers.has('mod');
const expectedShift = chord.modifiers.has('shift');
const expectedAlt = chord.modifiers.has('alt');
const expectedCtrl = chord.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
let modMatches = event.ctrlKey;
if (isDesktopMac) {
modMatches = event.metaKey;
} else if (isMac) {
modMatches = event.metaKey || event.ctrlKey;
}
if (expectedMod && !modMatches) return false;
if (!expectedMod && event.metaKey) return false;
if (expectedShift !== event.shiftKey) return false;
if (expectedAlt !== event.altKey) return false;
if (expectedCtrl) {
if (!event.ctrlKey) return false;
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) return false;
}
return keyToShortcutToken(resolveShortcutEventKey(event)) === keyToShortcutToken(chord.key);
}
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
if (isUnassignedShortcut(prefixCombo)) return false;
const parsed = parseShortcut(prefixCombo);
if (!parsed || parsed.chords.length !== 1) return false;
const chord = parsed.chords[0];
for (const modifier of chord.modifiers) {
if (!MODIFIER_KEY_ALIASES[modifier].some((alias) => heldKeys.has(alias))) return false;
}
return !chord.key || heldKeys.has(chord.key.toLowerCase());
}
export function eventMatchesShortcutPrefix(
event: KeyboardEvent | React.KeyboardEvent,
prefixCombo: ShortcutCombo,
heldKeys?: ReadonlySet<string>,
): boolean {
if (isUnassignedShortcut(prefixCombo)) return false;
const parsed = parseShortcut(prefixCombo);
if (!parsed || parsed.chords.length !== 1) return false;
const chord = parsed.chords[0];
const expectedMod = chord.modifiers.has('mod');
const expectedShift = chord.modifiers.has('shift');
const expectedAlt = chord.modifiers.has('alt');
const expectedCtrl = chord.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac ? event.metaKey : isMac ? event.metaKey || event.ctrlKey : event.ctrlKey;
if (expectedMod && !modMatches) return false;
if (!expectedMod && event.metaKey) return false;
if (expectedShift !== event.shiftKey || expectedAlt !== event.altKey) return false;
if (expectedCtrl) {
if (!event.ctrlKey) return false;
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) return false;
}
return !chord.key || Boolean(heldKeys?.has(chord.key.toLowerCase()));
}
+280
View File
@@ -0,0 +1,280 @@
import type { ShortcutCombo } from './bindings';
type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application';
type ShortcutConfig = {
id: string;
defaultBinding: ShortcutCombo;
/** The binding is a bare-modifier chord prefix (completed by another key);
conflict resolution compares its prefix rather than a full combo. */
prefixStyle?: true;
} & (
| { customizable: false }
| {
customizable: true;
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${string}.label`;
}
);
// Default layout, unified around three modes:
// - Single chords for everyday actions.
// - The mod+k leader for "open/go" actions, second key mnemonic.
// - Held mod + digit switches header session tabs; held mod+alt + digit
// switches context panel surfaces (mod+shift+digit is reserved by macOS
// screenshots).
// Everything else lives only in the command palette, outside this schema.
const SHORTCUT_GROUPS = {
session: [
{
id: 'add_selection_to_chat',
defaultBinding: 'mod+l',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label',
},
{
id: 'focus_input',
defaultBinding: 'mod+i',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.focus_input.label',
},
{
id: 'open_timeline_dialog',
defaultBinding: 'mod+k t',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label',
},
{
id: 'new_chat',
defaultBinding: 'mod+n',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_chat.label',
},
{
id: 'switch_session_previous',
defaultBinding: 'mod+alt+arrowleft',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label',
},
{
id: 'switch_session_next',
defaultBinding: 'mod+alt+arrowright',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label',
},
{
id: 'rename_current_session',
defaultBinding: 'mod+k r',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label',
},
{
id: 'toggle_permission_auto_accept',
defaultBinding: 'mod+k a',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label',
},
{
id: 'close_session_tab',
defaultBinding: 'alt+w',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label',
},
{
id: 'open_draft_project_picker',
defaultBinding: 'mod+k p',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label',
},
{
id: 'open_draft_worktree_picker',
defaultBinding: 'mod+k g',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label',
},
{
id: 'open_session_list',
defaultBinding: 'mod+k l',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_session_list.label',
},
{
id: 'new_chat_worktree',
defaultBinding: 'mod+shift+n',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label',
},
{
id: 'new_mini_chat',
defaultBinding: 'mod+alt+n',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label',
},
{
id: 'expand_input',
defaultBinding: 'mod+shift+e',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.expand_input.label',
},
{
id: 'toggle_dictation',
defaultBinding: 'mod+alt+v',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label',
},
{ id: 'abort_run', defaultBinding: 'escape', customizable: false },
],
models: [
{
id: 'open_model_selector',
defaultBinding: 'mod+shift+m',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label',
},
{ id: 'cycle_thinking_variant', defaultBinding: 'mod+shift+t', customizable: false },
{
id: 'cycle_agent',
defaultBinding: 'tab',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label',
},
{
id: 'cycle_favorite_model_forward',
defaultBinding: 'ctrl+]',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label',
},
{
id: 'cycle_favorite_model_backward',
defaultBinding: 'ctrl+[',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label',
},
],
panels: [
{
id: 'toggle_terminal',
defaultBinding: 'mod+j',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label',
},
{
id: 'toggle_terminal_expanded',
defaultBinding: 'mod+shift+j',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label',
},
{
id: 'toggle_sidebar',
defaultBinding: 'mod+b',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label',
},
{
id: 'toggle_prompt_navigator',
defaultBinding: 'mod+k n',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label',
},
{
id: 'switch_session_tab',
defaultBinding: 'mod',
// The binding is a bare modifier acting as a chord prefix (completed by
// a digit); conflict resolution must compare its PREFIX, not a combo.
prefixStyle: true,
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label',
},
{
id: 'switch_context_surface',
defaultBinding: 'mod+alt',
prefixStyle: true,
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label',
},
{
id: 'toggle_services_menu',
defaultBinding: 'mod+k i',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label',
},
],
navigation: [
{ id: 'save_file', defaultBinding: 'mod+s', customizable: false },
{ id: 'find_in_file', defaultBinding: 'mod+f', customizable: false },
{
id: 'open_go_to_line',
defaultBinding: 'alt+g',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label',
},
],
application: [
{
id: 'open_command_palette',
defaultBinding: 'mod+p',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label',
},
{
id: 'open_settings',
defaultBinding: 'mod+comma',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_settings.label',
},
{
id: 'open_help',
defaultBinding: 'mod+k h',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_help.label',
},
{
id: 'cycle_theme',
defaultBinding: 'mod+k c',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label',
},
],
} as const satisfies Record<ShortcutCategory, readonly ShortcutConfig[]>;
/** All application shortcuts, flattened in the same order used by Settings. */
export const SHORTCUT_SCHEMA = [
...SHORTCUT_GROUPS.session.map((shortcut) => ({
...shortcut,
category: 'session' as const,
})),
...SHORTCUT_GROUPS.models.map((shortcut) => ({
...shortcut,
category: 'models' as const,
})),
...SHORTCUT_GROUPS.panels.map((shortcut) => ({
...shortcut,
category: 'panels' as const,
})),
...SHORTCUT_GROUPS.navigation.map((shortcut) => ({
...shortcut,
category: 'navigation' as const,
})),
...SHORTCUT_GROUPS.application.map((shortcut) => ({
...shortcut,
category: 'application' as const,
})),
] as const;
@@ -0,0 +1,207 @@
import { describe, expect, test } from 'bun:test';
import { ShortcutDispatcher } from './dispatcher';
import { ShortcutRegistry } from './registry';
function key(key: string, options: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
key,
code: `Key${key.toUpperCase()}`,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
repeat: false,
isComposing: false,
...options,
} as KeyboardEvent;
}
describe('ShortcutDispatcher', () => {
test('dispatches a sequence and consumes only leaders with active handlers', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
const unregister = registry.register('open_command_palette', (event) => {
calls.push(event.key);
});
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'open_command_palette' ? 'g h' : '',
});
expect(dispatcher.dispatch(key('g'))).toBe(true);
expect(dispatcher.dispatch(key('h'))).toBe(true);
expect(calls).toEqual(['h']);
unregister();
expect(dispatcher.dispatch(key('g'))).toBe(false);
});
test('re-matches a prefix mismatch and clears on escape or blur', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
registry.register('open_help', () => { calls.push('single'); });
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'open_command_palette' ? 'g h' : 'x',
});
dispatcher.dispatch(key('g'));
expect(dispatcher.dispatch(key('x'))).toBe(true);
expect(calls).toEqual(['single']);
dispatcher.dispatch(key('g'));
expect(dispatcher.dispatch(key('Escape'))).toBe(true);
expect(dispatcher.handleEscape()).toBe(false);
dispatcher.dispatch(key('g'));
dispatcher.handleBlur();
expect(dispatcher.dispatch(key('h'))).toBe(false);
});
test('expires prefixes and ignores repeats, composition, and modifier keys', () => {
let now = 0;
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now });
expect(dispatcher.dispatch(key('g'))).toBe(true);
now = 2999;
expect(dispatcher.hasActivePrefix()).toBe(true);
now = 3000;
expect(dispatcher.dispatch(key('h'))).toBe(false);
expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false);
expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false);
expect(dispatcher.dispatch(key('Shift'))).toBe(false);
expect(calls).toEqual([]);
});
test('does not consume a completed binding when every handler declines it', () => {
const registry = new ShortcutRegistry();
registry.register('open_command_palette', () => false);
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
expect(dispatcher.dispatch(key('g'))).toBe(true);
expect(dispatcher.dispatch(key('h'))).toBe(false);
});
test('does not consume a single chord when its handler declines it', () => {
const registry = new ShortcutRegistry();
registry.register('open_command_palette', () => false);
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' });
expect(dispatcher.dispatch(key('x'))).toBe(false);
});
test('starts a sequence when a single-chord handler with the same leader declines', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('save_file', () => false);
registry.register('open_draft_project_picker', () => { calls.push('project'); });
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p',
});
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
expect(dispatcher.dispatch(key('p'))).toBe(true);
expect(calls).toEqual(['project']);
});
test('does not start a sequence when a single-chord handler accepts the leader', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('save_file', () => { calls.push('save'); });
registry.register('open_draft_project_picker', () => { calls.push('project'); });
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p',
});
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
expect(dispatcher.dispatch(key('p'))).toBe(false);
expect(calls).toEqual(['save']);
});
test('resolves bindings at dispatch time', () => {
const registry = new ShortcutRegistry();
let binding = 'x';
const calls: string[] = [];
registry.register('open_command_palette', (event) => { calls.push(event.key); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => binding });
expect(dispatcher.dispatch(key('x'))).toBe(true);
binding = 'y';
expect(dispatcher.dispatch(key('x'))).toBe(false);
expect(dispatcher.dispatch(key('y'))).toBe(true);
expect(calls).toEqual(['x', 'y']);
});
test('invalidates a prefix when shortcut suspension changes', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
expect(dispatcher.dispatch(key('g'))).toBe(true);
const resume = registry.suspend();
expect(dispatcher.hasActivePrefix()).toBe(false);
expect(dispatcher.handleEscape()).toBe(false);
resume();
expect(dispatcher.dispatch(key('h'))).toBe(false);
expect(calls).toEqual([]);
});
test('marks a second key dispatched from capture so bubble does not dispatch it again', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
const secondKey = key('h');
dispatcher.dispatch(key('g'));
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(false);
expect(calls).toEqual(['sequence']);
});
test('consumes a matching captured prefix key during IME composition', () => {
for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_session_list', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' });
const secondKey = key('l', compositionState);
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
expect(calls).toEqual(['sequence']);
}
});
test('clears an active prefix but preserves an unmatched IME key', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_session_list', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' });
const secondKey = key('x', { isComposing: true });
dispatcher.dispatch(key('s', { ctrlKey: true }));
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(false);
expect(dispatcher.hasActivePrefix()).toBe(false);
expect(calls).toEqual([]);
});
test('stops after the first handler that accepts a conflicting binding', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('declined'); return false; });
registry.register('open_help', () => { calls.push('first'); });
registry.register('open_settings', () => { calls.push('second'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' });
expect(dispatcher.dispatch(key('x'))).toBe(true);
expect(calls).toEqual(['declined', 'first']);
});
});
+170
View File
@@ -0,0 +1,170 @@
import {
eventMatchesShortcut,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
} from './bindings';
import { type ShortcutHandler, ShortcutRegistry } from './registry';
import type { ShortcutActionId } from './schema';
import { isIMECompositionEvent } from '../ime';
const SEQUENCE_TIMEOUT_MS = 3000;
const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']);
export interface ShortcutDispatcherOptions {
registry: ShortcutRegistry;
getBinding: (actionId: ShortcutActionId) => ShortcutCombo;
now?: () => number;
timeoutMs?: number;
}
interface BindingMatch {
chords: string[];
handler: ShortcutHandler;
}
/** Stateless with respect to the DOM; callers decide whether a consumed event is prevented. */
export class ShortcutDispatcher {
private readonly now: () => number;
private readonly timeoutMs: number;
private prefix: string | undefined;
// The target the leader chord was pressed on. DOM-agnostic (opaque
// EventTarget): callers use it to decide whether an unmodified completion
// key arriving from an EDITABLE target is a deliberate sequence (same
// target as the arming press) or typing that must not be swallowed.
private prefixTarget: EventTarget | null = null;
private expiresAt = 0;
private prefixSuspensionVersion = 0;
private readonly capturedPrefixEvents = new WeakSet<KeyboardEvent>();
constructor(private readonly options: ShortcutDispatcherOptions) {
this.now = options.now ?? Date.now;
this.timeoutMs = options.timeoutMs ?? SEQUENCE_TIMEOUT_MS;
}
dispatch(event: KeyboardEvent): boolean {
if (event.repeat || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) {
return false;
}
if (event.key === 'Escape' && this.hasActivePrefix()) {
return this.handleEscape();
}
this.hasActivePrefix();
const matches = this.getMatches();
if (this.prefix) {
const pending = this.getPrefixMatches(matches, event);
if (pending.length > 0) {
this.clear();
return this.invoke(pending, event);
}
this.clear();
}
const singles = matches.filter((match) => (
match.chords.length === 1 && eventMatchesShortcut(event, match.chords[0])
));
if (singles.length > 0 && this.invoke(singles, event)) {
return true;
}
const leader = matches.find((match) => (
match.chords.length === 2 && eventMatchesShortcut(event, match.chords[0])
));
if (leader) {
this.prefix = leader.chords[0];
this.prefixTarget = event.target;
this.expiresAt = this.now() + this.timeoutMs;
this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion();
return true;
}
return false;
}
clear(): void {
this.prefix = undefined;
this.prefixTarget = null;
this.expiresAt = 0;
this.prefixSuspensionVersion = 0;
}
getActivePrefixTarget(): EventTarget | null {
return this.hasActivePrefix() ? this.prefixTarget : null;
}
handleBlur(): void {
this.clear();
}
handleEscape(): boolean {
const hadPrefix = this.hasActivePrefix();
this.clear();
return hadPrefix;
}
hasActivePrefix(): boolean {
if (!this.prefix) return false;
if (
this.now() >= this.expiresAt
|| this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion()
) {
this.clear();
return false;
}
return true;
}
dispatchActivePrefix(event: KeyboardEvent): boolean {
this.capturedPrefixEvents.add(event);
if (isIMECompositionEvent(event)) {
if (event.repeat || MODIFIER_KEYS.has(event.key.toLowerCase()) || !this.hasActivePrefix()) {
return false;
}
const pending = this.getPrefixMatches(this.getMatches(), event);
this.clear();
return pending.length > 0 ? this.invoke(pending, event) : false;
}
return this.dispatch(event);
}
consumeCapturedPrefixEvent(event: KeyboardEvent): boolean {
if (!this.capturedPrefixEvents.has(event)) return false;
this.capturedPrefixEvents.delete(event);
return true;
}
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
for (const match of matches) {
if (match.handler(event) !== false) {
return true;
}
}
return false;
}
private getPrefixMatches(matches: BindingMatch[], event: KeyboardEvent): BindingMatch[] {
return matches.filter((match) => (
match.chords.length === 2
&& match.chords[0] === this.prefix
&& eventMatchesShortcut(event, match.chords[1])
));
}
private getMatches(): BindingMatch[] {
const matches: BindingMatch[] = [];
for (const actionId of this.options.registry.actionIds()) {
const handler = this.options.registry.get(actionId);
if (!handler) continue;
const binding = normalizeCombo(this.options.getBinding(actionId));
const parsed = parseShortcut(binding);
if (!parsed || parsed.chords.some((chord) => !chord.key || chord.key === UNASSIGNED_SHORTCUT)) {
continue;
}
matches.push({ chords: binding.split(' '), handler });
}
return matches;
}
}
+32
View File
@@ -0,0 +1,32 @@
export {
eventMatchesShortcut,
eventMatchesShortcutPrefix,
formatShortcutForDisplay,
getShortcutConflict,
isRiskyBrowserShortcut,
isShortcutPrefixHeld,
keyToShortcutToken,
normalizeCombo,
parseShortcut,
resolveShortcutEventDigit,
resolveShortcutEventKey,
UNASSIGNED_SHORTCUT,
} from './bindings';
export type { ShortcutCombo } from './bindings';
export { ShortcutDispatcher } from './dispatcher';
export { shortcutRegistry } from './registry';
export type { ShortcutHandler } from './registry';
export {
getCustomizableShortcutActions,
getShortcutBindingConflicts,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
getShortcutAction,
SHORTCUT_SCHEMA,
} from './schema';
export type {
CustomizableShortcutAction,
ShortcutBindingConflict,
ShortcutActionId,
ShortcutCategory,
} from './schema';
@@ -0,0 +1,46 @@
import { expect, test } from 'bun:test';
import { ShortcutRegistry } from './registry';
test('the first registration wins and a later unregister cannot remove it', () => {
const registry = new ShortcutRegistry();
const firstHandler = () => undefined;
const first = registry.register('open_settings', firstHandler);
const replacement = registry.register('open_settings', () => false);
replacement();
expect(registry.get('open_settings')).toBe(firstHandler);
first();
expect(registry.get('open_settings')).toBe(undefined);
});
test('a later registration takes over after the first unregisters', () => {
const registry = new ShortcutRegistry();
const firstHandler = () => undefined;
const secondHandler = () => false;
const first = registry.register('open_settings', firstHandler);
registry.register('open_settings', secondHandler);
expect(registry.get('open_settings')).toBe(firstHandler);
first();
expect(registry.get('open_settings')).toBe(secondHandler);
});
test('suspends all handlers until every idempotent cleanup completes', () => {
const registry = new ShortcutRegistry();
const handler = () => undefined;
registry.register('open_settings', handler);
const resumeFirst = registry.suspend();
const resumeSecond = registry.suspend();
expect(registry.get('open_settings')).toBe(undefined);
expect(registry.isSuspended()).toBe(true);
resumeFirst();
resumeFirst();
expect(registry.get('open_settings')).toBe(undefined);
resumeSecond();
resumeSecond();
expect(registry.get('open_settings')).toBe(handler);
expect(registry.isSuspended()).toBe(false);
});
+79
View File
@@ -0,0 +1,79 @@
import type { ShortcutActionId } from './schema';
export type ShortcutHandler = (event: KeyboardEvent) => boolean | void;
interface RegisteredHandler {
handler: ShortcutHandler;
}
/** Active application command handlers, keyed by shortcut action ID. */
export class ShortcutRegistry {
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
private suspensionCount = 0;
private suspensionVersion = 0;
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
const registration = { handler };
const registered = this.handlers.get(actionId) ?? [];
if (registered.length > 0 && typeof console !== 'undefined' && import.meta.env?.DEV) {
// First registration wins at dispatch; a silent second registration is
// almost always two components fighting over one action.
console.warn(`[shortcuts] duplicate handler registration for "${actionId}" — only the first will dispatch`);
}
registered.push(registration);
this.handlers.set(actionId, registered);
return () => {
const current = this.handlers.get(actionId);
if (!current) return;
const index = current.indexOf(registration);
if (index === -1) return;
current.splice(index, 1);
if (current.length === 0) {
this.handlers.delete(actionId);
}
};
}
get(actionId: ShortcutActionId): ShortcutHandler | undefined {
if (this.suspensionCount > 0) return undefined;
return this.handlers.get(actionId)?.[0]?.handler;
}
/** Runs an action outside keyboard dispatch (command palette). Bypasses
suspension: the invoking surface, not the keyboard, owns the gesture. */
invoke(actionId: ShortcutActionId): boolean {
const handler = this.handlers.get(actionId)?.[0]?.handler;
if (!handler) return false;
return handler(new KeyboardEvent('keydown')) !== false;
}
/** Temporarily disables every registered application shortcut. */
suspend(): () => void {
this.suspensionCount += 1;
this.suspensionVersion += 1;
let active = true;
return () => {
if (!active) return;
active = false;
this.suspensionCount -= 1;
if (this.suspensionCount === 0) {
this.suspensionVersion += 1;
}
};
}
getSuspensionVersion(): number {
return this.suspensionVersion;
}
isSuspended(): boolean {
return this.suspensionCount > 0;
}
actionIds(): IterableIterator<ShortcutActionId> {
return this.handlers.keys();
}
}
/** Shared registry for application commands registered by React surfaces. */
export const shortcutRegistry = new ShortcutRegistry();
@@ -0,0 +1,144 @@
import { describe, expect, test } from 'bun:test';
import {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getShortcutBindingConflicts,
getShortcutAction,
parseShortcut,
SHORTCUT_SCHEMA,
type ShortcutCategory,
} from './index';
describe('shortcut schema', () => {
test('declares unique IDs and valid bindings for every application shortcut', () => {
const ids = SHORTCUT_SCHEMA.map((action) => action.id);
const hasValidMetadata = SHORTCUT_SCHEMA.every((action) => {
const chordCount = parseShortcut(action.defaultBinding)?.chords.length;
return Boolean(action.category)
&& chordCount !== undefined
&& chordCount >= 1
&& chordCount <= 2;
});
expect(new Set(ids).size).toBe(ids.length);
expect(hasValidMetadata).toBe(true);
});
test('keeps the flattened schema grouped in Settings order', () => {
const groupOrder: ShortcutCategory[] = [];
for (const action of SHORTCUT_SCHEMA) {
if (groupOrder.at(-1) !== action.category) {
groupOrder.push(action.category);
}
}
expect(groupOrder).toEqual([
'session',
'models',
'panels',
'navigation',
'application',
]);
});
test('derives settings labels for every customizable shortcut', () => {
const customizable = getCustomizableShortcutActions();
expect(customizable.length).toBeGreaterThan(0);
expect(customizable.every((action) => (
action.settingsLabelKey === `settings.openchamber.keyboardShortcuts.action.${action.id}.label`
))).toBe(true);
});
test('keeps the mod+k leader for open/go actions', () => {
expect(getShortcutAction('open_draft_project_picker')?.defaultBinding).toBe('mod+k p');
expect(getShortcutAction('open_draft_worktree_picker')?.defaultBinding).toBe('mod+k g');
expect(getShortcutAction('open_session_list')?.defaultBinding).toBe('mod+k l');
expect(getShortcutAction('open_timeline_dialog')?.defaultBinding).toBe('mod+k t');
expect(getShortcutAction('toggle_prompt_navigator')?.defaultBinding).toBe('mod+k n');
expect(getShortcutAction('toggle_services_menu')?.defaultBinding).toBe('mod+k i');
expect(getShortcutAction('open_help')?.defaultBinding).toBe('mod+k h');
expect(getShortcutAction('cycle_theme')?.defaultBinding).toBe('mod+k c');
expect(getShortcutAction('focus_input')?.category).toBe('session');
});
test('splits the held digit prefixes between session tabs and surfaces', () => {
expect(getShortcutAction('switch_session_tab')?.defaultBinding).toBe('mod');
expect(getShortcutAction('switch_context_surface')?.defaultBinding).toBe('mod+alt');
});
test('every action ships with a default binding', () => {
// Palette-only commands live outside this schema entirely; an action in
// the schema without a binding would be dead weight in Settings.
for (const action of SHORTCUT_SCHEMA) {
expect(getEffectiveShortcutCombo(action.id)).not.toBe('');
}
});
test('preserves valid overrides and falls back from malformed bindings', () => {
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k');
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n');
});
test('keeps internal bindings authoritative over persisted overrides', () => {
expect(getEffectiveShortcutCombo('save_file', { save_file: 'mod+k' })).toBe('mod+s');
expect(getEffectiveShortcutCombo('save_file', { save_file: '__unassigned__' })).toBe('mod+s');
});
test('detects conflicts against customizable and internal bindings', () => {
const customizableConflict = getShortcutBindingConflicts('new_chat', 'mod+p')
.find((conflict) => conflict.action.id === 'open_command_palette');
const internalConflict = getShortcutBindingConflicts('new_chat', 'mod+f')
.find((conflict) => conflict.action.id === 'find_in_file');
const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x')
.find((conflict) => conflict.action.id === 'save_file');
const leaderPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+k')
.find((conflict) => conflict.action.id === 'open_session_list');
const blockingPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+p x')
.find((conflict) => conflict.action.id === 'open_command_palette');
expect(customizableConflict?.kind).toBe('exact');
expect(customizableConflict?.action.customizable).toBe(true);
expect(internalConflict?.kind).toBe('exact');
expect(internalConflict?.action.customizable).toBe(false);
expect(internalPrefixConflict?.kind).toBe('prefix');
expect(internalPrefixConflict?.action.customizable).toBe(false);
expect(leaderPrefixConflict?.kind).toBe('prefix');
expect(blockingPrefixConflict?.kind).toBe('prefix');
});
});
describe('shortcut defaults', () => {
// Two actions silently sharing a default binding would race at dispatch
// (registry insertion order decides). Pairs that intentionally share a
// combo because they can never be active in the same runtime must be
// whitelisted here explicitly.
const RUNTIME_EXCLUSIVE_BINDING_PAIRS: ReadonlyArray<ReadonlySet<string>> = [];
test('no two actions share a normalized default binding', () => {
const byBinding = new Map<string, string[]>();
for (const action of SHORTCUT_SCHEMA) {
const combo = getEffectiveShortcutCombo(action.id);
if (!combo) continue;
const list = byBinding.get(combo) ?? [];
list.push(action.id);
byBinding.set(combo, list);
}
const conflicts = [...byBinding.entries()]
.filter(([, ids]) => ids.length > 1)
.filter(([, ids]) => !RUNTIME_EXCLUSIVE_BINDING_PAIRS.some(
(pair) => ids.every((id) => pair.has(id)),
))
.map(([combo, ids]) => `"${combo}" shared by ${ids.join(', ')}`);
expect(conflicts).toEqual([]);
});
test('overrides recorded under the flat-file era still resolve', () => {
// The persisted override format is a flat Record<string, string> and
// must keep resolving through the schema after the module split.
const overrides = { close_session_tab: 'alt+q', open_command_palette: 'mod+shift+k' };
expect(getEffectiveShortcutCombo('close_session_tab', overrides)).toBe('alt+q');
expect(getEffectiveShortcutCombo('open_command_palette', overrides)).toBe('mod+shift+k');
// Unknown ids stay inert rather than throwing.
expect(getEffectiveShortcutCombo('close_session_tab', { ghost_action: 'mod+z', close_session_tab: 'alt+q' } as Record<string, string>)).toBe('alt+q');
});
});
+92
View File
@@ -0,0 +1,92 @@
import {
getShortcutConflict,
isValidShortcutCombo,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
type ShortcutConflict,
} from './bindings';
import { SHORTCUT_SCHEMA } from './config';
export { SHORTCUT_SCHEMA } from './config';
export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
export type ShortcutActionId = ShortcutAction['id'];
export type ShortcutCategory = ShortcutAction['category'];
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
/** 'contextual-prefix' is kept in the union for the recording dialog's
messaging even though no default layout produces it any more. */
export type ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix';
export type ShortcutBindingConflict = {
action: ShortcutAction;
kind: ShortcutBindingConflictKind;
};
export function getShortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_SCHEMA.find((action) => action.id === id);
}
export function getCustomizableShortcutActions(): ReadonlyArray<CustomizableShortcutAction> {
return SHORTCUT_SCHEMA.filter(
(action): action is CustomizableShortcutAction => action.customizable,
);
}
export function getEffectiveShortcutCombo(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
const defaultBinding = action.defaultBinding === UNASSIGNED_SHORTCUT ? '' : action.defaultBinding;
if (!action.customizable) return defaultBinding;
const override = overrides?.[actionId];
if (typeof override === 'string') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) return '';
if (isValidShortcutCombo(normalized)) return normalized;
}
return defaultBinding;
}
export function getEffectiveShortcutPrefix(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
if (!action.customizable) return action.defaultBinding;
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) return UNASSIGNED_SHORTCUT;
const chord = parseShortcut(normalized)?.chords[0];
if (chord && (chord.modifiers.size > 0 || chord.key)) return normalized;
}
return action.defaultBinding;
}
export function getShortcutBindingConflicts(
actionId: ShortcutActionId,
combo: ShortcutCombo,
overrides?: Record<string, ShortcutCombo>,
): ShortcutBindingConflict[] {
const conflicts: ShortcutBindingConflict[] = [];
const action = getShortcutAction(actionId);
if (!action) return conflicts;
for (const candidate of SHORTCUT_SCHEMA) {
if (candidate.id === actionId) continue;
const candidateCombo = ('prefixStyle' in candidate && candidate.prefixStyle)
? getEffectiveShortcutPrefix(candidate.id, overrides)
: getEffectiveShortcutCombo(candidate.id, overrides);
const kind = getShortcutConflict(combo, candidateCombo);
if (!kind) continue;
conflicts.push({ action: candidate, kind });
}
return conflicts;
}

Some files were not shown because too many files have changed in this diff Show More