mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 09:49:41 +02:00
Merge the final checkup's fixes into the 1.40.0 release
From the read-only final review of the release, adversarially verified, then reviewed again: - tiles: a file dropped on the grid uploads to that tile's session instead of navigating away; app-driven tile changes no longer move the keyboard into another session; popping out the last tile no longer leaves a frozen view; "Open group as tiles" no longer merges an open split; the Tiles button defaults off on touch tablets (opt-in) - voice: dictation with the grid open reaches the focused tile - css: By case stays one scrolling strip on 600-767px tablets, the needs-you pulse animates opacity only, phone welcome chips are 40px - i18n: zh-CN for the case picker rows, the git status settings, new toasts, tile and spreadsheet texts - cli registry: codex launch defaults are registry data, not an id branch; the codex footer reads an ultra effort - build and docs: a dependency preflight runs before the build deletes dist; docs no longer name 1.36.0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -409,6 +409,17 @@ const capabilitiesSchema = z
|
||||
'rejectWords has nothing to filter without a screenLine'
|
||||
)
|
||||
.optional(),
|
||||
// Launch param -> synced App Settings key. The values are a closed enum, like
|
||||
// configResolver: a clis.json override names one of the settings this build knows
|
||||
// how to validate, never an arbitrary key. Params are checked against the declared
|
||||
// ones in the superRefine below.
|
||||
launchDefaults: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
|
||||
)
|
||||
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
|
||||
.optional(),
|
||||
privilegedParams: z
|
||||
.array(
|
||||
z
|
||||
@@ -627,6 +638,30 @@ export const CliEntrySchema = z
|
||||
}
|
||||
});
|
||||
|
||||
// Same silent-no-op class again: a launch default for a param the entry never declared
|
||||
// would be filled into the config object and then read by nothing. And without a
|
||||
// `legacyConfigField` the entry's params are read off the request body itself, where a
|
||||
// filled `model` would be a different field (claude's per-session one), so refuse it.
|
||||
const { launchDefaults } = entry.capabilities;
|
||||
if (launchDefaults !== undefined) {
|
||||
if (entry.launch.legacyConfigField === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'launchDefaults needs launch.legacyConfigField to fill',
|
||||
path: ['capabilities', 'launchDefaults'],
|
||||
});
|
||||
}
|
||||
for (const param of Object.keys(launchDefaults)) {
|
||||
if (!declaredParams.has(param)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `launchDefaults param "${param}" is not a declared launch param`,
|
||||
path: ['capabilities', 'launchDefaults', param],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { setenvProfile } = entry.env;
|
||||
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -671,6 +671,11 @@ const CODEX: CliEntry = {
|
||||
// popup or a bare line of prose does not have that shape. A footer without an effort
|
||||
// word (a model with no reasoning setting) is not read, and the session keeps its
|
||||
// last known or launch model.
|
||||
// The effort words are built from CODEX_REASONING_EFFORTS, the same list the
|
||||
// `reasoningEffort` launch param above admits, plus `default` (what codex prints when
|
||||
// no effort is configured). A hand-kept copy once left out `ultra`, so a session at
|
||||
// that level never named its model. Every word is plain letters, so the join adds no
|
||||
// quantifier and only a few characters to the 200-character compileVersionRegex cap.
|
||||
// ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row
|
||||
// under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and
|
||||
// drops it again while a prompt is being typed. With a one-row window the footer was
|
||||
@@ -681,9 +686,13 @@ const CODEX: CliEntry = {
|
||||
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
|
||||
// an indented row, so it is not read.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · [^\n]*(?:\n {2}[^\n]*)?$`,
|
||||
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
|
||||
screenLines: 2,
|
||||
},
|
||||
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
|
||||
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
|
||||
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
|
||||
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
|
||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||
|
||||
@@ -96,6 +96,14 @@ export type NewlineSequence = 'line-feed' | 'esc-enter';
|
||||
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
|
||||
export type ModelConfigResolverName = 'deepseek-route';
|
||||
|
||||
/**
|
||||
* The synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts).
|
||||
* A closed list rather than any settings key, so a clis.json override cannot feed an
|
||||
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
|
||||
* key, which the resolver's typing enforces.
|
||||
*/
|
||||
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
|
||||
|
||||
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
||||
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
|
||||
@@ -529,6 +537,20 @@ export interface CliCapabilities {
|
||||
rejectWords?: string[];
|
||||
configResolver?: ModelConfigResolverName;
|
||||
};
|
||||
/**
|
||||
* Synced App Settings that seed this CLI's launch params when the caller left them unset,
|
||||
* keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the
|
||||
* resolver translates through `launch.legacyConfigAliases` like every other `param`.
|
||||
*
|
||||
* Filled into the entry's `launch.legacyConfigField` object at create time by
|
||||
* `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value
|
||||
* with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which
|
||||
* launches get it is the CALLER's decision (local ones only: never remote, Docker or a
|
||||
* custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without
|
||||
* a `legacyConfigField`, whose params would otherwise be read off the request body itself.
|
||||
* Absent = no launch defaults.
|
||||
*/
|
||||
launchDefaults?: Record<string, LaunchDefaultSettingKey>;
|
||||
/**
|
||||
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
|
||||
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Launch-time defaults for Codex sessions.
|
||||
*
|
||||
* Resolves the synced App Settings `codexModel` / `codexReasoningEffort` into the
|
||||
* `codexConfig` a launch uses, filling ONLY the fields the caller left unset.
|
||||
* Persisted values are re-validated with `SettingsUpdateSchema`, so a hand-edited
|
||||
* settings.json can never smuggle an unchecked value onto the codex command line.
|
||||
*
|
||||
* Scope is the caller's decision: the create and quick-start routes apply it to
|
||||
* local launches only, never to remote, Docker or custom-endpoint launches.
|
||||
* Nothing here writes Codex's own config files.
|
||||
*/
|
||||
|
||||
import type { CodexConfig } from '../types.js';
|
||||
import { SettingsUpdateSchema } from './schemas.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
|
||||
|
||||
/** Resolve launch-only defaults without changing Codex's own configuration files. */
|
||||
export async function resolveCodexLaunchDefaults(
|
||||
config: CodexConfig | undefined,
|
||||
customEndpoint = false
|
||||
): Promise<CodexConfig | undefined> {
|
||||
if (customEndpoint) return config;
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'Codex launch defaults', {});
|
||||
const model = SettingsUpdateSchema.shape.codexModel.safeParse(settings.codexModel);
|
||||
const effort = SettingsUpdateSchema.shape.codexReasoningEffort.safeParse(settings.codexReasoningEffort);
|
||||
const defaultModel = model.success ? model.data || undefined : undefined;
|
||||
const defaultEffort = effort.success ? effort.data || undefined : undefined;
|
||||
if (!defaultModel && !defaultEffort) return config;
|
||||
return {
|
||||
...config,
|
||||
model: config?.model ?? defaultModel,
|
||||
reasoningEffort: config?.reasoningEffort ?? defaultEffort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
|
||||
*
|
||||
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
|
||||
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
|
||||
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
|
||||
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
|
||||
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
|
||||
* unchecked value onto the command line.
|
||||
*
|
||||
* Scope is the caller's decision: the create and quick-start routes apply it to local
|
||||
* launches only, never to remote, Docker or custom-endpoint launches. Nothing here writes
|
||||
* a CLI's own config files.
|
||||
*/
|
||||
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
import { SettingsUpdateSchema } from './schemas.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
|
||||
|
||||
/**
|
||||
* Return `configs` with the launch defaults of `mode`'s registry entry filled into its
|
||||
* legacy config object (e.g. `codexConfig`). Every other field of `configs` is passed
|
||||
* through untouched, and `configs` itself comes back unchanged (same object) when the entry
|
||||
* declares no defaults, `customEndpoint` is set, or no setting names a value.
|
||||
*/
|
||||
export async function applyLaunchDefaults<T extends object>(
|
||||
mode: string,
|
||||
configs: T,
|
||||
customEndpoint = false
|
||||
): Promise<T> {
|
||||
const entry = getCli(mode);
|
||||
const declared = entry?.capabilities.launchDefaults;
|
||||
const field = entry?.launch.legacyConfigField;
|
||||
if (customEndpoint || !declared || field === undefined) return configs;
|
||||
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'CLI launch defaults', {});
|
||||
const aliases = entry.launch.legacyConfigAliases ?? {};
|
||||
const current = (configs as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const [param, settingKey] of Object.entries(declared)) {
|
||||
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
|
||||
// '' is the settings' "leave it to the CLI" value, the same as unset.
|
||||
const value = parsed.success ? parsed.data || undefined : undefined;
|
||||
const wireKey = aliases[param] ?? param;
|
||||
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
|
||||
}
|
||||
if (Object.keys(defaults).length === 0) return configs;
|
||||
return { ...configs, [field]: { ...current, ...defaults } };
|
||||
}
|
||||
+22
-1
@@ -985,6 +985,7 @@ class CodemanApp {
|
||||
// Last rendered connection-indicator tuple; the hot input path skips DOM
|
||||
// writes when the freshly computed descriptor is identical (COD-136).
|
||||
this._lastIndicatorDescriptor = null;
|
||||
this._lastIndicatorLanguage = null; // the UI language it was rendered in
|
||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
|
||||
// session they are bound to: the split pane's second terminal registers its
|
||||
@@ -4354,6 +4355,21 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile's value word as shown: i18n.js's scoped 'Connection tile: <word>'
|
||||
* entry in Chinese, the English word otherwise. Never a bare-word key: those
|
||||
* would also translate other text ("retry" is the orchestrator's Retry button),
|
||||
* which is why the value span carries data-i18n-skip.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_connectionTileValueText(value) {
|
||||
if (!value) return '';
|
||||
const key = `Connection tile: ${value}`;
|
||||
const translated = typeof window.codemanT === 'function' ? window.codemanT(key) : key;
|
||||
return translated && translated !== key ? translated : value;
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
@@ -4365,8 +4381,12 @@ class CodemanApp {
|
||||
// writes when nothing changed (COD-136) — the compute above is DOM-free.
|
||||
const next = this._computeConnectionDescriptor();
|
||||
const prev = this._lastIndicatorDescriptor;
|
||||
// The tile's value word is written in the UI language, so a language
|
||||
// switch counts as a change too.
|
||||
const language = window.CodemanI18n?.language || 'en';
|
||||
if (
|
||||
prev &&
|
||||
language === this._lastIndicatorLanguage &&
|
||||
prev.display === next.display &&
|
||||
prev.dotClass === next.dotClass &&
|
||||
prev.text === next.text &&
|
||||
@@ -4375,6 +4395,7 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
this._lastIndicatorDescriptor = next;
|
||||
this._lastIndicatorLanguage = language;
|
||||
|
||||
indicator.style.display = next.display;
|
||||
if (next.display !== 'none') {
|
||||
@@ -4386,7 +4407,7 @@ class CodemanApp {
|
||||
if (tileLabel && tileValue) {
|
||||
const words = this._connectionTileWords(next);
|
||||
tileLabel.textContent = words.label;
|
||||
tileValue.textContent = words.value;
|
||||
tileValue.textContent = this._connectionTileValueText(words.value);
|
||||
tileValue.className = `connection-tile-value ${words.state}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,8 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
|
||||
'Only image files are supported': '仅支持图像文件',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
@@ -228,6 +230,11 @@
|
||||
'Create New Case': '新建案例',
|
||||
'Create new case': '新建案例',
|
||||
'Link Existing': '关联现有目录',
|
||||
// The toolbar case picker's action rows (session-ui.js CASE_PICKER_ACTIONS),
|
||||
// which replaced the "+" and gear buttons, and its empty state.
|
||||
'New or link a case…': '新建或关联案例…',
|
||||
'Case settings…': '案例设置…',
|
||||
'No cases match': '没有匹配的案例',
|
||||
'Add Case': '添加案例',
|
||||
'Open sessions': '打开会话',
|
||||
'Recent Sessions': '最近会话',
|
||||
@@ -377,6 +384,32 @@
|
||||
'As before (bars)': '与以前相同(进度条)',
|
||||
'Compact (default)': '紧凑(默认)',
|
||||
'Tiles (label over value)': '磁贴(标签在数值上方)',
|
||||
// The connection tile's value word in that style (app.js
|
||||
// _connectionTileValueText). Scoped keys on purpose: the bare words also
|
||||
// name other things ("retry" is the orchestrator's Retry button, "LIVE" a
|
||||
// badge in the resume list), and a bare key would translate those too.
|
||||
'Connection tile: live': '已连接',
|
||||
'Connection tile: fallback': '回退',
|
||||
'Connection tile: offline': '离线',
|
||||
'Connection tile: queued': '已排队',
|
||||
'Connection tile: retry': '重连中',
|
||||
// App Settings → Bottom bar, translated as one group (the Git status rows,
|
||||
// #543's two included). Keys are the trimmed label text, without the scope tag.
|
||||
'Bottom bar': '底部栏',
|
||||
'Git status': 'Git 状态',
|
||||
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.":
|
||||
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
|
||||
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
|
||||
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
|
||||
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
|
||||
'Git status: max repositories': 'Git 状态:最多仓库数',
|
||||
"When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.":
|
||||
'当会话的文件夹包含多个项目(而不是本身就是一个项目)时,Git 窗口最多列出这么多个(1 到 50,默认 12)。每个仓库每次刷新都要运行几条 git 命令。',
|
||||
'Git status: git timeout': 'Git 状态:git 超时',
|
||||
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
|
||||
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
|
||||
'Refresh git status': '刷新 Git 状态',
|
||||
'Close git status': '关闭 Git 状态',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
'Project Insights': '项目洞察',
|
||||
@@ -697,6 +730,13 @@
|
||||
'Nothing to copy': '没有可复制的内容',
|
||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||
'Session not found': '未找到会话',
|
||||
// A native host that would not open a window (app.js openInHostWindow); the
|
||||
// "dashboard" is a web tab.
|
||||
'Could not open a new window for this session': '无法在新窗口中打开此会话',
|
||||
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
|
||||
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
|
||||
// Dictation whose session closed before the text was sent (voice-input.js).
|
||||
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
@@ -870,6 +910,28 @@
|
||||
'Spreadsheet parser message failed.': '电子表格解析器消息出错。',
|
||||
'Spreadsheet parser message failed': '电子表格解析器消息出错',
|
||||
'Spreadsheet preview is unavailable.': '电子表格预览不可用。',
|
||||
'Spreadsheet preview must use a same-origin URL': '电子表格预览必须使用同源 URL',
|
||||
// Worker refusals, one sentence per error code (spreadsheet-preview.js
|
||||
// WORKER_ERROR_TEXT), and the notice bar's items (renderWarnings). The
|
||||
// counted ones are patterns in translateDynamic below.
|
||||
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.':
|
||||
'此工作簿受密码保护或为旧版 .xls 格式,无法预览。',
|
||||
'This workbook uses ZIP64, which the preview does not support.': '此工作簿使用 ZIP64 格式,预览不支持该格式。',
|
||||
'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。',
|
||||
'This workbook could not be read. The file may be damaged or not a valid .xlsx file.':
|
||||
'无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。',
|
||||
// The features the preview leaves out (spreadsheet-preview.js warningText).
|
||||
// Scoped keys on purpose: a bare 'charts' or 'macros' key would also
|
||||
// translate a folder or case of that name (a Helm chart's charts/, a dbt
|
||||
// project's macros/) in the Files panel and the case picker.
|
||||
'Spreadsheet feature: charts': '图表',
|
||||
'Spreadsheet feature: drawings': '绘图',
|
||||
'Spreadsheet feature: pivot tables': '数据透视表',
|
||||
'Spreadsheet feature: external links': '外部链接',
|
||||
'Spreadsheet feature: macros': '宏',
|
||||
'Formula has no cached result': '公式没有缓存的计算结果',
|
||||
'Unsupported cell value': '不支持的单元格值',
|
||||
'Unsupported number format': '不支持的数字格式',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
'Export as Markdown': '导出为 Markdown',
|
||||
'Mark all read': '全部标为已读',
|
||||
@@ -1097,6 +1159,10 @@
|
||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
||||
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
|
||||
[/^Spreadsheet preview failed \((\d+)\)$/, (_m, status) => `电子表格预览失败(${status})`],
|
||||
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
|
||||
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
[
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue"></span></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue" data-i18n-skip></span></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
|
||||
@@ -334,6 +334,34 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab Layout "By case" at tablet widths, getDeviceType()'s 'tablet' (600 to
|
||||
767px). The strip is the tablet block's one scrolling row and
|
||||
updateTabOverflowMode() never wraps it here, so the boxes, which may shrink
|
||||
below 768px, squeezed and wrapped their tabs inside themselves instead of
|
||||
overflowing: the strip never scrolled, and every tab past a box's first
|
||||
line was clipped under the fixed header. The boxes dissolve into the chip
|
||||
row as on phones (the 599px block), which keeps the tablet's own 40px chip
|
||||
geometry; a 44px box would hang below the 48px header. Ends at 767px, not
|
||||
at the tablet block's 768: from 768 getDeviceType() says 'desktop', and the
|
||||
desktop rule in styles.css (flex-shrink: 0, wrapping box by box) owns the
|
||||
strip. With the boxes, their labels and their case-colour borders gone, the
|
||||
`-<case>` part of a generated name (.tab-name-case, which styles.css hides
|
||||
in the clustered strip) is the only cue left to which case a chip is in: the
|
||||
w<n> counter is per case, so w1-alpha and w1-beta would both read "w1". */
|
||||
@media (min-width: 600px) and (max-width: 767px) {
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:where(.header) .tab-cluster-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge fade for the phone's header tab strip (used in the block below).
|
||||
Registered so the keyframes can interpolate them as lengths; @property is
|
||||
only valid at the top level, hence out here. */
|
||||
@@ -767,7 +795,9 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Clustered by case, the same holds: the boxes dissolve into the one chip
|
||||
row (in cluster order) and the labels go, since every chip still names
|
||||
its session. */
|
||||
its session. It does so in full: the `-<case>` part of a generated name,
|
||||
which styles.css hides in the clustered strip, shows again, or w1-alpha
|
||||
and w1-beta (the w<n> counter is per case) would both read "w1". */
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: contents;
|
||||
}
|
||||
@@ -776,6 +806,10 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
@@ -1718,7 +1752,10 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
/* Only reached with the phone overview switched off. The primary spans the
|
||||
column; the chips keep wrapping, a little taller for a thumb. */
|
||||
column; the chips keep wrapping at the finger size styles.css gives touch
|
||||
screens (40px, its pointer: coarse rule). This rule loads later at equal
|
||||
specificity, so a lower value here made a phone's chips shorter than a
|
||||
tablet's; restating 40px also covers a narrow window with a mouse. */
|
||||
.welcome-primary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
@@ -1727,8 +1764,8 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.welcome-chip {
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.history-show-more {
|
||||
|
||||
@@ -3549,7 +3549,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
// Desktop defaults - rely on ?? operators in apply functions
|
||||
// This allows desktop to have different defaults without duplication
|
||||
return {};
|
||||
// A touch-primary tablet (iPad, an Android tablet: not a handheld, so it
|
||||
// lands here) keeps the Tiles button opt-in, as Split is: a tile has none of
|
||||
// the main terminal's touch, IME and soft-keyboard handling. The PRIMARY
|
||||
// pointer decides, never MobileDetection.isTouchDevice(), which is true on a
|
||||
// touchscreen laptop too (fine primary pointer: the desktop default stays).
|
||||
const coarsePrimaryPointer =
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)')?.matches === true;
|
||||
return coarsePrimaryPointer ? { showTileGridButton: false } : {};
|
||||
},
|
||||
|
||||
loadAppSettingsFromStorage() {
|
||||
@@ -3566,8 +3573,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch (err) {
|
||||
console.error('Failed to load app settings:', err);
|
||||
}
|
||||
// Return device-specific defaults
|
||||
this._cachedAppSettings = this.getDefaultSettings();
|
||||
// Return device-specific defaults, without showTileGridButton: its default
|
||||
// on a non-handheld follows the LIVE primary pointer (getDefaultSettings),
|
||||
// and this object is what a fresh device caches and the server-settings
|
||||
// merge then persists, which would freeze a 2-in-1's first-load posture
|
||||
// into a stored value. Every reader resolves the absent key through a
|
||||
// fresh getDefaultSettings() (?? defaults.showTileGridButton ?? true).
|
||||
const defaults = { ...this.getDefaultSettings() };
|
||||
delete defaults.showTileGridButton;
|
||||
this._cachedAppSettings = defaults;
|
||||
return this._cachedAppSettings;
|
||||
},
|
||||
|
||||
@@ -3613,6 +3627,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (result && this.notificationManager) {
|
||||
this.notificationManager.originalTitle = document.title;
|
||||
}
|
||||
// The connection tile's value word (Header Stats Style Tiles) is written
|
||||
// already translated into a data-i18n-skip span, so the translator above
|
||||
// cannot revert it; its own language compare makes this one call repaint it.
|
||||
this._updateConnectionIndicator?.();
|
||||
},
|
||||
|
||||
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
|
||||
|
||||
@@ -31,6 +31,76 @@
|
||||
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
|
||||
});
|
||||
|
||||
// What a refusal from the worker says on screen. The core's own messages
|
||||
// (spreadsheet-xlsx-core.js) are developer detail, so each error code maps to
|
||||
// one sentence with a zh-CN entry in i18n.js, and the raw message goes to the
|
||||
// console. Any other code (parse-failed carries ExcelJS's own exception text)
|
||||
// shows the generic failure.
|
||||
const TOO_LARGE_OR_COMPLEX = 'This workbook is too large or complex to preview.';
|
||||
const UNREADABLE = 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.';
|
||||
const WORKER_ERROR_TEXT = Object.freeze({
|
||||
encrypted: 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.',
|
||||
zip64: 'This workbook uses ZIP64, which the preview does not support.',
|
||||
malformed: UNREADABLE,
|
||||
'number-format': UNREADABLE,
|
||||
'entry-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'entry-size': TOO_LARGE_OR_COMPLEX,
|
||||
'inflated-size': TOO_LARGE_OR_COMPLEX,
|
||||
'compression-ratio': TOO_LARGE_OR_COMPLEX,
|
||||
'worksheet-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'element-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'row-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'cell-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'merge-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'style-limit': TOO_LARGE_OR_COMPLEX,
|
||||
});
|
||||
|
||||
// The workbook features the preview leaves out, as the core names them
|
||||
// (featureForName), in words for the notice bar.
|
||||
const FEATURE_LABELS = Object.freeze({
|
||||
charts: 'charts',
|
||||
drawings: 'drawings',
|
||||
pivotTables: 'pivot tables',
|
||||
externalLinks: 'external links',
|
||||
macros: 'macros',
|
||||
});
|
||||
const UNSUPPORTED_FORMAT_PREFIX = 'Unsupported number format: ';
|
||||
|
||||
function translate(text) {
|
||||
return global.codemanT?.(text) || text;
|
||||
}
|
||||
|
||||
function own(map, key) {
|
||||
return Object.prototype.hasOwnProperty.call(map, key);
|
||||
}
|
||||
|
||||
function workerErrorText(payload) {
|
||||
const code = String(payload?.code || '');
|
||||
if (payload?.message) console.warn(`Spreadsheet preview: ${code || 'error'}: ${payload.message}`);
|
||||
return own(WORKER_ERROR_TEXT, code) ? WORKER_ERROR_TEXT[code] : 'Spreadsheet preview failed';
|
||||
}
|
||||
|
||||
// One notice bar item, translated on its own (the bar is one text node, which
|
||||
// the i18n layer could only match whole; the bar itself carries
|
||||
// data-i18n-skip). A number format's code is workbook text: it is appended
|
||||
// as is, never passed through the translator, which would read a `{…}` in it
|
||||
// as a placeholder. A feature word goes through its scoped
|
||||
// 'Spreadsheet feature: <word>' key and reads as the plain word when that key
|
||||
// has no translation: a bare 'charts' key would also rename a charts/ folder.
|
||||
function warningText(warning) {
|
||||
const text = String(warning);
|
||||
if (text.startsWith(UNSUPPORTED_FORMAT_PREFIX)) {
|
||||
return `${translate('Unsupported number format')}: ${text.slice(UNSUPPORTED_FORMAT_PREFIX.length)}`;
|
||||
}
|
||||
if (own(FEATURE_LABELS, text)) {
|
||||
const label = FEATURE_LABELS[text];
|
||||
const key = `Spreadsheet feature: ${label}`;
|
||||
const translated = translate(key);
|
||||
return translated !== key ? translated : label;
|
||||
}
|
||||
return translate(text);
|
||||
}
|
||||
|
||||
function message(container, text, kind) {
|
||||
container.textContent = '';
|
||||
const state = document.createElement('div');
|
||||
@@ -209,9 +279,8 @@
|
||||
if (!notice) return;
|
||||
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
|
||||
notice.hidden = warnings.length === 0;
|
||||
const warningLabel =
|
||||
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
|
||||
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
|
||||
const warningLabel = translate('Some workbook features are not shown');
|
||||
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.map(warningText).join(', ')}` : '';
|
||||
}
|
||||
|
||||
function pinHeadings() {
|
||||
@@ -376,6 +445,10 @@
|
||||
}
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'spreadsheet-preview-notice';
|
||||
// Written already translated, item by item (renderWarnings), and it ends
|
||||
// with workbook text (a number format's code): the observer's t() over
|
||||
// the whole line would rewrite a `{name}` or a "Codeman" in that code.
|
||||
notice.setAttribute('data-i18n-skip', '');
|
||||
notice.hidden = true;
|
||||
emptySheetState = document.createElement('div');
|
||||
emptySheetState.className = 'spreadsheet-empty-sheet';
|
||||
@@ -459,7 +532,7 @@
|
||||
clearTimer();
|
||||
renderMetadata(payload);
|
||||
} else if (payload.type === 'tile') renderTile(payload);
|
||||
else if (payload.type === 'error') fail(payload.message);
|
||||
else if (payload.type === 'error') fail(workerErrorText(payload));
|
||||
};
|
||||
worker.onerror = () => fail('Spreadsheet parser failed.');
|
||||
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
|
||||
|
||||
+37
-18
@@ -4519,7 +4519,8 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Touch screens wide enough for this view (tablets) get finger-sized chips. */
|
||||
/* Touch screens get finger-sized chips. Phones too: mobile.css's 599px block
|
||||
loads later and wins at equal specificity, so it restates the 40px. */
|
||||
@media (pointer: coarse) {
|
||||
.welcome-chip {
|
||||
height: 40px;
|
||||
@@ -19741,29 +19742,50 @@ body.tile-grid-resizing--row * {
|
||||
color: var(--accent, #4a9eff);
|
||||
}
|
||||
|
||||
/* A permission prompt or question, visible across the room. Only the border
|
||||
colour and a box-shadow animate: anything that moves layout would resize
|
||||
the tile's PTY every frame. */
|
||||
/* A permission prompt or question, visible across the room: a red border and
|
||||
a glow that pulses for as long as the prompt waits, which can be hours.
|
||||
Anything that moves layout would resize the tile's PTY every frame, and a
|
||||
box-shadow animated on the tile itself repainted the whole tile (its DOM
|
||||
rendered terminal rows included, the whole stage when zoomed) every frame.
|
||||
So the glow is a STATIC shadow on an overlay and only its opacity
|
||||
animates, which the compositor runs without repainting the tile. The
|
||||
shadow is inset because .tile is overflow: hidden and would clip an outer
|
||||
one on its own pseudo-element. Above .tile-attach (z-index 2), and never
|
||||
in the way of a click. */
|
||||
.tile.tile--needs {
|
||||
border-color: var(--red, #ef4444);
|
||||
}
|
||||
|
||||
.tile.tile--needs::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
opacity: 0;
|
||||
animation: tile-needs-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tile-needs-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--red, #ef4444) 0%, transparent);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile.tile--needs {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
}
|
||||
|
||||
.tile.tile--needs::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The tab of a tiled session (the focused tile's tab is .active as usual). */
|
||||
@@ -19821,9 +19843,11 @@ body.tile-grid-resizing--row * {
|
||||
}
|
||||
|
||||
/* Quiet "loading" state while the tile's capture waits in the grid's one load
|
||||
queue (or runs). */
|
||||
queue (or runs). The text is the body's data-loading-label, which tile-grid.js
|
||||
writes in the UI language (_setTileLoadingLabel): generated content is out of
|
||||
the i18n layer's reach, so no literal words here. */
|
||||
.tile.tile--loading .tile-body::after {
|
||||
content: 'Loading\2026';
|
||||
content: attr(data-loading-label);
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
@@ -20030,13 +20054,6 @@ body.tile-grid-resizing--row * {
|
||||
animation-delay: calc(var(--tile-enter-index, 0) * 24ms);
|
||||
}
|
||||
|
||||
/* A tile that needs you enters AND pulses (one animation property). */
|
||||
.tile.tile--entering.tile--needs {
|
||||
animation:
|
||||
tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--tile-enter-index, 0) * 24ms) both,
|
||||
tile-needs-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tile-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -20121,7 +20138,7 @@ body.tile-grid-resizing--row * {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile-count-menu,
|
||||
.tile.tile--entering,
|
||||
.tile.tile--entering.tile--needs,
|
||||
.tile.tile--needs::after,
|
||||
.tile.tile--loading .tile-body::after,
|
||||
.tile-grid-ghosts .tile.tile--leaving,
|
||||
.tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving,
|
||||
@@ -20981,7 +20998,9 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta
|
||||
the routes ran through tabs, labels and box borders. Only a case wider than
|
||||
the whole strip still wraps inside its box (max-width: 100%), and the strip
|
||||
wraps around it too (_tabClustersWrapInside). The narrower screens keep
|
||||
their scrolling strip. */
|
||||
their scrolling strip: mobile.css dissolves the boxes into its chip row on
|
||||
phones (599px block) and on tablets (600 to 767px block), since a box
|
||||
allowed to shrink there wrapped its tabs out of the one-row strip. */
|
||||
@media (min-width: 768px) {
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
flex-shrink: 0;
|
||||
|
||||
+112
-27
@@ -233,6 +233,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
onChange: (tile, state) => {
|
||||
const entry = grid.tiles.get(tile.sessionId);
|
||||
if (entry?.tile !== tile) return;
|
||||
// Rewritten on the way in, so a language switched since is picked up.
|
||||
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
|
||||
entry.el.classList.toggle('tile--loading', state !== 'idle');
|
||||
// The first capture has landed (or failed): the terminal fades in,
|
||||
// whole, instead of showing its replay scroll by.
|
||||
@@ -243,6 +245,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
return grid.queue;
|
||||
},
|
||||
|
||||
/**
|
||||
* The "Loading…" label of a tile body. It is CSS generated content (styles.css,
|
||||
* `content: attr(data-loading-label)`), which the i18n layer never reaches, so
|
||||
* the text is written here in the UI language.
|
||||
*/
|
||||
_setTileLoadingLabel(body) {
|
||||
if (!body) return;
|
||||
const t = window.codemanT;
|
||||
body.dataset.loadingLabel = typeof t === 'function' ? t('Loading…') : 'Loading…';
|
||||
},
|
||||
|
||||
_tileGridSection() {
|
||||
let section = document.getElementById('tileGrid');
|
||||
if (!section) {
|
||||
@@ -253,16 +266,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
const wrap = document.querySelector('.terminal-wrap');
|
||||
wrap?.parentElement?.insertBefore(section, wrap.nextSibling);
|
||||
}
|
||||
// Once per section (index.html ships it, so not only on create).
|
||||
if (this._tileFileDropSection !== section) {
|
||||
this._tileFileDropSection = section;
|
||||
this._installTileFileDrop(section);
|
||||
}
|
||||
return section;
|
||||
},
|
||||
|
||||
/**
|
||||
* A file dragged over the grid. The single view's file drop (image-input.js)
|
||||
* listens on #terminalContainer, hidden while tiles are open, so nothing
|
||||
* cancelled a file drag here and the browser opened the file in place of
|
||||
* Codeman. Anywhere over the grid (a tile, an empty cell, a divider, the
|
||||
* padding) the drag is cancelled, so the page never navigates; dropped on a
|
||||
* tile, its images upload to THAT tile's session and their paths are typed
|
||||
* there, as the single view does for the active one. Bubble phase, files
|
||||
* only: a tab or tile drag carries none, and its target stops it in the
|
||||
* capture phase anyway (_acceptTabDrops).
|
||||
*/
|
||||
_installTileFileDrop(section) {
|
||||
const isFileDrag = (e) => {
|
||||
const types = e.dataTransfer?.types;
|
||||
return !!types && Array.from(types).includes('Files');
|
||||
};
|
||||
// The open grid's tile under `target`, or null (an empty cell, a divider, the padding).
|
||||
const tileAt = (target) => {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || !target) return null;
|
||||
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(target)) return id;
|
||||
return null;
|
||||
};
|
||||
section.addEventListener('dragover', (e) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer && tileAt(e.target)) e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
section.addEventListener('drop', (e) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const sessionId = tileAt(e.target);
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (!sessionId || files.length === 0) return;
|
||||
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
|
||||
if (images.length === 0) {
|
||||
this.showToast?.('Only image files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(images, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the grid on `ids` (unknown, detached and duplicate ids are skipped;
|
||||
* at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it
|
||||
* adds what is missing and moves focus. `auto: false` makes the focus a human
|
||||
* selection (it acknowledges that session's idle alert). An open split
|
||||
* closes (the two are never open together); `mergeSplit` (default) makes its
|
||||
* two sessions the first tiles, false opens exactly `ids` (a stored grid).
|
||||
* two sessions the first tiles, false opens exactly `ids` (a stored grid, a
|
||||
* group, a Ctrl/Cmd+click: callers that size their own set).
|
||||
*
|
||||
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
|
||||
* its snapshot of the session it shows is still right, and closes its socket.
|
||||
@@ -275,15 +337,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
||||
// The grid and the split are never open together. An open split becomes the
|
||||
// grid's first two tiles (Pane A focused, Pane B beside it), so "split, then
|
||||
// want more" is one step. No closing resize for Pane A: it is about to park.
|
||||
// want more" is one step.
|
||||
let requested = ids || [];
|
||||
if (this._splitPane) {
|
||||
const splitOpen = !!this._splitPane;
|
||||
if (splitOpen && mergeSplit) {
|
||||
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
|
||||
this.closeSplitPane({ skipPrimaryResize: true });
|
||||
if (mergeSplit) {
|
||||
requested = [...seed, ...requested];
|
||||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||
}
|
||||
requested = [...seed, ...requested];
|
||||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||
}
|
||||
const wanted = [];
|
||||
for (const id of requested) {
|
||||
@@ -292,6 +352,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
wanted.push(id);
|
||||
if (wanted.length === max) break;
|
||||
}
|
||||
// No closing resize for Pane A only when it becomes a tile (its tile sizes
|
||||
// the PTY). A Pane A left out of the set (a group, a stored grid: mergeSplit
|
||||
// false) gets its full width back now, while the main terminal still shows
|
||||
// it, or its PTY stays at the split's half width for as long as the grid
|
||||
// is open.
|
||||
if (splitOpen) this.closeSplitPane({ skipPrimaryResize: wanted.includes(this.activeSessionId) });
|
||||
if (wanted.length === 0) return grid.open;
|
||||
const focus = wanted.includes(focusedId) ? focusedId : wanted[0];
|
||||
|
||||
@@ -1015,8 +1081,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* The tile chord `e` asks for, if it applies right now, else null. The
|
||||
* toggle applies while the grid is open, or where one could open AND the
|
||||
* per-device `showTileGridButton` setting is on (the desktop default; OFF on
|
||||
* handhelds): with it off the chord is inert and reaches the terminal like
|
||||
* any unbound key (owner decision 6 in docs/tile-grid-plan.md). An absent key
|
||||
* handhelds and touch-primary tablets): with it off the chord is inert and
|
||||
* reaches the terminal like any unbound key (owner decision 6 in
|
||||
* docs/tile-grid-plan.md). An absent key
|
||||
* resolves through the device defaults exactly as the header button does
|
||||
* (settings-ui.js), so the chord and the button can never disagree. The focus, move,
|
||||
* zoom and remove chords apply only while the grid is open, however it was
|
||||
@@ -1140,10 +1207,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// replayed fresh (forceReload drops the stale snapshot and nulls
|
||||
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
||||
// or, if that session is gone, the same fallback as closing the active tab.
|
||||
// Returns the selection's promise (it settles once the replay is written),
|
||||
// or undefined for the welcome screen.
|
||||
// A popped-out session counts as gone in both: selectSession would only
|
||||
// raise its window and return, leaving the parked terminal's pre-grid
|
||||
// content on screen under its tab (and saved as its snapshot on the next
|
||||
// switch). Returns the selection's promise (it settles once the replay is
|
||||
// written), or undefined for the welcome screen.
|
||||
_selectAfterTileGrid(sessionId) {
|
||||
if (sessionId && this.sessions.has(sessionId)) {
|
||||
const usable = (id) => this.sessions.has(id) && !this.detachedSessions?.has(id);
|
||||
if (sessionId && usable(sessionId)) {
|
||||
return this.selectSession(sessionId, { forceReload: true, auto: true });
|
||||
}
|
||||
this.activeSessionId = null;
|
||||
@@ -1152,7 +1223,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {
|
||||
/* Nothing stored. */
|
||||
}
|
||||
const next = this.sessionOrder.find((id) => this.sessions.has(id));
|
||||
const next = this.sessionOrder.find(usable);
|
||||
if (next) return this.selectSession(next, { auto: true });
|
||||
this.terminal?.clear();
|
||||
this.showWelcome();
|
||||
@@ -1213,11 +1284,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Removes one tile; the session keeps running. Its cell becomes empty where
|
||||
* it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus`
|
||||
* moves focus to the neighbouring tile (next in grid order, else previous),
|
||||
* as the app's choice (`auto`: no idle alert is spent). The last tile
|
||||
* as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
|
||||
* DOM focus where it is (an app-driven removal: a socket the server closed),
|
||||
* so keystrokes never land in the neighbour's PTY unasked. The last tile
|
||||
* leaving closes the grid: with `refocus` the single view then shows that
|
||||
* session, without it the caller decides what comes next.
|
||||
* session (or, popped out, the next one: _selectAfterTileGrid), without it
|
||||
* the caller decides what comes next.
|
||||
*/
|
||||
removeTile(sessionId, { refocus = true } = {}) {
|
||||
removeTile(sessionId, { refocus = true, focus = true } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||||
if (!entry) return false;
|
||||
@@ -1243,7 +1317,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyTileLayout();
|
||||
this._scheduleTileGridRefit();
|
||||
this.renderSessionTabs?.();
|
||||
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true });
|
||||
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus });
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1279,6 +1353,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header = this._buildTileHeader(sessionId);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tile-body';
|
||||
this._setTileLoadingLabel(body);
|
||||
el.append(header.el, body);
|
||||
// Pressing a tile is a human selection: it focuses the tile and
|
||||
// acknowledges its idle alert (the already-focused tile hits
|
||||
@@ -1331,7 +1406,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `accepts(id)` is the target's own rule (a tile takes any session but its
|
||||
* own; an empty cell takes any). A session it does not accept
|
||||
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
|
||||
* drop follows). Any other drag (a file) is left alone.
|
||||
* drop follows). Any other drag (a file) is left to the grid section's own
|
||||
* guard (_installTileFileDrop).
|
||||
*/
|
||||
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
|
||||
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
|
||||
@@ -1525,7 +1601,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
|
||||
const base = this._tileGridOpenSet()?.ids || [];
|
||||
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
|
||||
this.openTileGrid(ids, { focusedId: sessionId, auto: false });
|
||||
// Exactly these: an open split is already in `base` (tileGridOpenSet seeds
|
||||
// it), and merging it again went past the count (N+1) and the window.
|
||||
this.openTileGrid(ids, { focusedId: sessionId, auto: false, mergeSplit: false });
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1567,7 +1645,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTileGrid({ keepStored: false, reselect: false });
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
return this.openTileGrid(ids, { focusedId: focus });
|
||||
// Exactly the group: an open split closes without joining it (merged, its
|
||||
// two pushed group members out and the grid past the window's capacity).
|
||||
return this.openTileGrid(ids, { focusedId: focus, mergeSplit: false });
|
||||
},
|
||||
|
||||
/** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */
|
||||
@@ -2036,7 +2116,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* A tile's socket stopped for good. 4009 (the session exited) keeps the tile
|
||||
* with its "session ended" marker; 4003 (refused), 4004 (session gone) and
|
||||
* 4010 (another socket took over) remove it.
|
||||
* 4010 (another socket took over) remove it. Nobody here asked for that, so
|
||||
* the neighbour takes focus without the keyboard (`focus: false`): what the
|
||||
* user is typing never lands in another session's PTY.
|
||||
*/
|
||||
_onTileExit(sessionId, tile, code) {
|
||||
if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return;
|
||||
@@ -2045,7 +2127,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderTileOverlay(sessionId);
|
||||
return;
|
||||
}
|
||||
this.removeTile(sessionId);
|
||||
this.removeTile(sessionId, { focus: false });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2560,8 +2642,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!grid.open) return false;
|
||||
// Only a focus that is gone moves: re-selecting the same tile would hide an
|
||||
// active web tab on every SSE blip (_selectTiledSession hides the web layer),
|
||||
// which the single view's reconnect never does.
|
||||
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true });
|
||||
// which the single view's reconnect never does. The app's choice, so DOM
|
||||
// focus stays put (an open modal or text field keeps the keyboard).
|
||||
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true, focus: false });
|
||||
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
|
||||
return true;
|
||||
},
|
||||
@@ -2569,7 +2652,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
|
||||
// the neighbouring tile takes it (`auto`: the app chose, so no idle alert is
|
||||
// spent). Done BEFORE the original handler, so activeSessionId no longer names
|
||||
// spent; `focus: false`: the keyboard stays put, so what the user was typing
|
||||
// never goes on into the neighbour's PTY, as the single view sends it
|
||||
// nowhere). Done BEFORE the original handler, so activeSessionId no longer names
|
||||
// the deleted id and its welcome-screen handoff stays out of it. The last tile
|
||||
// closes the grid without a reselect, and the original handler then shows the
|
||||
// welcome screen as in the single view. A close started from this tab
|
||||
@@ -2582,7 +2667,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
|
||||
this.removeTile(data.id, { refocus: false });
|
||||
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
|
||||
this._selectTiledSession(neighbor, { auto: true });
|
||||
this._selectTiledSession(neighbor, { auto: true, focus: false });
|
||||
}
|
||||
}
|
||||
return _tileGridOriginalOnSessionDeleted.call(this, data);
|
||||
|
||||
@@ -1007,15 +1007,30 @@ const VoiceInput = {
|
||||
} else {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||
// The overlay belongs to the ACTIVE session's terminal, so text dictated
|
||||
// for any other session must not be typed into it.
|
||||
// for any other session must not be typed into it. It also belongs to the
|
||||
// MAIN terminal, which the tile grid parks (display: none): with tiles
|
||||
// open the text would sit in an invisible overlay the focused tile never
|
||||
// sees, so it goes straight to the session instead.
|
||||
const isActive = target === app.activeSessionId;
|
||||
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
|
||||
const tilesOpen = !!app._tilesOwnTerminal?.();
|
||||
if (isActive && !tilesOpen && app._localEchoEnabled && app._localEchoOverlay) {
|
||||
app._localEchoOverlay.appendText(trimmed);
|
||||
} else {
|
||||
this._sendToTarget(target, trimmed).catch(() => {});
|
||||
}
|
||||
this._showVoiceSendBtn();
|
||||
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
|
||||
setTimeout(() => {
|
||||
if (!isActive) return;
|
||||
// With the grid open the keyboard belongs to the focused tile; the
|
||||
// parked main terminal cannot take focus. Split view keeps the main
|
||||
// terminal, even if Pane B took focus meanwhile (it is not the target).
|
||||
if (app._tilesOwnTerminal?.()) {
|
||||
const pane = app._focusedPane?.();
|
||||
if (pane?.sessionId === target) pane.terminal?.focus();
|
||||
} else if (app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1045,9 +1060,10 @@ const VoiceInput = {
|
||||
if (!target) return;
|
||||
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
||||
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||
// predictions belong to the ACTIVE session's terminal, so a dictation
|
||||
// for another session just sends its Enter there.
|
||||
if (target !== app.activeSessionId) {
|
||||
// predictions belong to the ACTIVE session's MAIN terminal, so a
|
||||
// dictation for another session, or for a tile while the grid has the
|
||||
// main terminal parked, just sends its Enter there.
|
||||
if (target !== app.activeSessionId || app._tilesOwnTerminal?.()) {
|
||||
this._sendToTarget(target, '\r').catch(() => {});
|
||||
} else if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
const text = app._localEchoOverlay.pendingText || '';
|
||||
|
||||
@@ -116,7 +116,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { resolveCodexLaunchDefaults } from '../codex-launch-defaults.js';
|
||||
import { applyLaunchDefaults } from '../launch-defaults.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
@@ -1150,8 +1150,10 @@ export function registerSessionRoutes(
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
const launchCodexConfig =
|
||||
mode === 'codex' && !remote ? await resolveCodexLaunchDefaults(body.codexConfig) : body.codexConfig;
|
||||
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and
|
||||
// effort today) fill the CLI's own config object where the caller left it unset.
|
||||
// Local launches only: a remote attach runs whatever the remote pane already runs.
|
||||
const launchBody = remote ? body : await applyLaunchDefaults(mode, body);
|
||||
// Where a model override comes from is a capability, and the three answers are
|
||||
// genuinely different mechanisms:
|
||||
// 'flag' — the CLI takes --model, so read the value the caller sent
|
||||
@@ -1166,10 +1168,9 @@ export function registerSessionRoutes(
|
||||
const modelSource = getCli(mode)?.capabilities.model;
|
||||
const model =
|
||||
modelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>)?.[modelSource.param ?? 'model'] as string | undefined)
|
||||
? (legacyConfigForMode(mode, launchBody as unknown as Record<string, unknown>)?.[
|
||||
modelSource.param ?? 'model'
|
||||
] as string | undefined)
|
||||
: modelSource?.source === 'claude-settings-file'
|
||||
? body.model || modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
@@ -1186,12 +1187,12 @@ export function registerSessionRoutes(
|
||||
deepSeekConfig: gatedDeepSeekConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
launchCodexConfig,
|
||||
body.geminiConfig,
|
||||
body.antigravityConfig,
|
||||
body.piConfig,
|
||||
body.grokConfig,
|
||||
body.deepSeekConfig
|
||||
launchBody.codexConfig,
|
||||
launchBody.geminiConfig,
|
||||
launchBody.antigravityConfig,
|
||||
launchBody.piConfig,
|
||||
launchBody.grokConfig,
|
||||
launchBody.deepSeekConfig
|
||||
);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
@@ -1204,14 +1205,14 @@ export function registerSessionRoutes(
|
||||
model,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
openCodeConfig: mode === 'opencode' ? launchBody.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
|
||||
effort: body.effort,
|
||||
@@ -3875,23 +3876,31 @@ export function registerSessionRoutes(
|
||||
// Apply global Nice priority config and model config from settings
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const qsModelConfig = await ctx.getModelConfig();
|
||||
const qsLaunchCodexConfig =
|
||||
mode === 'codex' && !remote && !docker
|
||||
? await resolveCodexLaunchDefaults(codexConfig, !!customModel)
|
||||
: codexConfig;
|
||||
// Synced App Settings launch defaults, as on the create path: local launches only, so
|
||||
// never a remote or Docker case, and never a custom model endpoint launch.
|
||||
const qsRequestConfigs = {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
};
|
||||
const qsLaunchConfigs =
|
||||
remote || docker ? qsRequestConfigs : await applyLaunchDefaults(mode, qsRequestConfigs, !!customModel);
|
||||
// ⚠️ The model is read from a bag WITHOUT ompConfig, as it always was here: quick-start
|
||||
// has never taken omp's session model from ompConfig (the create path does). Kept as
|
||||
// found rather than changed in passing.
|
||||
const { ompConfig: qsLaunchOmpConfig, ...qsModelConfigs } = qsLaunchConfigs;
|
||||
// See the create path for why this is a capability rather than a mode ladder.
|
||||
const qsModelSource = getCli(mode)?.capabilities.model;
|
||||
const qsModel =
|
||||
qsModelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, {
|
||||
openCodeConfig,
|
||||
codexConfig: qsLaunchCodexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
|
||||
? (legacyConfigForMode(mode, qsModelConfigs as unknown as Record<string, unknown>)?.[
|
||||
qsModelSource.param ?? 'model'
|
||||
] as string | undefined)
|
||||
: qsModelSource?.source === 'claude-settings-file'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
@@ -3907,16 +3916,16 @@ export function registerSessionRoutes(
|
||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
qsLaunchCodexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig
|
||||
qsLaunchConfigs.codexConfig,
|
||||
qsLaunchConfigs.geminiConfig,
|
||||
qsLaunchConfigs.antigravityConfig,
|
||||
qsLaunchConfigs.piConfig,
|
||||
qsLaunchConfigs.grokConfig,
|
||||
qsLaunchConfigs.deepSeekConfig
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
||||
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig);
|
||||
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
|
||||
|
||||
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
|
||||
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
|
||||
@@ -4073,7 +4082,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: qsEffectiveClaudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
owner,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
openCodeConfig: mode === 'opencode' ? qsLaunchConfigs.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||
|
||||
+7
-6
@@ -1406,14 +1406,14 @@ export const SettingsUpdateSchema = z
|
||||
/**
|
||||
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
|
||||
* (per-device).
|
||||
* 'classic' = one flat list in tab order, as before. The default.
|
||||
* 'state' = a row per state in the header strip (needs you, waiting,
|
||||
* working, idle; option C), sections in the flat side rail and
|
||||
* the sidebar. The default.
|
||||
* the sidebar. Opt-in.
|
||||
* 'case' = one cluster per case (option A): a labelled box in the strip,
|
||||
* a section in the side rail and the sidebar.
|
||||
* a section in the side rail and the sidebar. Opt-in.
|
||||
* 'ledger' = the flat list on an aligned column grid with a status bar
|
||||
* per cell (option B). Header strip on desktop only.
|
||||
* 'classic' = one flat list in tab order, as before.
|
||||
* per cell (option B). Header strip on desktop only. Opt-in.
|
||||
*/
|
||||
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
|
||||
/**
|
||||
@@ -1457,8 +1457,9 @@ export const SettingsUpdateSchema = z
|
||||
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
|
||||
* (per-device), desktop only (the cluster is hidden below 768px).
|
||||
* 'classic' = the bars and the 5H · 7D chip, as before
|
||||
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value
|
||||
* 'tiles' = label over value with a bar underneath, no icons. The default.
|
||||
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value.
|
||||
* The default.
|
||||
* 'tiles' = label over value with a bar underneath, no icons
|
||||
*/
|
||||
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
|
||||
showTokenCount: z.boolean().optional(),
|
||||
|
||||
Reference in New Issue
Block a user