Compare commits

..

5 Commits

Author SHA1 Message Date
tanzhenxin
758e5c0992 update header display for narrow screen 2026-01-16 14:45:07 +08:00
tanzhenxin
881e7d038b try fix test fail on Windows again 2026-01-16 14:05:12 +08:00
tanzhenxin
5c6c3b2cf6 hide context usage if no request sent 2026-01-16 14:03:20 +08:00
tanzhenxin
f4d4844364 fix failed tests on Windows 2026-01-16 13:46:30 +08:00
tanzhenxin
b804b1f48a feat: Redesign CLI welcome screen and improve visual consistency 2026-01-16 11:48:31 +08:00
92 changed files with 1646 additions and 1471 deletions

View File

@@ -74,9 +74,6 @@ Settings are organized into categories. All settings should be placed within the
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
| `ui.hideBanner` | boolean | Hide the application banner. | `false` |
| `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` |
| `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` |
| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` |
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` |
| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |
@@ -356,7 +353,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o
},
"ui": {
"theme": "GitHub",
"hideBanner": true,
"hideTips": false,
"customWittyPhrases": [
"You forget a thousand things every day. Make sure this is one of 'em",

View File

@@ -20,6 +20,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
| Shortcut | Description |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `!` | Toggle shell mode when the input is empty. |
| `?` | Toggle keyboard shortcuts display when the input is empty. |
| `\` (at end of line) + `Enter` | Insert a newline. |
| `Down Arrow` | Navigate down through the input history. |
| `Enter` | Submit the current prompt. |
@@ -38,6 +39,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
| `Ctrl+N` | Navigate down through the input history. |
| `Ctrl+P` | Navigate up through the input history. |
| `Ctrl+R` | Reverse search through input/shell history. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.0",
"workspaces": [
"packages/*"
],
@@ -17310,7 +17310,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.0",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17947,7 +17947,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.7.0",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21408,7 +21408,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.1",
"version": "0.7.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21420,7 +21420,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.7.1",
"version": "0.7.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.1",
"version": "0.7.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
},
"dependencies": {
"@google/genai": "1.30.0",

View File

@@ -553,70 +553,6 @@ describe('loadCliConfig', () => {
expect(config.getIncludePartialMessages()).toBe(true);
});
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
process.argv = ['node', 'script.js', '--show-memory-usage'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getShowMemoryUsage()).toBe(true);
});
it('should set showMemoryUsage to false when --memory flag is not present', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getShowMemoryUsage()).toBe(false);
});
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { ui: { showMemoryUsage: false } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getShowMemoryUsage()).toBe(false);
});
it('should prioritize CLI flag over settings for showMemoryUsage (CLI true, settings false)', async () => {
process.argv = ['node', 'script.js', '--show-memory-usage'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { ui: { showMemoryUsage: false } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.getShowMemoryUsage()).toBe(true);
});
describe('Proxy configuration', () => {
const originalProxyEnv: { [key: string]: string | undefined } = {};
const proxyEnvVars = [

View File

@@ -105,7 +105,6 @@ export interface CliArgs {
prompt: string | undefined;
promptInteractive: string | undefined;
allFiles: boolean | undefined;
showMemoryUsage: boolean | undefined;
yolo: boolean | undefined;
approvalMode: string | undefined;
telemetry: boolean | undefined;
@@ -298,11 +297,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Include ALL files in context?',
default: false,
})
.option('show-memory-usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.option('yolo', {
alias: 'y',
type: 'boolean',
@@ -498,10 +492,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
],
description: 'Authentication type',
})
.deprecateOption(
'show-memory-usage',
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
)
.deprecateOption(
'sandbox-image',
'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.',
@@ -874,10 +864,11 @@ export async function loadCliConfig(
}
};
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
const isAcpMode = argv.acp || argv.experimentalAcp;
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
if (
!interactive &&
!argv.experimentalAcp &&
inputFormat !== InputFormat.STREAM_JSON
) {
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:
@@ -1013,8 +1004,6 @@ export async function loadCliConfig(
userMemory: memoryContent,
geminiMdFileCount: fileCount,
approvalMode,
showMemoryUsage:
argv.showMemoryUsage || settings.ui?.showMemoryUsage || false,
accessibility: {
...settings.ui?.accessibility,
screenReader,

View File

@@ -2260,7 +2260,7 @@ describe('Settings Loading and Merging', () => {
disableAutoUpdate: true,
},
ui: {
hideBanner: true,
hideTips: true,
customThemes: {
myTheme: {},
},
@@ -2283,7 +2283,7 @@ describe('Settings Loading and Merging', () => {
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
disableAutoUpdate: true,
hideBanner: true,
hideTips: true,
customThemes: {
myTheme: {},
},

View File

@@ -90,13 +90,6 @@ const MIGRATION_MAP: Record<string, string> = {
hideWindowTitle: 'ui.hideWindowTitle',
showStatusInTitle: 'ui.showStatusInTitle',
hideTips: 'ui.hideTips',
hideBanner: 'ui.hideBanner',
hideFooter: 'ui.hideFooter',
hideCWD: 'ui.footer.hideCWD',
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
hideModelInfo: 'ui.footer.hideModelInfo',
hideContextSummary: 'ui.hideContextSummary',
showMemoryUsage: 'ui.showMemoryUsage',
showLineNumbers: 'ui.showLineNumbers',
showCitations: 'ui.showCitations',
ideMode: 'ide.enabled',

View File

@@ -157,9 +157,6 @@ describe('SettingsSchema', () => {
it('should have showInDialog property configured', () => {
// Check that user-facing settings are marked for dialog display
expect(
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
).toBe(true);
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
true,
);
@@ -175,9 +172,6 @@ describe('SettingsSchema', () => {
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
true,
);
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
true,
);
expect(
getSettingsSchema().privacy.properties.usageStatisticsEnabled
.showInDialog,

View File

@@ -321,82 +321,6 @@ const SETTINGS_SCHEMA = {
description: 'Hide helpful tips in the UI',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide the application banner',
showInDialog: true,
},
hideContextSummary: {
type: 'boolean',
label: 'Hide Context Summary',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Hide the context summary (QWEN.md, MCP servers) above the input.',
showInDialog: true,
},
footer: {
type: 'object',
label: 'Footer',
category: 'UI',
requiresRestart: false,
default: {},
description: 'Settings for the footer.',
showInDialog: false,
properties: {
hideCWD: {
type: 'boolean',
label: 'Hide CWD',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Hide the current working directory path in the footer.',
showInDialog: true,
},
hideSandboxStatus: {
type: 'boolean',
label: 'Hide Sandbox Status',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide the sandbox status indicator in the footer.',
showInDialog: true,
},
hideModelInfo: {
type: 'boolean',
label: 'Hide Model Info',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide the model name and context usage in the footer.',
showInDialog: true,
},
},
},
hideFooter: {
type: 'boolean',
label: 'Hide Footer',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide the footer from the UI',
showInDialog: true,
},
showMemoryUsage: {
type: 'boolean',
label: 'Show Memory Usage',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Display memory usage information in the UI',
showInDialog: true,
},
showLineNumbers: {
type: 'boolean',
label: 'Show Line Numbers',
@@ -1292,9 +1216,3 @@ type InferSettings<T extends SettingsSchema> = {
};
export type Settings = InferSettings<SettingsSchemaType>;
export interface FooterSettings {
hideCWD?: boolean;
hideSandboxStatus?: boolean;
hideModelInfo?: boolean;
}

View File

@@ -456,7 +456,6 @@ describe('gemini.tsx main function kitty protocol', () => {
promptInteractive: undefined,
query: undefined,
allFiles: undefined,
showMemoryUsage: undefined,
yolo: undefined,
approvalMode: undefined,
telemetry: undefined,

View File

@@ -278,13 +278,6 @@ export default {
'Hide Window Title': 'Fenstertitel ausblenden',
'Show Status in Title': 'Status im Titel anzeigen',
'Hide Tips': 'Tipps ausblenden',
'Hide Banner': 'Banner ausblenden',
'Hide Context Summary': 'Kontextzusammenfassung ausblenden',
'Hide CWD': 'Arbeitsverzeichnis ausblenden',
'Hide Sandbox Status': 'Sandbox-Status ausblenden',
'Hide Model Info': 'Modellinformationen ausblenden',
'Hide Footer': 'Fußzeile ausblenden',
'Show Memory Usage': 'Speichernutzung anzeigen',
'Show Line Numbers': 'Zeilennummern anzeigen',
'Show Citations': 'Quellenangaben anzeigen',
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',

View File

@@ -33,6 +33,25 @@ export default {
'Model Context Protocol command (from external servers)':
'Model Context Protocol command (from external servers)',
'Keyboard Shortcuts:': 'Keyboard Shortcuts:',
'Toggle this help display': 'Toggle this help display',
'Toggle shell mode': 'Toggle shell mode',
'Open command menu': 'Open command menu',
'Add file context': 'Add file context',
'Accept suggestion / Autocomplete': 'Accept suggestion / Autocomplete',
'Reverse search history': 'Reverse search history',
'Press ? again to close': 'Press ? again to close',
// Keyboard shortcuts panel descriptions
'for shell mode': 'for shell mode',
'for commands': 'for commands',
'for file paths': 'for file paths',
'to clear input': 'to clear input',
'to cycle approvals': 'to cycle approvals',
'to quit': 'to quit',
'for newline': 'for newline',
'to clear screen': 'to clear screen',
'to search history': 'to search history',
'to paste images': 'to paste images',
'for external editor': 'for external editor',
'Jump through words in the input': 'Jump through words in the input',
'Close dialogs, cancel requests, or quit application':
'Close dialogs, cancel requests, or quit application',
@@ -46,6 +65,7 @@ export default {
'Connecting to MCP servers... ({{connected}}/{{total}})':
'Connecting to MCP servers... ({{connected}}/{{total}})',
'Type your message or @path/to/file': 'Type your message or @path/to/file',
'? for shortcuts': '? for shortcuts',
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.",
'Cancel operation / Clear input (double press)':
@@ -275,13 +295,6 @@ export default {
'Hide Window Title': 'Hide Window Title',
'Show Status in Title': 'Show Status in Title',
'Hide Tips': 'Hide Tips',
'Hide Banner': 'Hide Banner',
'Hide Context Summary': 'Hide Context Summary',
'Hide CWD': 'Hide CWD',
'Hide Sandbox Status': 'Hide Sandbox Status',
'Hide Model Info': 'Hide Model Info',
'Hide Footer': 'Hide Footer',
'Show Memory Usage': 'Show Memory Usage',
'Show Line Numbers': 'Show Line Numbers',
'Show Citations': 'Show Citations',
'Custom Witty Phrases': 'Custom Witty Phrases',
@@ -891,14 +904,23 @@ export default {
// ============================================================================
// Startup Tips
// ============================================================================
'Tips for getting started:': 'Tips for getting started:',
'1. Ask questions, edit files, or run commands.':
'1. Ask questions, edit files, or run commands.',
'2. Be specific for the best results.':
'2. Be specific for the best results.',
'files to customize your interactions with Qwen Code.':
'files to customize your interactions with Qwen Code.',
'for more information.': 'for more information.',
'Tips:': 'Tips:',
'Use /compress when the conversation gets long to summarize history and free up context.':
'Use /compress when the conversation gets long to summarize history and free up context.',
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
'Use /bug to submit issues to the maintainers when something goes off.':
'Use /bug to submit issues to the maintainers when something goes off.',
'Switch auth type quickly with /auth.':
'Switch auth type quickly with /auth.',
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
// ============================================================================
// Exit Screen / Stats

View File

@@ -33,6 +33,13 @@ export default {
'Model Context Protocol command (from external servers)':
'Команда Model Context Protocol (из внешних серверов)',
'Keyboard Shortcuts:': 'Горячие клавиши:',
'Toggle this help display': 'Показать/скрыть эту справку',
'Toggle shell mode': 'Переключить режим оболочки',
'Open command menu': 'Открыть меню команд',
'Add file context': 'Добавить файл в контекст',
'Accept suggestion / Autocomplete': 'Принять подсказку / Автодополнение',
'Reverse search history': 'Обратный поиск по истории',
'Press ? again to close': 'Нажмите ? ещё раз, чтобы закрыть',
'Jump through words in the input': 'Переход по словам во вводе',
'Close dialogs, cancel requests, or quit application':
'Закрыть диалоги, отменить запросы или выйти из приложения',
@@ -46,6 +53,7 @@ export default {
'Connecting to MCP servers... ({{connected}}/{{total}})':
'Подключение к MCP-серверам... ({{connected}}/{{total}})',
'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу',
'? for shortcuts': '? — горячие клавиши',
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
"Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.",
'Cancel operation / Clear input (double press)':
@@ -60,6 +68,19 @@ export default {
'submit a bug report': 'Отправка отчёта об ошибке',
'About Qwen Code': 'Об Qwen Code',
// Keyboard shortcuts panel descriptions
'for shell mode': 'режим оболочки',
'for commands': 'меню команд',
'for file paths': 'пути к файлам',
'to clear input': 'очистить ввод',
'to cycle approvals': 'переключить режим',
'to quit': 'выход',
'for newline': 'новая строка',
'to clear screen': 'очистить экран',
'to search history': 'поиск в истории',
'to paste images': 'вставить изображения',
'for external editor': 'внешний редактор',
// ============================================================================
// Поля системной информации
// ============================================================================
@@ -278,13 +299,6 @@ export default {
'Hide Window Title': 'Скрыть заголовок окна',
'Show Status in Title': 'Показывать статус в заголовке',
'Hide Tips': 'Скрыть подсказки',
'Hide Banner': 'Скрыть баннер',
'Hide Context Summary': 'Скрыть сводку контекста',
'Hide CWD': 'Скрыть текущую директорию',
'Hide Sandbox Status': 'Скрыть статус песочницы',
'Hide Model Info': 'Скрыть информацию о модели',
'Hide Footer': 'Скрыть нижний колонтитул',
'Show Memory Usage': 'Показывать использование памяти',
'Show Line Numbers': 'Показывать номера строк',
'Show Citations': 'Показывать цитаты',
'Custom Witty Phrases': 'Пользовательские остроумные фразы',

View File

@@ -32,6 +32,25 @@ export default {
'Model Context Protocol command (from external servers)':
'模型上下文协议命令(来自外部服务器)',
'Keyboard Shortcuts:': '键盘快捷键:',
'Toggle this help display': '切换此帮助显示',
'Toggle shell mode': '切换命令行模式',
'Open command menu': '打开命令菜单',
'Add file context': '添加文件上下文',
'Accept suggestion / Autocomplete': '接受建议 / 自动补全',
'Reverse search history': '反向搜索历史',
'Press ? again to close': '再次按 ? 关闭',
// Keyboard shortcuts panel descriptions
'for shell mode': '命令行模式',
'for commands': '命令菜单',
'for file paths': '文件路径',
'to clear input': '清空输入',
'to cycle approvals': '切换审批模式',
'to quit': '退出',
'for newline': '换行',
'to clear screen': '清屏',
'to search history': '搜索历史',
'to paste images': '粘贴图片',
'for external editor': '外部编辑器',
'Jump through words in the input': '在输入中按单词跳转',
'Close dialogs, cancel requests, or quit application':
'关闭对话框、取消请求或退出应用程序',
@@ -45,6 +64,7 @@ export default {
'Connecting to MCP servers... ({{connected}}/{{total}})':
'正在连接到 MCP 服务器... ({{connected}}/{{total}})',
'Type your message or @path/to/file': '输入您的消息或 @ 文件路径',
'? for shortcuts': '按 ? 查看快捷键',
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
"按 'i' 进入插入模式,按 'Esc' 进入普通模式",
'Cancel operation / Clear input (double press)':
@@ -266,13 +286,6 @@ export default {
'Hide Window Title': '隐藏窗口标题',
'Show Status in Title': '在标题中显示状态',
'Hide Tips': '隐藏提示',
'Hide Banner': '隐藏横幅',
'Hide Context Summary': '隐藏上下文摘要',
'Hide CWD': '隐藏当前工作目录',
'Hide Sandbox Status': '隐藏沙箱状态',
'Hide Model Info': '隐藏模型信息',
'Hide Footer': '隐藏页脚',
'Show Memory Usage': '显示内存使用',
'Show Line Numbers': '显示行号',
'Show Citations': '显示引用',
'Custom Witty Phrases': '自定义诙谐短语',
@@ -845,13 +858,22 @@ export default {
// ============================================================================
// Startup Tips
// ============================================================================
'Tips for getting started:': '入门提示:',
'1. Ask questions, edit files, or run commands.':
'1. 提问、编辑文件或运行命令',
'2. Be specific for the best results.': '2. 具体描述以获得最佳结果',
'files to customize your interactions with Qwen Code.':
'文件以自定义您与 Qwen Code 的交互',
'for more information.': '获取更多信息',
'Tips:': '提示:',
'Use /compress when the conversation gets long to summarize history and free up context.':
'对话变长时用 /compress总结历史并释放上下文。',
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
'用 /clear 或 /new 开启新思路;之前的会话会保留在历史记录中。',
'Use /bug to submit issues to the maintainers when something goes off.':
'遇到问题时,用 /bug 将问题提交给维护者。',
'Switch auth type quickly with /auth.': '用 /auth 快速切换认证方式。',
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
'在 Qwen Code 中使用 ! 可运行任意 shell 命令(例如 !ls。',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
'输入 / 打开命令弹窗;按 Tab 自动补全斜杠命令和保存的提示词。',
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
'运行 qwen --continue 或 qwen --resume 可继续之前的会话。',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。',
// ============================================================================
// Exit Screen / Stats

View File

@@ -5,34 +5,15 @@
*/
import { useIsScreenReaderEnabled } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { lerp } from '../utils/math.js';
import { useUIState } from './contexts/UIStateContext.js';
import { StreamingContext } from './contexts/StreamingContext.js';
import { QuittingDisplay } from './components/QuittingDisplay.js';
import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js';
import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
const getContainerWidth = (terminalWidth: number): string => {
if (terminalWidth <= 80) {
return '98%';
}
if (terminalWidth >= 132) {
return '90%';
}
// Linearly interpolate between 80 columns (98%) and 132 columns (90%).
const t = (terminalWidth - 80) / (132 - 80);
const percentage = lerp(98, 90, t);
return `${Math.round(percentage)}%`;
};
export const App = () => {
const uiState = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const { columns } = useTerminalSize();
const containerWidth = getContainerWidth(columns);
if (uiState.quittingMessages) {
return <QuittingDisplay />;
@@ -40,11 +21,7 @@ export const App = () => {
return (
<StreamingContext.Provider value={uiState.streamingState}>
{isScreenReaderEnabled ? (
<ScreenReaderAppLayout />
) : (
<DefaultAppLayout width={containerWidth} />
)}
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
</StreamingContext.Provider>
);
};

View File

@@ -294,10 +294,7 @@ describe('AppContainer State Management', () => {
// Mock LoadedSettings
mockSettings = {
merged: {
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
showStatusInTitle: false,
@@ -445,10 +442,7 @@ describe('AppContainer State Management', () => {
it('handles settings with all display options disabled', () => {
const settingsAllHidden = {
merged: {
hideBanner: true,
hideFooter: true,
hideTips: true,
showMemoryUsage: false,
},
} as unknown as LoadedSettings;
@@ -463,28 +457,6 @@ describe('AppContainer State Management', () => {
);
}).not.toThrow();
});
it('handles settings with memory usage enabled', () => {
const settingsWithMemory = {
merged: {
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: true,
},
} as unknown as LoadedSettings;
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={settingsWithMemory}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
});
});
describe('Version Handling', () => {

View File

@@ -271,7 +271,8 @@ export const AppContainer = (props: AppContainerProps) => {
calculatePromptWidths(terminalWidth);
return { inputWidth, suggestionsWidth };
}, [terminalWidth]);
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
// Uniform width for bordered box components: accounts for margins and caps at 100
const mainAreaWidth = Math.min(terminalWidth - 4, 100);
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const isValidPath = useCallback((filePath: string): boolean => {

View File

@@ -15,9 +15,11 @@ import {
} from '../../utils/systemInfoFields.js';
import { t } from '../../i18n/index.js';
type AboutBoxProps = ExtendedSystemInfo;
type AboutBoxProps = ExtendedSystemInfo & {
width?: number;
};
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
export const AboutBox: React.FC<AboutBoxProps> = ({ width, ...props }) => {
const fields = getSystemInfoFields(props);
return (
@@ -26,8 +28,7 @@ export const AboutBox: React.FC<AboutBoxProps> = (props) => {
borderColor={theme.border.default}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
width={width}
>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, expect, it, vi } from 'vitest';
import { AppHeader } from './AppHeader.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const createSettings = (options?: { hideTips?: boolean }): LoadedSettings =>
({
merged: {
ui: {
hideTips: options?.hideTips ?? true,
},
},
}) as never;
const createMockConfig = (overrides = {}) => ({
getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })),
getModel: vi.fn(() => 'gemini-pro'),
getTargetDir: vi.fn(() => '/projects/qwen-code'),
getMcpServers: vi.fn(() => ({})),
getBlockedMcpServers: vi.fn(() => []),
getDebugMode: vi.fn(() => false),
getScreenReader: vi.fn(() => false),
...overrides,
});
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
branchName: 'main',
nightly: false,
debugMessage: '',
sessionStats: {
lastPromptTokenCount: 0,
},
...overrides,
}) as UIState;
const renderWithProviders = (
uiState: UIState,
settings = createSettings(),
config = createMockConfig(),
) => {
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
return render(
<ConfigContext.Provider value={config as never}>
<SettingsContext.Provider value={settings}>
<VimModeProvider settings={settings}>
<UIStateContext.Provider value={uiState}>
<AppHeader version="1.2.3" />
</UIStateContext.Provider>
</VimModeProvider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
};
describe('<AppHeader />', () => {
it('shows the working directory', () => {
const { lastFrame } = renderWithProviders(createMockUIState());
expect(lastFrame()).toContain('/projects/qwen-code');
});
it('hides the header when screen reader is enabled', () => {
const { lastFrame } = renderWithProviders(
createMockUIState(),
createSettings(),
createMockConfig({ getScreenReader: vi.fn(() => true) }),
);
// When screen reader is enabled, header is not rendered
expect(lastFrame()).not.toContain('/projects/qwen-code');
expect(lastFrame()).not.toContain('Qwen Code');
});
it('shows the header with all info when banner is visible', () => {
const { lastFrame } = renderWithProviders(createMockUIState());
expect(lastFrame()).toContain('>_ Qwen Code');
expect(lastFrame()).toContain('gemini-pro');
expect(lastFrame()).toContain('/projects/qwen-code');
});
});

View File

@@ -9,7 +9,6 @@ import { Header } from './Header.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
interface AppHeaderProps {
version: string;
@@ -18,16 +17,25 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly } = useUIState();
const contentGeneratorConfig = config.getContentGeneratorConfig();
const authType = contentGeneratorConfig?.authType;
const model = config.getModel();
const targetDir = config.getTargetDir();
const showBanner = !config.getScreenReader();
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<Header version={version} nightly={nightly} />
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
{showBanner && (
<Header
version={version}
authType={authType}
model={model}
workingDirectory={targetDir}
/>
)}
{showTips && <Tips />}
</Box>
);
};

View File

@@ -5,29 +5,10 @@
*/
export const shortAsciiLogo = `
██████╗ ██╗ ██╗███████╗███╗ ██╗
▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄
██╔═══██╗██║ ██║██╔════╝████╗ ██║
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;
export const longAsciiLogo = `
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;
export const tinyAsciiLogo = `
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;

View File

@@ -14,7 +14,6 @@ import {
type UIActions,
} from '../contexts/UIActionsContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
@@ -146,92 +145,33 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hideFooter: false,
showMemoryUsage: false,
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
settings = createMockSettings(),
config = createMockConfig(),
uiActions = createMockUIActions(),
) =>
render(
<ConfigContext.Provider value={config as any}>
<SettingsContext.Provider value={settings as any}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</SettingsContext.Provider>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</ConfigContext.Provider>,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
describe('Composer', () => {
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
describe('Footer Display', () => {
it('renders Footer by default', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: false });
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = renderComposer(uiState);
// Smoke check that the Footer renders when enabled.
// Smoke check that the Footer renders
expect(lastFrame()).toContain('Footer');
});
it('does NOT render Footer when hideFooter is true', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: true });
const { lastFrame } = renderComposer(uiState, settings);
// Check for content that only appears IN the Footer component itself
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
});
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
errorCount: 2,
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
lastPromptTokenCount: 150,
promptCount: 5,
},
});
const config = createMockConfig({
getModel: vi.fn(() => 'gemini-1.5-flash'),
getTargetDir: vi.fn(() => '/project/path'),
getDebugMode: vi.fn(() => true),
});
const settings = createMockSettings({
hideFooter: false,
showMemoryUsage: true,
});
// Mock vim mode for this test
const { useVimMode } = await import('../contexts/VimModeContext.js');
vi.mocked(useVimMode).mockReturnValueOnce({
vimEnabled: true,
vimMode: 'INSERT',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const { lastFrame } = renderComposer(uiState, settings, config);
expect(lastFrame()).toContain('Footer');
// Footer should be rendered with all the state passed through
});
});
describe('Loading Indicator', () => {
@@ -261,7 +201,7 @@ describe('Composer', () => {
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
});
const { lastFrame } = renderComposer(uiState, undefined, config);
const { lastFrame } = renderComposer(uiState, config);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
@@ -318,7 +258,8 @@ describe('Composer', () => {
});
describe('Context and Status Display', () => {
it('shows ContextSummaryDisplay in normal state', () => {
// Note: ContextSummaryDisplay and status prompts are now rendered in Footer, not Composer
it('shows empty space in normal state (ContextSummaryDisplay moved to Footer)', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -327,37 +268,43 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ContextSummaryDisplay');
// ContextSummaryDisplay is now in Footer, so we just verify normal state renders
expect(lastFrame()).toBeDefined();
});
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
// Note: Ctrl+C, Ctrl+D, and Escape prompts are now rendered in Footer component
// These are tested in Footer.test.tsx
it('renders Footer which handles Ctrl+C exit prompt', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
// Ctrl+C prompt is now inside Footer, verify Footer renders
expect(lastFrame()).toContain('Footer');
});
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
it('renders Footer which handles Ctrl+D exit prompt', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
// Ctrl+D prompt is now inside Footer, verify Footer renders
expect(lastFrame()).toContain('Footer');
});
it('shows escape prompt when showEscapePrompt is true', () => {
it('renders Footer which handles escape prompt', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Esc again to clear');
// Escape prompt is now inside Footer, verify Footer renders
expect(lastFrame()).toContain('Footer');
});
});
@@ -382,7 +329,9 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('InputPrompt');
});
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
// Note: AutoAcceptIndicator and ShellModeIndicator are now rendered inside Footer component
// These are tested in Footer.test.tsx
it('renders Footer which contains AutoAcceptIndicator when approval mode is not default', () => {
const uiState = createMockUIState({
showAutoAcceptIndicator: ApprovalMode.YOLO,
shellModeActive: false,
@@ -390,17 +339,19 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('AutoAcceptIndicator');
// AutoAcceptIndicator is now inside Footer, verify Footer renders
expect(lastFrame()).toContain('Footer');
});
it('shows ShellModeIndicator when shell mode is active', () => {
it('renders Footer which contains ShellModeIndicator when shell mode is active', () => {
const uiState = createMockUIState({
shellModeActive: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShellModeIndicator');
// ShellModeIndicator is now inside Footer, verify Footer renders
expect(lastFrame()).toContain('Footer');
});
});

View File

@@ -4,42 +4,46 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useMemo } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { useCallback, useMemo, useState } from 'react';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { t } from '../../i18n/index.js';
export const Composer = () => {
const config = useConfig();
const settings = useSettings();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const terminalWidth = process.stdout.columns;
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const { contextFileNames, showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator } = uiState;
// State for keyboard shortcuts display toggle
const [showShortcuts, setShowShortcuts] = useState(false);
const handleToggleShortcuts = useCallback(() => {
setShowShortcuts((prev) => !prev);
}, []);
// State for suggestions visibility
const [showSuggestions, setShowSuggestions] = useState(false);
const handleSuggestionsVisibilityChange = useCallback((visible: boolean) => {
setShowSuggestions(visible);
}, []);
// Use the container width of InputPrompt for width of DetailedMessagesDisplay
const { containerWidth } = useMemo(
@@ -48,7 +52,7 @@ export const Composer = () => {
);
return (
<Box flexDirection="column">
<Box flexDirection="column" marginTop={1}>
{!uiState.embeddedShellFocused && (
<LoadingIndicator
thought={
@@ -70,55 +74,6 @@ export const Composer = () => {
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
<Box
marginTop={1}
justifyContent={
settings.merged.ui?.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box marginRight={1}>
{process.env['GEMINI_SYSTEM_MD'] && (
<Text color={theme.status.error}>|_| </Text>
)}
{uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>
{t('Press Ctrl+C again to exit.')}
</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
{t('Press Ctrl+D again to exit.')}
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>
{t('Press Esc again to clear.')}
</Text>
) : (
!settings.merged.ui?.hideContextSummary && (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()}
blockedMcpServers={config.getBlockedMcpServers()}
showToolDescriptions={uiState.showToolDescriptions}
/>
)
)}
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!uiState.shellModeActive && (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
)}
{uiState.shellModeActive && <ShellModeIndicator />}
</Box>
</Box>
{uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
@@ -149,6 +104,9 @@ export const Composer = () => {
setShellModeActive={uiActions.setShellModeActive}
approvalMode={showAutoAcceptIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
onToggleShortcuts={handleToggleShortcuts}
showShortcuts={showShortcuts}
onSuggestionsVisibilityChange={handleSuggestionsVisibilityChange}
focus={true}
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
@@ -160,7 +118,13 @@ export const Composer = () => {
/>
)}
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
{/* Exclusive area: only one component visible at a time */}
{!showSuggestions &&
(showShortcuts ? (
<KeyboardShortcuts />
) : (
!isScreenReaderEnabled && <Footer />
))}
</Box>
);
};

View File

@@ -44,7 +44,7 @@ describe('ConsentPrompt', () => {
{
isPending: true,
text: prompt,
terminalWidth,
contentWidth: terminalWidth,
},
undefined,
);

View File

@@ -32,7 +32,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
<MarkdownDisplay
isPending={true}
text={prompt}
terminalWidth={terminalWidth}
contentWidth={terminalWidth}
/>
) : (
prompt

View File

@@ -17,15 +17,19 @@ export const ContextUsageDisplay = ({
model: string;
terminalWidth: number;
}) => {
const percentage = promptTokenCount / tokenLimit(model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
if (promptTokenCount === 0) {
return null;
}
const label = terminalWidth < 100 ? '%' : '% context left';
const percentage = promptTokenCount / tokenLimit(model);
const percentageUsed = (percentage * 100).toFixed(1);
const label = terminalWidth < 100 ? '% used' : '% context used';
return (
<Text color={theme.text.secondary}>
({percentageLeft}
{label})
{percentageUsed}
{label}
</Text>
);
};

View File

@@ -8,41 +8,23 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Footer } from './Footer.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { tildeifyPath } from '@qwen-code/qwen-code-core';
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
shortenPath: (p: string, len: number) => {
if (p.length > len) {
return '...' + p.slice(p.length - len + 3);
}
return p;
},
};
});
const defaultProps = {
model: 'gemini-pro',
targetDir:
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
branchName: 'main',
};
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => defaultProps.model),
getTargetDir: vi.fn(() => defaultProps.targetDir),
getDebugMode: vi.fn(() => false),
getMcpServers: vi.fn(() => ({})),
getBlockedMcpServers: vi.fn(() => []),
...overrides,
});
@@ -51,46 +33,31 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
sessionStats: {
lastPromptTokenCount: 100,
},
branchName: defaultProps.branchName,
geminiMdFileCount: 0,
contextFileNames: [],
showToolDescriptions: false,
ideContextState: undefined,
...overrides,
}) as UIState;
const createDefaultSettings = (
options: {
showMemoryUsage?: boolean;
hideCWD?: boolean;
hideSandboxStatus?: boolean;
hideModelInfo?: boolean;
} = {},
): LoadedSettings =>
const createMockSettings = (): LoadedSettings =>
({
merged: {
ui: {
showMemoryUsage: options.showMemoryUsage,
footer: {
hideCWD: options.hideCWD,
hideSandboxStatus: options.hideSandboxStatus,
hideModelInfo: options.hideModelInfo,
},
general: {
vimMode: false,
},
},
}) as never;
}) as LoadedSettings;
const renderWithWidth = (
width: number,
uiState: UIState,
settings: LoadedSettings = createDefaultSettings(),
) => {
const renderWithWidth = (width: number, uiState: UIState) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(
<ConfigContext.Provider value={createMockConfig() as never}>
<SettingsContext.Provider value={settings}>
<VimModeProvider settings={settings}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</SettingsContext.Provider>
<VimModeProvider settings={createMockSettings()}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</ConfigContext.Provider>,
);
};
@@ -101,161 +68,28 @@ describe('<Footer />', () => {
expect(lastFrame()).toBeDefined();
});
describe('path display', () => {
it('should display a shortened path on a narrow terminal', () => {
const { lastFrame } = renderWithWidth(79, createMockUIState());
const tildePath = tildeifyPath(defaultProps.targetDir);
const pathLength = Math.max(20, Math.floor(79 * 0.25));
const expectedPath =
'...' + tildePath.slice(tildePath.length - pathLength + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithWidth(80, createMockUIState());
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath =
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
expect(lastFrame()).toContain(expectedPath);
});
});
it('displays the branch name when provided', () => {
it('does not display the working directory or branch name', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
expect(lastFrame()).not.toMatch(/\(.*\*\)/);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
branchName: undefined,
}),
);
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
it('displays the context percentage', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
expect(lastFrame()).toMatch(/\d+(\.\d+)?% context used/);
});
it('displays the model name and abbreviated context percentage', () => {
it('displays the abbreviated context percentage on narrow terminal', () => {
const { lastFrame } = renderWithWidth(99, createMockUIState());
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
expect(lastFrame()).toMatch(/\d+%/);
});
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: false,
}),
);
expect(lastFrame()).toContain('untrusted');
});
it('should display custom sandbox info when SANDBOX env is set', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: undefined,
}),
);
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
});
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: true,
}),
);
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
});
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: true,
}),
);
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
});
it('should prioritize untrusted message over sandbox info', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: false,
}),
);
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
});
});
describe('footer configuration filtering (golden snapshots)', () => {
it('renders complete footer with all sections visible (baseline)', () => {
describe('footer rendering (golden snapshots)', () => {
it('renders complete footer on wide terminal', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
});
it('renders footer with all optional sections hidden (minimal footer)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
}),
);
expect(lastFrame()).toMatchSnapshot('footer-minimal');
});
it('renders footer with only model info hidden (partial filtering)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
}),
);
expect(lastFrame()).toMatchSnapshot('footer-no-model');
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
}),
);
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
});
it('renders complete footer in narrow terminal (baseline narrow)', () => {
it('renders complete footer on narrow terminal', () => {
const { lastFrame } = renderWithWidth(79, createMockUIState());
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
});

View File

@@ -7,159 +7,134 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
const {
model,
targetDir,
debugMode,
branchName,
debugMessage,
errorCount,
showErrorDetails,
promptTokenCount,
nightly,
isTrustedFolder,
showAutoAcceptIndicator,
} = {
model: config.getModel(),
targetDir: config.getTargetDir(),
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
showAutoAcceptIndicator: uiState.showAutoAcceptIndicator,
};
const showMemoryUsage =
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
const hideCWD = settings.merged.ui?.footer?.hideCWD || false;
const hideSandboxStatus =
settings.merged.ui?.footer?.hideSandboxStatus || false;
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
const showErrorIndicator = !showErrorDetails && errorCount > 0;
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
// Determine sandbox info from environment
const sandboxEnv = process.env['SANDBOX'];
const sandboxInfo = sandboxEnv
? sandboxEnv === 'sandbox-exec'
? 'seatbelt'
: sandboxEnv.startsWith('qwen-code')
? 'docker'
: sandboxEnv
: null;
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
const displayVimMode = vimEnabled ? vimMode : undefined;
// Check if debug mode is enabled
const debugMode = config.getDebugMode();
// Left section should show exactly ONE thing at any time, in priority order.
const leftContent = uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>{t('Press Ctrl+D again to exit.')}</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>{t('Press Esc again to clear.')}</Text>
) : vimEnabled && vimMode === 'INSERT' ? (
<Text color={theme.text.secondary}>-- INSERT --</Text>
) : uiState.shellModeActive ? (
<ShellModeIndicator />
) : showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
) : (
<Text color={theme.text.secondary}>{t('? for shortcuts')}</Text>
);
const rightItems: Array<{ key: string; node: React.ReactNode }> = [];
if (sandboxInfo) {
rightItems.push({
key: 'sandbox',
node: <Text color={theme.status.success}>🔒 {sandboxInfo}</Text>,
});
}
if (debugMode) {
rightItems.push({
key: 'debug',
node: <Text color={theme.status.warning}>Debug Mode</Text>,
});
}
if (promptTokenCount > 0) {
rightItems.push({
key: 'context',
node: (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
</Text>
),
});
}
if (showErrorIndicator) {
rightItems.push({
key: 'errors',
node: <ConsoleSummaryDisplay errorCount={errorCount} />,
});
}
return (
<Box
justifyContent={justifyContent}
justifyContent="space-between"
width="100%"
flexDirection="row"
alignItems="center"
>
{(debugMode || displayVimMode || !hideCWD) && (
<Box>
{debugMode && <DebugProfiler />}
{displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)}
{!hideCWD &&
(nightly ? (
<Gradient colors={theme.ui.gradient}>
<Text>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
) : (
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
))}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
</Box>
)}
{/* Left Section: Exactly one status line (exit prompts / mode indicator / default hint) */}
<Box
marginLeft={2}
justifyContent="flex-start"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{leftContent}
</Box>
{/* Middle Section: Centered Trust/Sandbox Info */}
{!hideSandboxStatus && (
<Box
flexGrow={1}
alignItems="center"
justifyContent="center"
display="flex"
>
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
) : process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'sandbox-exec' ? (
<Text color="green">
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.text.secondary}>
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
) : (
<Text color={theme.status.error}>
no sandbox
{terminalWidth >= 100 && (
<Text color={theme.text.secondary}> (see /docs)</Text>
)}
</Text>
)}
</Box>
)}
{/* Right Section: Gemini Label and Console Summary */}
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
{/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */}
<Box alignItems="center" justifyContent="flex-end" marginRight={2}>
{rightItems.map(({ key, node }, index) => (
<Box key={key} alignItems="center">
{index > 0 && <Text color={theme.text.secondary}> | </Text>}
{node}
</Box>
<Box alignItems="center" paddingLeft={2}>
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}
</Box>
</Box>
)}
))}
</Box>
</Box>
);
};

View File

@@ -6,39 +6,96 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo } from './AsciiArt.js';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const defaultProps = {
version: '1.0.0',
authType: AuthType.QWEN_OAUTH,
model: 'qwen-coder-plus',
workingDirectory: '/home/user/projects/test',
};
describe('<Header />', () => {
beforeEach(() => {});
it('renders the long logo on a wide terminal', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).toContain(longAsciiLogo);
beforeEach(() => {
// Default to wide terminal (shows both logo and info panel)
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
});
it('renders custom ASCII art when provided', () => {
it('renders the ASCII logo on wide terminal', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
// Check that parts of the shortAsciiLogo are rendered
expect(lastFrame()).toContain('██╔═══██╗');
});
it('hides the ASCII logo on narrow terminal', () => {
useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 });
const { lastFrame } = render(<Header {...defaultProps} />);
// Should not contain the logo but still show the info panel
expect(lastFrame()).not.toContain('██╔═══██╗');
expect(lastFrame()).toContain('>_ Qwen Code');
});
it('renders custom ASCII art when provided on wide terminal', () => {
const customArt = 'CUSTOM ART';
const { lastFrame } = render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
<Header {...defaultProps} customAsciiArt={customArt} />,
);
expect(lastFrame()).toContain(customArt);
});
it('displays the version number when nightly is true', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
it('displays the version number', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).not.toContain('v1.0.0');
it('displays Qwen Code title with >_ prefix', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('>_ Qwen Code');
});
it('displays auth type and model', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('Qwen OAuth');
expect(lastFrame()).toContain('qwen-coder-plus');
});
it('displays working directory', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('/home/user/projects/test');
});
it('renders a custom working directory display', () => {
const { lastFrame } = render(
<Header {...defaultProps} workingDirectory="custom display" />,
);
expect(lastFrame()).toContain('custom display');
});
it('displays working directory without branch name', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
// Branch name is no longer shown in header
expect(lastFrame()).toContain('/home/user/projects/test');
expect(lastFrame()).not.toContain('(main*)');
});
it('formats home directory with tilde', () => {
const { lastFrame } = render(
<Header {...defaultProps} workingDirectory="/Users/testuser/projects" />,
);
// The actual home dir replacement depends on os.homedir()
// Just verify the path is shown
expect(lastFrame()).toContain('projects');
});
it('renders with border around info panel', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
// Check for border characters (round border style uses these)
expect(lastFrame()).toContain('╭');
expect(lastFrame()).toContain('╯');
});
});

View File

@@ -7,64 +7,172 @@
import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { AuthType, shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { shortAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
version: string;
nightly: boolean;
authType?: AuthType;
model: string;
workingDirectory: string;
}
function titleizeAuthType(value: string): string {
return value
.split(/[-_]/g)
.filter(Boolean)
.map((part) => {
if (part.toLowerCase() === 'ai') {
return 'AI';
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join(' ');
}
// Format auth type for display
function formatAuthType(authType?: AuthType): string {
if (!authType) {
return 'Unknown';
}
switch (authType) {
case AuthType.QWEN_OAUTH:
return 'Qwen OAuth';
case AuthType.USE_OPENAI:
return 'OpenAI';
case AuthType.USE_GEMINI:
return 'Gemini';
case AuthType.USE_VERTEX_AI:
return 'Vertex AI';
case AuthType.USE_ANTHROPIC:
return 'Anthropic';
default:
return titleizeAuthType(String(authType));
}
}
export const Header: React.FC<HeaderProps> = ({
customAsciiArt,
version,
nightly,
authType,
model,
workingDirectory,
}) => {
const { columns: terminalWidth } = useTerminalSize();
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = shortAsciiLogo;
} else {
displayTitle = tinyAsciiLogo;
}
const displayLogo = customAsciiArt ?? shortAsciiLogo;
const logoWidth = getAsciiArtWidth(displayLogo);
const formattedAuthType = formatAuthType(authType);
const artWidth = getAsciiArtWidth(displayTitle);
// Calculate available space properly:
// First determine if logo can be shown, then use remaining space for path
const containerMarginX = 2; // marginLeft + marginRight on the outer container
const logoGap = 2; // Gap between logo and info panel
const infoPanelPaddingX = 1;
const infoPanelBorderWidth = 2; // left + right border
const infoPanelChromeWidth = infoPanelBorderWidth + infoPanelPaddingX * 2;
const minPathLength = 40; // Minimum readable path length
const minInfoPanelWidth = minPathLength + infoPanelChromeWidth;
const availableTerminalWidth = Math.max(
0,
terminalWidth - containerMarginX * 2,
);
// Check if we have enough space for logo + gap + minimum info panel
const showLogo =
availableTerminalWidth >= logoWidth + logoGap + minInfoPanelWidth;
// Calculate available width for info panel (use all remaining space)
const availableInfoPanelWidth = showLogo
? availableTerminalWidth - logoWidth - logoGap
: availableTerminalWidth;
// Calculate max path length (subtract padding/borders from available space)
const maxPathLength = Math.max(
0,
availableInfoPanelWidth - infoPanelChromeWidth,
);
const infoPanelContentWidth = Math.max(
0,
availableInfoPanelWidth - infoPanelChromeWidth,
);
const authModelText = `${formattedAuthType} | ${model}`;
const authHintText = ' (/auth to change)';
const showAuthHint =
infoPanelContentWidth > 0 &&
getCachedStringWidth(authModelText + authHintText) <= infoPanelContentWidth;
// Now shorten the path to fit the available space
const tildeifiedPath = tildeifyPath(workingDirectory);
const shortenedPath = shortenPath(tildeifiedPath, Math.max(3, maxPathLength));
const displayPath =
maxPathLength <= 0
? ''
: shortenedPath.length > maxPathLength
? shortenedPath.slice(0, maxPathLength)
: shortenedPath;
// Use theme gradient colors if available, otherwise use text colors (excluding primary)
const gradientColors = theme.ui.gradient || [
theme.text.secondary,
theme.text.link,
theme.text.accent,
];
return (
<Box
alignItems="flex-start"
width={artWidth}
flexShrink={0}
flexDirection="column"
flexDirection="row"
alignItems="center"
marginX={containerMarginX}
width={availableTerminalWidth}
>
{theme.ui.gradient ? (
<Gradient colors={theme.ui.gradient}>
<Text>{displayTitle}</Text>
</Gradient>
) : (
<Text>{displayTitle}</Text>
)}
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
{theme.ui.gradient ? (
<Gradient colors={theme.ui.gradient}>
<Text>v{version}</Text>
{/* Left side: ASCII logo (only if enough space) */}
{showLogo && (
<>
<Box flexShrink={0}>
<Gradient colors={gradientColors}>
<Text>{displayLogo}</Text>
</Gradient>
) : (
<Text>v{version}</Text>
)}
</Box>
</Box>
{/* Fixed gap between logo and info panel */}
<Box width={logoGap} />
</>
)}
{/* Right side: Info panel (flexible width) */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={infoPanelPaddingX}
flexGrow={1}
>
{/* Title line: >_ Qwen Code (v{version}) */}
<Text>
<Text bold color={theme.text.accent}>
&gt;_ Qwen Code
</Text>
<Text color={theme.text.secondary}> (v{version})</Text>
</Text>
{/* Empty line for spacing */}
<Text> </Text>
{/* Auth and Model line */}
<Text>
<Text color={theme.text.secondary}>{authModelText}</Text>
{showAuthHint && (
<Text color={theme.text.secondary}>{authHintText}</Text>
)}
</Text>
{/* Directory line */}
<Text color={theme.text.secondary}>{displayPath}</Text>
</Box>
</Box>
);
};

View File

@@ -12,15 +12,16 @@ import { t } from '../../i18n/index.js';
interface Help {
commands: readonly SlashCommand[];
width?: number;
}
export const Help: React.FC<Help> = ({ commands }) => (
export const Help: React.FC<Help> = ({ commands, width }) => (
<Box
flexDirection="column"
marginBottom={1}
borderColor={theme.border.default}
borderStyle="round"
padding={1}
width={width}
>
{/* Basics */}
<Text bold color={theme.text.primary}>

View File

@@ -38,6 +38,7 @@ interface HistoryItemDisplayProps {
item: HistoryItem;
availableTerminalHeight?: number;
terminalWidth: number;
mainAreaWidth?: number;
isPending: boolean;
isFocused?: boolean;
commands?: readonly SlashCommand[];
@@ -50,6 +51,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
item,
availableTerminalHeight,
terminalWidth,
mainAreaWidth,
isPending,
commands,
isFocused = true,
@@ -58,9 +60,16 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeightGemini,
}) => {
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
const contentWidth = terminalWidth - 4;
const boxWidth = mainAreaWidth || contentWidth;
return (
<Box flexDirection="column" key={itemForDisplay.id}>
<Box
flexDirection="column"
key={itemForDisplay.id}
marginLeft={2}
marginRight={2}
>
{/* Render standard message types */}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} />
@@ -75,7 +84,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'gemini_content' && (
@@ -85,7 +94,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
@@ -95,7 +104,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
@@ -105,7 +114,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'info' && (
@@ -118,25 +127,32 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<ErrorMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'about' && (
<AboutBox {...itemForDisplay.systemInfo} />
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
)}
{itemForDisplay.type === 'help' && commands && (
<Help commands={commands} />
<Help commands={commands} width={boxWidth} />
)}
{itemForDisplay.type === 'stats' && (
<StatsDisplay duration={itemForDisplay.duration} />
<StatsDisplay duration={itemForDisplay.duration} width={boxWidth} />
)}
{itemForDisplay.type === 'model_stats' && (
<ModelStatsDisplay width={boxWidth} />
)}
{itemForDisplay.type === 'tool_stats' && (
<ToolStatsDisplay width={boxWidth} />
)}
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
{itemForDisplay.type === 'quit' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
<SessionSummaryDisplay
duration={itemForDisplay.duration}
width={boxWidth}
/>
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={itemForDisplay.tools}
groupId={itemForDisplay.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
isFocused={isFocused}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
@@ -149,7 +165,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'extensions_list' && <ExtensionsList />}
{itemForDisplay.type === 'tools_list' && (
<ToolsList
terminalWidth={terminalWidth}
contentWidth={contentWidth}
tools={itemForDisplay.tools}
showDescriptions={itemForDisplay.showDescriptions}
/>

View File

@@ -52,6 +52,9 @@ export interface InputPromptProps {
setShellModeActive: (value: boolean) => void;
approvalMode: ApprovalMode;
onEscapePromptChange?: (showPrompt: boolean) => void;
onToggleShortcuts?: () => void;
showShortcuts?: boolean;
onSuggestionsVisibilityChange?: (visible: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
isEmbeddedShellFocused?: boolean;
}
@@ -96,6 +99,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setShellModeActive,
approvalMode,
onEscapePromptChange,
onToggleShortcuts,
showShortcuts,
onSuggestionsVisibilityChange,
vimHandleInput,
isEmbeddedShellFocused,
}) => {
@@ -338,11 +344,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.text === '' &&
!completion.showSuggestions
) {
// Hide shortcuts when toggling shell mode
if (showShortcuts && onToggleShortcuts) {
onToggleShortcuts();
}
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
}
// Toggle keyboard shortcuts display with "?" when buffer is empty
if (
key.sequence === '?' &&
buffer.text === '' &&
!completion.showSuggestions &&
onToggleShortcuts
) {
onToggleShortcuts();
return;
}
// Hide shortcuts on any other key press
if (showShortcuts && onToggleShortcuts) {
onToggleShortcuts();
}
if (keyMatchers[Command.ESCAPE](key)) {
const cancelSearch = (
setActive: (active: boolean) => void,
@@ -670,6 +696,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
recentPasteTime,
commandSearchActive,
commandSearchCompletion,
onToggleShortcuts,
showShortcuts,
],
);
@@ -689,6 +717,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const activeCompletion = getActiveCompletion();
const shouldShowSuggestions = activeCompletion.showSuggestions;
// Notify parent about suggestions visibility changes
useEffect(() => {
if (onSuggestionsVisibilityChange) {
onSuggestionsVisibilityChange(shouldShowSuggestions);
}
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
const showAutoAcceptStyling =
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
const showYoloStyling =
@@ -721,7 +756,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
borderLeft={false}
borderRight={false}
borderColor={borderColor}
paddingX={1}
>
<Text
color={statusColor ?? theme.text.accent}
@@ -852,7 +886,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box>
</Box>
{shouldShowSuggestions && (
<Box paddingRight={2}>
<Box marginLeft={2} marginRight={2}>
<SuggestionsDisplay
suggestions={activeCompletion.suggestions}
activeIndex={activeCompletion.activeSuggestionIndex}

View File

@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { t } from '../../i18n/index.js';
interface Shortcut {
key: string;
description: string;
}
// Platform-specific key mappings
const getNewlineKey = () =>
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
const getExternalEditorKey = () =>
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';
// Generate shortcuts with translations (called at render time)
const getShortcuts = (): Shortcut[] => [
{ key: '!', description: t('for shell mode') },
{ key: '/', description: t('for commands') },
{ key: '@', description: t('for file paths') },
{ key: 'esc esc', description: t('to clear input') },
{ key: 'shift+tab', description: t('to cycle approvals') },
{ key: 'ctrl+c', description: t('to quit') },
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
{ key: 'ctrl+l', description: t('to clear screen') },
{ key: 'ctrl+r', description: t('to search history') },
{ key: getPasteKey(), description: t('to paste images') },
{ key: getExternalEditorKey(), description: t('for external editor') },
];
const ShortcutItem: React.FC<{ shortcut: Shortcut }> = ({ shortcut }) => (
<Text color={theme.text.secondary}>
<Text color={theme.text.primary}>{shortcut.key}</Text>{' '}
{shortcut.description}
</Text>
);
// Layout constants
const COLUMN_GAP = 4;
const MARGIN_LEFT = 2;
const MARGIN_RIGHT = 2;
// Column distribution for different layouts (3+4+4 for 3 cols, 6+5 for 2 cols)
const COLUMN_SPLITS: Record<number, number[]> = {
3: [3, 4, 4],
2: [6, 5],
1: [11],
};
export const KeyboardShortcuts: React.FC = () => {
const { columns: terminalWidth } = useTerminalSize();
const shortcuts = getShortcuts();
// Helper to calculate width needed for a column layout
const getShortcutWidth = (shortcut: Shortcut) =>
shortcut.key.length + 1 + shortcut.description.length;
const calculateLayoutWidth = (splits: number[]): number => {
let startIndex = 0;
let totalWidth = 0;
splits.forEach((count, colIndex) => {
const columnItems = shortcuts.slice(startIndex, startIndex + count);
const columnWidth = Math.max(...columnItems.map(getShortcutWidth));
totalWidth += columnWidth;
if (colIndex < splits.length - 1) {
totalWidth += COLUMN_GAP;
}
startIndex += count;
});
return totalWidth;
};
// Calculate number of columns based on terminal width and actual content
const availableWidth = terminalWidth - MARGIN_LEFT - MARGIN_RIGHT;
const width3Col = calculateLayoutWidth(COLUMN_SPLITS[3]);
const width2Col = calculateLayoutWidth(COLUMN_SPLITS[2]);
const numColumns =
availableWidth >= width3Col ? 3 : availableWidth >= width2Col ? 2 : 1;
// Split shortcuts into columns using predefined distribution
const splits = COLUMN_SPLITS[numColumns];
const columns: Shortcut[][] = [];
let startIndex = 0;
for (const count of splits) {
columns.push(shortcuts.slice(startIndex, startIndex + count));
startIndex += count;
}
return (
<Box
flexDirection="row"
marginLeft={MARGIN_LEFT}
marginRight={MARGIN_RIGHT}
>
{columns.map((column, colIndex) => (
<Box
key={colIndex}
flexDirection="column"
marginRight={colIndex < numColumns - 1 ? COLUMN_GAP : 0}
>
{column.map((shortcut) => (
<ShortcutItem key={shortcut.key} shortcut={shortcut} />
))}
</Box>
))}
</Box>
);
};

View File

@@ -23,6 +23,7 @@ export const MainContent = () => {
const uiState = useUIState();
const {
pendingHistoryItems,
terminalWidth,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
@@ -36,7 +37,8 @@ export const MainContent = () => {
<AppHeader key="app-header" version={version} />,
...uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
@@ -57,7 +59,8 @@ export const MainContent = () => {
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}

View File

@@ -50,7 +50,13 @@ const StatRow: React.FC<StatRowProps> = ({
</Box>
);
export const ModelStatsDisplay: React.FC = () => {
interface ModelStatsDisplayProps {
width?: number;
}
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
width,
}) => {
const { stats } = useSessionStats();
const { models } = stats.metrics;
const activeModels = Object.entries(models).filter(
@@ -64,6 +70,7 @@ export const ModelStatsDisplay: React.FC = () => {
borderColor={theme.border.default}
paddingY={1}
paddingX={2}
width={width}
>
<Text color={theme.text.primary}>
{t('No API calls have been made in this session.')}
@@ -93,6 +100,7 @@ export const ModelStatsDisplay: React.FC = () => {
flexDirection="column"
paddingY={1}
paddingX={2}
width={width}
>
<Text bold color={theme.text.accent}>
{t('Model Stats For Nerds')}

View File

@@ -34,7 +34,7 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
text={plan}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
contentWidth={childWidth}
/>
</Box>
);

View File

@@ -14,6 +14,7 @@ export const QuittingDisplay = () => {
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight;
const { mainAreaWidth } = uiState;
if (!uiState.quittingMessages) {
return null;
@@ -28,6 +29,7 @@ export const QuittingDisplay = () => {
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={item}
isPending={false}
/>

View File

@@ -127,8 +127,8 @@ export function SessionPicker(props: SessionPickerProps) {
const { columns: width, rows: height } = useTerminalSize();
// Calculate box width (width + 6 for border padding)
const boxWidth = width + 6;
// Calculate box width (marginX={2})
const boxWidth = width - 4;
// Calculate visible items (same heuristic as before)
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
@@ -179,7 +179,7 @@ export function SessionPicker(props: SessionPickerProps) {
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
</Box>
{/* Session list */}
@@ -212,7 +212,7 @@ export function SessionPicker(props: SessionPickerProps) {
isLast={visibleIndex === picker.visibleSessions.length - 1}
showScrollUp={picker.showScrollUp}
showScrollDown={picker.showScrollDown}
maxPromptWidth={width}
maxPromptWidth={boxWidth - 6}
prefixChars={PREFIX_CHARS}
boldSelectedPrefix={false}
/>
@@ -223,7 +223,7 @@ export function SessionPicker(props: SessionPickerProps) {
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
</Box>
{/* Footer */}

View File

@@ -14,10 +14,12 @@ import { t } from '../../i18n/index.js';
interface SessionSummaryDisplayProps {
duration: string;
width: number;
}
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
width,
}) => {
const config = useConfig();
const { stats } = useSessionStats();
@@ -32,6 +34,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
width={width}
/>
{hasMessages && canResume && (
<Box marginTop={1}>

View File

@@ -1275,7 +1275,6 @@ describe('SettingsDialog', () => {
ui: {
hideWindowTitle: true,
hideTips: true,
showMemoryUsage: true,
showLineNumbers: true,
showCitations: true,
accessibility: {
@@ -1324,7 +1323,6 @@ describe('SettingsDialog', () => {
disableAutoUpdate: true,
},
ui: {
showMemoryUsage: true,
hideWindowTitle: false,
},
tools: {
@@ -1375,9 +1373,7 @@ describe('SettingsDialog', () => {
vimMode: true,
disableAutoUpdate: false,
},
ui: {
showMemoryUsage: true,
},
ui: {},
},
);
const onSelect = vi.fn();
@@ -1438,7 +1434,6 @@ describe('SettingsDialog', () => {
disableLoadingPhrases: true,
screenReader: true,
},
showMemoryUsage: true,
showLineNumbers: true,
},
general: {
@@ -1520,7 +1515,6 @@ describe('SettingsDialog', () => {
ui: {
hideWindowTitle: false,
hideTips: false,
showMemoryUsage: false,
showLineNumbers: false,
showCitations: false,
accessibility: {

View File

@@ -160,11 +160,13 @@ const ModelUsageTable: React.FC<{
interface StatsDisplayProps {
duration: string;
title?: string;
width?: number;
}
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration,
title,
width,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
@@ -213,6 +215,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
flexDirection="column"
paddingY={1}
paddingX={2}
width={width}
>
{renderTitle()}
<Box height={1} />

View File

@@ -42,7 +42,7 @@ export function SuggestionsDisplay({
}: SuggestionsDisplayProps) {
if (isLoading) {
return (
<Box paddingX={1} width={width}>
<Box width={width}>
<Text color="gray">Loading suggestions...</Text>
</Box>
);
@@ -70,7 +70,7 @@ export function SuggestionsDisplay({
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
return (
<Box flexDirection="column" paddingX={1} width={width}>
<Box flexDirection="column" width={width}>
{scrollOffset > 0 && <Text color={theme.text.primary}></Text>}
{visibleSuggestions.map((suggestion, index) => {

View File

@@ -258,7 +258,7 @@ def fibonacci(n):
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={diffHeight}
terminalWidth={colorizeCodeWidth}
contentWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>

View File

@@ -4,42 +4,33 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type Config } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
interface TipsProps {
config: Config;
}
const startupTips = [
'Use /compress when the conversation gets long to summarize history and free up context.',
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
'Use /bug to submit issues to the maintainers when something goes off.',
'Switch auth type quickly with /auth.',
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
] as const;
export const Tips: React.FC = () => {
const selectedTip = useMemo(() => {
const randomIndex = Math.floor(Math.random() * startupTips.length);
return startupTips[randomIndex];
}, []);
export const Tips: React.FC<TipsProps> = ({ config }) => {
const geminiMdFileCount = config.getGeminiMdFileCount();
return (
<Box flexDirection="column">
<Text color={theme.text.primary}>{t('Tips for getting started:')}</Text>
<Text color={theme.text.primary}>
{t('1. Ask questions, edit files, or run commands.')}
</Text>
<Text color={theme.text.primary}>
{t('2. Be specific for the best results.')}
</Text>
{geminiMdFileCount === 0 && (
<Text color={theme.text.primary}>
3. Create{' '}
<Text bold color={theme.text.accent}>
QWEN.md
</Text>{' '}
{t('files to customize your interactions with Qwen Code.')}
</Text>
)}
<Text color={theme.text.primary}>
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
<Text bold color={theme.text.accent}>
/help
</Text>{' '}
{t('for more information.')}
<Box marginLeft={2} marginRight={2}>
<Text color={theme.text.secondary}>
{t('Tips: ')}
{t(selectedTip)}
</Text>
</Box>
);

View File

@@ -53,7 +53,13 @@ const StatRow: React.FC<{
);
};
export const ToolStatsDisplay: React.FC = () => {
interface ToolStatsDisplayProps {
width?: number;
}
export const ToolStatsDisplay: React.FC<ToolStatsDisplayProps> = ({
width,
}) => {
const { stats } = useSessionStats();
const { tools } = stats.metrics;
const activeTools = Object.entries(tools.byName).filter(
@@ -67,6 +73,7 @@ export const ToolStatsDisplay: React.FC = () => {
borderColor={theme.border.default}
paddingY={1}
paddingX={2}
width={width}
>
<Text color={theme.text.primary}>
{t('No tool calls have been made in this session.')}
@@ -101,7 +108,7 @@ export const ToolStatsDisplay: React.FC = () => {
flexDirection="column"
paddingY={1}
paddingX={2}
width={70}
width={width}
>
<Text bold color={theme.text.accent}>
{t('Tool Stats For Nerds')}

View File

@@ -1,11 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `"...s/to/make/it/long (main*) no sandbox gemini-pro (100%)"`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" ? for shortcuts 0.1% used"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs) gemini-pro (100% context left)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs)"`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used"`;

View File

@@ -1,137 +1,137 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
"✦ Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
" ✦ Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;
exports[`<HistoryItemDisplay /> > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
" Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
" Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
"✦ Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
" ✦ Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;
exports[`<HistoryItemDisplay /> > should render a truncated gemini_content item 1`] = `
" Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
" Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;

View File

@@ -20,38 +20,38 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
! Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
* Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View File

@@ -1,85 +1,85 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 1 │
│ » Accepted: 1 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 100.0% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────
│ Tool Stats For Nerds
│ Tool Name Calls Success Rate Avg Duration
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ test-tool 1 100.0% 100ms
│ User Decision Summary
│ Total Reviewed Suggestions: 1
│ » Accepted: 1
│ » Rejected: 0
│ » Modified: 0
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ Overall Agreement Rate: 100.0%
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ tool-a 2 50.0% 100ms │
│ tool-b 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 3 │
│ » Accepted: 1 │
│ » Rejected: 1 │
│ » Modified: 1 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 33.3% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────
│ Tool Stats For Nerds
│ Tool Name Calls Success Rate Avg Duration
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ tool-a 2 50.0% 100ms
│ tool-b 1 100.0% 100ms
│ User Decision Summary
│ Total Reviewed Suggestions: 3
│ » Accepted: 1
│ » Rejected: 1
│ » Modified: 1
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ Overall Agreement Rate: 33.3%
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ long-named-tool-for-testi99999999 88.9% 1ms │
│ ng-wrapping-and-such 9 │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 222234566 │
│ » Accepted: 123456789 │
│ » Rejected: 98765432 │
│ » Modified: 12345 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 55.6% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────
│ Tool Stats For Nerds
│ Tool Name Calls Success Rate Avg Duration
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ long-named-tool-for-testi99999999 88.9% 1ms
│ ng-wrapping-and-such 9
│ User Decision Summary
│ Total Reviewed Suggestions: 222234566
│ » Accepted: 123456789
│ » Rejected: 98765432
│ » Modified: 12345
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ Overall Agreement Rate: 55.6%
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 0 │
│ » Accepted: 0 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: -- │
│ │
╰────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────
│ Tool Stats For Nerds
│ Tool Name Calls Success Rate Avg Duration
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ test-tool 1 100.0% 100ms
│ User Decision Summary
│ Total Reviewed Suggestions: 0
│ » Accepted: 0
│ » Rejected: 0
│ » Modified: 0
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ Overall Agreement Rate: --
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `

View File

@@ -35,7 +35,7 @@ index 0000000..e69de29
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.py"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);
@@ -63,7 +63,7 @@ index 0000000..e69de29
<DiffRenderer
diffContent={newFileDiffContent}
filename="test.unknown"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);
@@ -88,7 +88,7 @@ index 0000000..e69de29
`;
render(
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
<DiffRenderer diffContent={newFileDiffContent} contentWidth={80} />
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
@@ -115,7 +115,7 @@ index 0000001..0000002 100644
<DiffRenderer
diffContent={existingFileDiffContent}
filename="test.txt"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);
@@ -145,7 +145,7 @@ index 1234567..1234567 100644
<DiffRenderer
diffContent={noChangeDiff}
filename="file.txt"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);
@@ -156,7 +156,7 @@ index 1234567..1234567 100644
it('should handle empty diff content', () => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
<DiffRenderer diffContent="" contentWidth={80} />
</OverflowProvider>,
);
expect(lastFrame()).toContain('No diff content');
@@ -182,7 +182,7 @@ index 123..456 100644
<DiffRenderer
diffContent={diffWithGap}
filename="file.txt"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);
@@ -219,7 +219,7 @@ index abc..def 100644
<DiffRenderer
diffContent={diffWithSmallGap}
filename="file.txt"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);
@@ -291,7 +291,7 @@ index 123..789 100644
<DiffRenderer
diffContent={diffWithMultipleHunks}
filename="multi.js"
terminalWidth={terminalWidth}
contentWidth={terminalWidth}
availableTerminalHeight={height}
/>
</OverflowProvider>,
@@ -323,7 +323,7 @@ fileDiff Index: file.txt
<DiffRenderer
diffContent={newFileDiff}
filename="TEST"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);
@@ -353,7 +353,7 @@ fileDiff Index: Dockerfile
<DiffRenderer
diffContent={newFileDiff}
filename="Dockerfile"
terminalWidth={80}
contentWidth={80}
/>
</OverflowProvider>,
);

View File

@@ -84,7 +84,7 @@ interface DiffRendererProps {
filename?: string;
tabWidth?: number;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
theme?: Theme;
}
@@ -95,7 +95,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
filename,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight,
terminalWidth,
contentWidth,
theme,
}) => {
const screenReaderEnabled = useIsScreenReaderEnabled();
@@ -155,7 +155,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
addedContent,
language,
availableTerminalHeight,
terminalWidth,
contentWidth,
theme,
);
} else {
@@ -164,7 +164,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
filename,
tabWidth,
availableTerminalHeight,
terminalWidth,
contentWidth,
);
}
@@ -176,7 +176,7 @@ const renderDiffContent = (
filename: string | undefined,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
contentWidth: number,
) => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
@@ -238,7 +238,7 @@ const renderDiffContent = (
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
maxWidth={contentWidth}
key={key}
>
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
@@ -260,7 +260,7 @@ const renderDiffContent = (
acc.push(
<Box key={`gap-${index}`}>
<Text wrap="truncate" color={semanticTheme.text.secondary}>
{'═'.repeat(terminalWidth)}
{'═'.repeat(contentWidth)}
</Text>
</Box>,
);

View File

@@ -17,7 +17,7 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.error}>{prefix}</Text>
</Box>

View File

@@ -14,14 +14,14 @@ interface GeminiMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
@@ -38,7 +38,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
</Box>

View File

@@ -12,7 +12,7 @@ interface GeminiMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
}
/*
@@ -25,7 +25,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
contentWidth,
}) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
@@ -36,7 +36,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
);

View File

@@ -13,7 +13,7 @@ interface GeminiThoughtMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
}
/**
@@ -24,13 +24,13 @@ export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
@@ -39,7 +39,7 @@ export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>

View File

@@ -13,7 +13,7 @@ interface GeminiThoughtMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
}
/**
@@ -22,17 +22,17 @@ interface GeminiThoughtMessageContentProps {
*/
export const GeminiThoughtMessageContent: React.FC<
GeminiThoughtMessageContentProps
> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => {
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth} marginBottom={1}>
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>

View File

@@ -23,7 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.warning}>{prefix}</Text>
</Box>

View File

@@ -34,7 +34,7 @@ describe('ToolConfirmationMessage', () => {
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
contentWidth={80}
/>,
);
@@ -58,7 +58,7 @@ describe('ToolConfirmationMessage', () => {
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
contentWidth={80}
/>,
);
@@ -81,7 +81,7 @@ describe('ToolConfirmationMessage', () => {
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
contentWidth={80}
/>,
);
@@ -162,7 +162,7 @@ describe('ToolConfirmationMessage', () => {
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
contentWidth={80}
/>,
);
@@ -180,7 +180,7 @@ describe('ToolConfirmationMessage', () => {
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
contentWidth={80}
/>,
);
@@ -212,7 +212,7 @@ describe('ToolConfirmationMessage', () => {
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
contentWidth={80}
/>,
{
settings: {
@@ -235,7 +235,7 @@ describe('ToolConfirmationMessage', () => {
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
contentWidth={80}
/>,
{
settings: {

View File

@@ -31,7 +31,7 @@ export interface ToolConfirmationMessageProps {
config: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
compactMode?: boolean;
}
@@ -42,11 +42,10 @@ export const ToolConfirmationMessage: React.FC<
config,
isFocused = true,
availableTerminalHeight,
terminalWidth,
contentWidth,
compactMode = false,
}) => {
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
const settings = useSettings();
const preferredEditor = settings.merged.general?.preferredEditor as
@@ -226,7 +225,7 @@ export const ToolConfirmationMessage: React.FC<
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
contentWidth={contentWidth}
/>
);
} else if (confirmationDetails.type === 'exec') {
@@ -263,7 +262,7 @@ export const ToolConfirmationMessage: React.FC<
<Box paddingX={1} marginLeft={1}>
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(childWidth - 4, 1)}
maxWidth={Math.max(contentWidth, 1)}
>
<Box>
<Text color={theme.text.link}>{executionProps.command}</Text>
@@ -298,7 +297,7 @@ export const ToolConfirmationMessage: React.FC<
text={planProps.plan}
isPending={false}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
contentWidth={contentWidth}
/>
</Box>
);
@@ -397,7 +396,7 @@ export const ToolConfirmationMessage: React.FC<
}
return (
<Box flexDirection="column" padding={1} width={childWidth}>
<Box flexDirection="column" padding={1} width={contentWidth}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>

View File

@@ -83,7 +83,7 @@ describe('<ToolGroupMessage />', () => {
const baseProps = {
groupId: 1,
terminalWidth: 80,
contentWidth: 80,
isFocused: true,
};
@@ -244,7 +244,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
terminalWidth={40}
contentWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();

View File

@@ -19,7 +19,7 @@ interface ToolGroupMessageProps {
groupId: number;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
isFocused?: boolean;
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
@@ -30,7 +30,7 @@ interface ToolGroupMessageProps {
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
availableTerminalHeight,
terminalWidth,
contentWidth,
isFocused = true,
activeShellPtyId,
embeddedShellFocused,
@@ -58,9 +58,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: theme.border.default;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// This is a bit of a magic number, but it accounts for the border and
// marginLeft.
const innerWidth = terminalWidth - 4;
// account for border (2 chars) and padding (2 chars)
const innerWidth = contentWidth - 4;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
@@ -96,8 +95,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width="100%"
marginLeft={1}
width={contentWidth}
borderDimColor={
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
}
@@ -112,7 +110,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
<ToolMessage
{...tool}
availableTerminalHeight={availableTerminalHeightPerToolMessage}
terminalWidth={innerWidth}
contentWidth={innerWidth}
emphasis={
isConfirming
? 'high'
@@ -135,7 +133,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={innerWidth}
contentWidth={innerWidth}
/>
)}
{tool.outputFile && (

View File

@@ -105,7 +105,7 @@ describe('<ToolMessage />', () => {
description: 'A tool for testing',
resultDisplay: 'Test result',
status: ToolCallStatus.Success,
terminalWidth: 80,
contentWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
config: mockConfig,
@@ -241,7 +241,7 @@ describe('<ToolMessage />', () => {
description: 'Delegate task to subagent',
resultDisplay: subagentResultDisplay,
status: ToolCallStatus.Executing,
terminalWidth: 80,
contentWidth: 80,
callId: 'test-call-id-2',
confirmationDetails: undefined,
config: mockConfig,

View File

@@ -186,7 +186,7 @@ const StringResultRenderer: React.FC<{
text={displayData}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
contentWidth={childWidth}
/>
</Box>
);
@@ -215,13 +215,13 @@ const DiffResultRenderer: React.FC<{
diffContent={data.fileDiff}
filename={data.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
contentWidth={childWidth}
/>
);
export interface ToolMessageProps extends IndividualToolCallDisplay {
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
emphasis?: TextEmphasis;
renderOutputAsMarkdown?: boolean;
activeShellPtyId?: number | null;
@@ -235,7 +235,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
resultDisplay,
status,
availableTerminalHeight,
terminalWidth,
contentWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
activeShellPtyId,
@@ -291,6 +291,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
const innerWidth = contentWidth - STATUS_INDICATOR_WIDTH;
// Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly,
// we're forcing it to not render as markdown when the response is too long, it will fallback
@@ -299,8 +300,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
renderOutputAsMarkdown = false;
}
const childWidth = terminalWidth - 3; // account for padding.
// Use the custom hook to determine the display type
const displayRenderer = useResultDisplayRenderer(resultDisplay);
@@ -333,14 +332,14 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
<PlanResultRenderer
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={childWidth}
childWidth={innerWidth}
/>
)}
{displayRenderer.type === 'task' && config && (
<SubagentExecutionRenderer
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={childWidth}
childWidth={innerWidth}
config={config}
/>
)}
@@ -348,7 +347,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
<DiffResultRenderer
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={childWidth}
childWidth={innerWidth}
/>
)}
{displayRenderer.type === 'ansi' && (
@@ -362,7 +361,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
data={displayRenderer.data}
renderAsMarkdown={renderOutputAsMarkdown}
availableHeight={availableHeight}
childWidth={childWidth}
childWidth={innerWidth}
/>
)}
</Box>

View File

@@ -18,7 +18,7 @@ export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
const prefixWidth = 3;
return (
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>

View File

@@ -1,105 +1,108 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: o test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: o test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ? first-confirm - A tool for testing (high)
│MockConfirmation: Confirm first tool
│MockTool[tool-2]: ? second-confirm - A tool for testing (low)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ? first-confirm - A tool for testing (high) │
│MockConfirmation: Confirm first tool │
│MockTool[tool-2]: ? second-confirm - A tool for testing (low) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ read_file - Read a file (medium)
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium)
│MockTool[tool-3]: o write_file - Write to file (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ read_file - Read a file (medium) │
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │
│MockTool[tool-3]: o write_file - Write to file (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium)
│MockTool[tool-2]: o pending-tool - This tool is pending (medium)
│MockTool[tool-3]: x error-tool - This tool failed (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │
│MockTool[tool-2]: o pending-tool - This tool is pending (medium) │
│MockTool[tool-3]: x error-tool - This tool failed (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation (high)
│MockConfirmation: Are you sure you want to proceed?
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation │
│(high)
│MockConfirmation: Are you sure you want to proceed? │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium)
│MockTool[tool-2]: ✓ another-tool - Another tool (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │
│MockTool[tool-2]: ✓ another-tool - Another tool (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ very-long-tool-name-that-might-wrap - This is a very long description that
│might cause wrapping issues (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────╮
│MockTool[tool-123]: ✓
│very-long-tool-name-that-might-wrap -
│This is a very long description that │
│might cause wrapping issues (medium) │
╰──────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium)
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium)
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -172,7 +172,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
confirmationDetails={data.pendingConfirmation}
isFocused={true}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
contentWidth={childWidth - 4}
compactMode={true}
config={config}
/>
@@ -242,7 +242,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
config={config}
isFocused={true}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
contentWidth={childWidth - 4}
compactMode={true}
/>
</Box>

View File

@@ -33,11 +33,7 @@ const mockTools: ToolDefinition[] = [
describe('<ToolsList />', () => {
it('renders correctly with descriptions', () => {
const { lastFrame } = render(
<ToolsList
tools={mockTools}
showDescriptions={true}
terminalWidth={40}
/>,
<ToolsList tools={mockTools} showDescriptions={true} contentWidth={40} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -47,7 +43,7 @@ describe('<ToolsList />', () => {
<ToolsList
tools={mockTools}
showDescriptions={false}
terminalWidth={40}
contentWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();
@@ -55,7 +51,7 @@ describe('<ToolsList />', () => {
it('renders correctly with no tools', () => {
const { lastFrame } = render(
<ToolsList tools={[]} showDescriptions={true} terminalWidth={40} />,
<ToolsList tools={[]} showDescriptions={true} contentWidth={40} />,
);
expect(lastFrame()).toMatchSnapshot();
});

View File

@@ -14,15 +14,15 @@ import { t } from '../../../i18n/index.js';
interface ToolsListProps {
tools: readonly ToolDefinition[];
showDescriptions: boolean;
terminalWidth: number;
contentWidth: number;
}
export const ToolsList: React.FC<ToolsListProps> = ({
tools,
showDescriptions,
terminalWidth,
contentWidth,
}) => (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column">
<Text bold color={theme.text.primary}>
{t('Available Qwen Code CLI tools:')}
</Text>
@@ -38,7 +38,7 @@ export const ToolsList: React.FC<ToolsListProps> = ({
</Text>
{showDescriptions && tool.description && (
<MarkdownDisplay
terminalWidth={terminalWidth}
contentWidth={contentWidth}
text={tool.description}
isPending={false}
/>

View File

@@ -11,15 +11,13 @@ exports[`<ToolsList /> > renders correctly with descriptions 1`] = `
2. note use this tool wisely and be sure to consider how this tool interacts with word wrap.
3. important this tool is awesome.
- Test Tool Three (test-tool-three)
This is the third test tool.
"
This is the third test tool."
`;
exports[`<ToolsList /> > renders correctly with no tools 1`] = `
"Available Qwen Code CLI tools:
No tools available
"
No tools available"
`;
exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
@@ -27,6 +25,5 @@ exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
- Test Tool One
- Test Tool Two
- Test Tool Three
"
- Test Tool Three"
`;

View File

@@ -212,7 +212,6 @@ describe('useGeminiStream', () => {
geminiMdFileCount: 0,
alwaysSkipModificationConfirmation: false,
vertexai: false,
showMemoryUsage: false,
contextFileName: undefined,
getToolRegistry: vi.fn(
() => ({ getToolSchemaList: vi.fn(() => []) }) as any,

View File

@@ -6,19 +6,21 @@
import { useEffect, useState } from 'react';
const TERMINAL_PADDING_X = 8;
/**
* Returns the actual terminal size without any padding adjustments.
* Components should handle their own margins/padding as needed.
*/
export function useTerminalSize(): { columns: number; rows: number } {
const [size, setSize] = useState({
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
rows: process.stdout.rows || 20,
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
});
useEffect(() => {
function updateSize() {
setSize({
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
rows: process.stdout.rows || 20,
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
});
}

View File

@@ -12,24 +12,26 @@ import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
export const DefaultAppLayout: React.FC<{ width?: string }> = ({
width = '90%',
}) => {
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const { columns: terminalWidth } = useTerminalSize();
return (
<Box flexDirection="column" width={width}>
<Box flexDirection="column" width={terminalWidth}>
<MainContent />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
<Notifications />
{uiState.dialogsVisible ? (
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
<Box marginX={2} flexDirection="column" width={uiState.mainAreaWidth}>
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
</Box>
) : (
<Composer />
)}

View File

@@ -25,10 +25,12 @@ export const ScreenReaderAppLayout: React.FC = () => {
<MainContent />
</Box>
{uiState.dialogsVisible ? (
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
<Box marginX={2} flexDirection="column" width={uiState.mainAreaWidth}>
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
</Box>
) : (
<Composer />
)}

View File

@@ -12,7 +12,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
describe('<MarkdownDisplay />', () => {
const baseProps = {
isPending: false,
terminalWidth: 80,
contentWidth: 80,
availableTerminalHeight: 40,
};

View File

@@ -16,7 +16,7 @@ interface MarkdownDisplayProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
textColor?: string;
}
@@ -31,7 +31,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
contentWidth,
textColor = theme.text.primary,
}) => {
if (!text) return <></>;
@@ -79,7 +79,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
lang={codeBlockLang}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>,
);
inCodeBlock = false;
@@ -144,7 +144,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
key={`table-${contentBlocks.length}`}
headers={tableHeaders}
rows={tableRows}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>,
);
}
@@ -266,7 +266,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
lang={codeBlockLang}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>,
);
}
@@ -278,7 +278,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
key={`table-${contentBlocks.length}`}
headers={tableHeaders}
rows={tableRows}
terminalWidth={terminalWidth}
contentWidth={contentWidth}
/>,
);
}
@@ -293,7 +293,7 @@ interface RenderCodeBlockProps {
lang: string | null;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
contentWidth: number;
}
const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
@@ -301,7 +301,7 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
lang,
isPending,
availableTerminalHeight,
terminalWidth,
contentWidth,
}) => {
const settings = useSettings();
const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message
@@ -329,7 +329,7 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
truncatedContent.join('\n'),
lang,
availableTerminalHeight,
terminalWidth - CODE_BLOCK_PREFIX_PADDING,
contentWidth - CODE_BLOCK_PREFIX_PADDING,
undefined,
settings,
);
@@ -347,7 +347,7 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
fullContent,
lang,
availableTerminalHeight,
terminalWidth - CODE_BLOCK_PREFIX_PADDING,
contentWidth - CODE_BLOCK_PREFIX_PADDING,
undefined,
settings,
);
@@ -356,7 +356,7 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
<Box
paddingLeft={CODE_BLOCK_PREFIX_PADDING}
flexDirection="column"
width={terminalWidth}
width={contentWidth}
flexShrink={0}
>
{colorizedCode}
@@ -407,15 +407,15 @@ const RenderListItem = React.memo(RenderListItemInternal);
interface RenderTableProps {
headers: string[];
rows: string[][];
terminalWidth: number;
contentWidth: number;
}
const RenderTableInternal: React.FC<RenderTableProps> = ({
headers,
rows,
terminalWidth,
contentWidth,
}) => (
<TableRenderer headers={headers} rows={rows} terminalWidth={terminalWidth} />
<TableRenderer headers={headers} rows={rows} contentWidth={contentWidth} />
);
const RenderTable = React.memo(RenderTableInternal);

View File

@@ -12,7 +12,7 @@ import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js';
interface TableRendererProps {
headers: string[];
rows: string[][];
terminalWidth: number;
contentWidth: number;
}
/**
@@ -22,7 +22,7 @@ interface TableRendererProps {
export const TableRenderer: React.FC<TableRendererProps> = ({
headers,
rows,
terminalWidth,
contentWidth,
}) => {
// Calculate column widths using actual display width after markdown processing
const columnWidths = headers.map((header, index) => {
@@ -35,8 +35,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
// Ensure table fits within terminal width
const totalWidth = columnWidths.reduce((sum, width) => sum + width + 1, 1);
const scaleFactor =
totalWidth > terminalWidth ? terminalWidth / totalWidth : 1;
const scaleFactor = totalWidth > contentWidth ? contentWidth / totalWidth : 1;
const adjustedWidths = columnWidths.map((width) =>
Math.floor(width * scaleFactor),
);

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.7.1",
"version": "0.7.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -157,6 +157,25 @@ vi.mock('../services/gitService.js', () => {
return { GitService: GitServiceMock };
});
vi.mock('../skills/skill-manager.js', () => {
const SkillManagerMock = vi.fn();
SkillManagerMock.prototype.startWatching = vi
.fn()
.mockResolvedValue(undefined);
SkillManagerMock.prototype.stopWatching = vi.fn();
return { SkillManager: SkillManagerMock };
});
vi.mock('../subagents/subagent-manager.js', () => {
const SubagentManagerMock = vi.fn();
SubagentManagerMock.prototype.loadSessionSubagents = vi.fn();
SubagentManagerMock.prototype.addChangeListener = vi
.fn()
.mockReturnValue(() => {});
SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]);
return { SubagentManager: SubagentManagerMock };
});
vi.mock('../ide/ide-client.js', () => ({
IdeClient: {
getInstance: vi.fn().mockResolvedValue({

View File

@@ -290,7 +290,6 @@ export interface ConfigParameters {
userMemory?: string;
geminiMdFileCount?: number;
approvalMode?: ApprovalMode;
showMemoryUsage?: boolean;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
@@ -404,7 +403,7 @@ export class Config {
private toolRegistry!: ToolRegistry;
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
private skillManager: SkillManager | null = null;
private skillManager!: SkillManager;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
@@ -434,7 +433,6 @@ export class Config {
private sdkMode: boolean;
private geminiMdFileCount: number;
private approvalMode: ApprovalMode;
private readonly showMemoryUsage: boolean;
private readonly accessibility: AccessibilitySettings;
private readonly telemetrySettings: TelemetrySettings;
private readonly gitCoAuthor: GitCoAuthorSettings;
@@ -539,7 +537,6 @@ export class Config {
this.userMemory = params.userMemory ?? '';
this.geminiMdFileCount = params.geminiMdFileCount ?? 0;
this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
this.showMemoryUsage = params.showMemoryUsage ?? false;
this.accessibility = params.accessibility ?? {};
this.telemetrySettings = {
enabled: params.telemetry?.enabled ?? false,
@@ -672,10 +669,8 @@ export class Config {
}
this.promptRegistry = new PromptRegistry();
this.subagentManager = new SubagentManager(this);
if (this.getExperimentalSkills()) {
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
}
this.skillManager = new SkillManager(this);
await this.skillManager.startWatching();
// Load session subagents if they were provided before initialization
if (this.sessionSubagents.length > 0) {
@@ -1083,10 +1078,6 @@ export class Config {
this.approvalMode = mode;
}
getShowMemoryUsage(): boolean {
return this.showMemoryUsage;
}
getInputFormat(): 'text' | 'stream-json' {
return this.inputFormat;
}
@@ -1441,7 +1432,7 @@ export class Config {
return this.subagentManager;
}
getSkillManager(): SkillManager | null {
getSkillManager(): SkillManager {
return this.skillManager;
}

View File

@@ -270,28 +270,28 @@ export function createContentGeneratorConfig(
}
export async function createContentGenerator(
generatorConfig: ContentGeneratorConfig,
config: Config,
config: ContentGeneratorConfig,
gcConfig: Config,
isInitialAuth?: boolean,
): Promise<ContentGenerator> {
const validation = validateModelConfig(generatorConfig, false);
const validation = validateModelConfig(config, false);
if (!validation.valid) {
throw new Error(validation.errors.map((e) => e.message).join('\n'));
}
const authType = generatorConfig.authType;
if (!authType) {
throw new Error('ContentGeneratorConfig must have an authType');
}
let baseGenerator: ContentGenerator;
if (authType === AuthType.USE_OPENAI) {
if (config.authType === AuthType.USE_OPENAI) {
// Import OpenAIContentGenerator dynamically to avoid circular dependencies
const { createOpenAIContentGenerator } = await import(
'./openaiContentGenerator/index.js'
);
baseGenerator = createOpenAIContentGenerator(generatorConfig, config);
} else if (authType === AuthType.QWEN_OAUTH) {
// Always use OpenAIContentGenerator, logging is controlled by enableOpenAILogging flag
const generator = createOpenAIContentGenerator(config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
}
if (config.authType === AuthType.QWEN_OAUTH) {
// Import required classes dynamically
const { getQwenOAuthClient: getQwenOauthClient } = await import(
'../qwen/qwenOAuth2.js'
);
@@ -300,38 +300,44 @@ export async function createContentGenerator(
);
try {
// Get the Qwen OAuth client (now includes integrated token management)
// If this is initial auth, require cached credentials to detect missing credentials
const qwenClient = await getQwenOauthClient(
config,
gcConfig,
isInitialAuth ? { requireCachedCredentials: true } : undefined,
);
baseGenerator = new QwenContentGenerator(
qwenClient,
generatorConfig,
config,
);
// Create the content generator with dynamic token management
const generator = new QwenContentGenerator(qwenClient, config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}`,
);
}
} else if (authType === AuthType.USE_ANTHROPIC) {
}
if (config.authType === AuthType.USE_ANTHROPIC) {
const { createAnthropicContentGenerator } = await import(
'./anthropicContentGenerator/index.js'
);
baseGenerator = createAnthropicContentGenerator(generatorConfig, config);
} else if (
authType === AuthType.USE_GEMINI ||
authType === AuthType.USE_VERTEX_AI
const generator = createAnthropicContentGenerator(config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
}
if (
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI
) {
const { createGeminiContentGenerator } = await import(
'./geminiContentGenerator/index.js'
);
baseGenerator = createGeminiContentGenerator(generatorConfig, config);
} else {
throw new Error(
`Error creating contentGenerator: Unsupported authType: ${authType}`,
);
const generator = createGeminiContentGenerator(config, gcConfig);
return new LoggingContentGenerator(generator, gcConfig);
}
return new LoggingContentGenerator(baseGenerator, config, generatorConfig);
throw new Error(
`Error creating contentGenerator: Unsupported authType: ${config.authType}`,
);
}

View File

@@ -12,7 +12,6 @@ import type {
import { GenerateContentResponse } from '@google/genai';
import type { Config } from '../../config/config.js';
import type { ContentGenerator } from '../contentGenerator.js';
import { AuthType } from '../contentGenerator.js';
import { LoggingContentGenerator } from './index.js';
import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js';
import {
@@ -51,17 +50,14 @@ const convertGeminiResponseToOpenAISpy = vi
choices: [],
} as OpenAI.Chat.ChatCompletion);
const createConfig = (overrides: Record<string, unknown> = {}): Config => {
const configContent = {
authType: 'openai',
enableOpenAILogging: false,
...overrides,
};
return {
getContentGeneratorConfig: () => configContent,
getAuthType: () => configContent.authType as AuthType | undefined,
} as Config;
};
const createConfig = (overrides: Record<string, unknown> = {}): Config =>
({
getContentGeneratorConfig: () => ({
authType: 'openai',
enableOpenAILogging: false,
...overrides,
}),
}) as Config;
const createWrappedGenerator = (
generateContent: ContentGenerator['generateContent'],
@@ -128,17 +124,13 @@ describe('LoggingContentGenerator', () => {
),
vi.fn(),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
schemaCompliance: 'openapi_30' as const,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig(),
generatorConfig,
createConfig({
enableOpenAILogging: true,
openAILoggingDir: 'logs',
schemaCompliance: 'openapi_30',
}),
);
const request = {
@@ -233,15 +225,9 @@ describe('LoggingContentGenerator', () => {
vi.fn().mockRejectedValue(error),
vi.fn(),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig(),
generatorConfig,
createConfig({ enableOpenAILogging: true }),
);
const request = {
@@ -307,15 +293,9 @@ describe('LoggingContentGenerator', () => {
})(),
),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig(),
generatorConfig,
createConfig({ enableOpenAILogging: true }),
);
const request = {
@@ -365,15 +345,9 @@ describe('LoggingContentGenerator', () => {
})(),
),
);
const generatorConfig = {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
};
const generator = new LoggingContentGenerator(
wrapped,
createConfig(),
generatorConfig,
createConfig({ enableOpenAILogging: true }),
);
const request = {

View File

@@ -31,10 +31,7 @@ import {
logApiRequest,
logApiResponse,
} from '../../telemetry/loggers.js';
import type {
ContentGenerator,
ContentGeneratorConfig,
} from '../contentGenerator.js';
import type { ContentGenerator } from '../contentGenerator.js';
import { isStructuredError } from '../../utils/quotaErrorDetection.js';
import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js';
import { OpenAILogger } from '../../utils/openaiLogger.js';
@@ -53,11 +50,9 @@ export class LoggingContentGenerator implements ContentGenerator {
constructor(
private readonly wrapped: ContentGenerator,
private readonly config: Config,
generatorConfig: ContentGeneratorConfig,
) {
// Extract fields needed for initialization from passed config
// (config.getContentGeneratorConfig() may not be available yet during refreshAuth)
if (generatorConfig.enableOpenAILogging) {
const generatorConfig = this.config.getContentGeneratorConfig();
if (generatorConfig?.enableOpenAILogging) {
this.openaiLogger = new OpenAILogger(generatorConfig.openAILoggingDir);
this.schemaCompliance = generatorConfig.schemaCompliance;
}
@@ -94,7 +89,7 @@ export class LoggingContentGenerator implements ContentGenerator {
model,
durationMs,
prompt_id,
this.config.getAuthType(),
this.config.getContentGeneratorConfig()?.authType,
usageMetadata,
responseText,
),
@@ -131,7 +126,7 @@ export class LoggingContentGenerator implements ContentGenerator {
errorMessage,
durationMs,
prompt_id,
this.config.getAuthType(),
this.config.getContentGeneratorConfig()?.authType,
errorType,
errorStatus,
),

View File

@@ -235,7 +235,6 @@ export class SkillManager {
}
this.watchStarted = true;
await this.ensureUserSkillsDir();
await this.refreshCache();
this.updateWatchersFromCache();
}
@@ -487,14 +486,29 @@ export class SkillManager {
}
private updateWatchersFromCache(): void {
const watchTargets = new Set<string>(
(['project', 'user'] as const)
.map((level) => this.getSkillsBaseDir(level))
.filter((baseDir) => fsSync.existsSync(baseDir)),
);
const desiredPaths = new Set<string>();
for (const level of ['project', 'user'] as const) {
const baseDir = this.getSkillsBaseDir(level);
const parentDir = path.dirname(baseDir);
if (fsSync.existsSync(parentDir)) {
desiredPaths.add(parentDir);
}
if (fsSync.existsSync(baseDir)) {
desiredPaths.add(baseDir);
}
const levelSkills = this.skillsCache?.get(level) || [];
for (const skill of levelSkills) {
const skillDir = path.dirname(skill.filePath);
if (fsSync.existsSync(skillDir)) {
desiredPaths.add(skillDir);
}
}
}
for (const existingPath of this.watchers.keys()) {
if (!watchTargets.has(existingPath)) {
if (!desiredPaths.has(existingPath)) {
void this.watchers
.get(existingPath)
?.close()
@@ -508,7 +522,7 @@ export class SkillManager {
}
}
for (const watchPath of watchTargets) {
for (const watchPath of desiredPaths) {
if (this.watchers.has(watchPath)) {
continue;
}
@@ -543,16 +557,4 @@ export class SkillManager {
void this.refreshCache().then(() => this.updateWatchersFromCache());
}, 150);
}
private async ensureUserSkillsDir(): Promise<void> {
const baseDir = this.getSkillsBaseDir('user');
try {
await fs.mkdir(baseDir, { recursive: true });
} catch (error) {
console.warn(
`Failed to create user skills directory at ${baseDir}:`,
error,
);
}
}
}

View File

@@ -88,6 +88,28 @@ vi.mock('../core/nonInteractiveToolExecutor.js');
vi.mock('../ide/ide-client.js');
vi.mock('../core/client.js');
vi.mock('../skills/skill-manager.js', () => {
const SkillManagerMock = vi.fn();
SkillManagerMock.prototype.startWatching = vi
.fn()
.mockResolvedValue(undefined);
SkillManagerMock.prototype.stopWatching = vi.fn();
SkillManagerMock.prototype.addChangeListener = vi
.fn()
.mockReturnValue(() => {});
return { SkillManager: SkillManagerMock };
});
vi.mock('./subagent-manager.js', () => {
const SubagentManagerMock = vi.fn();
SubagentManagerMock.prototype.loadSessionSubagents = vi.fn();
SubagentManagerMock.prototype.addChangeListener = vi
.fn()
.mockReturnValue(() => {});
SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]);
return { SubagentManager: SubagentManagerMock };
});
async function createMockConfig(
toolRegistryMocks = {},
): Promise<{ config: Config; toolRegistry: ToolRegistry }> {

View File

@@ -53,7 +53,7 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
false, // canUpdateOutput
);
this.skillManager = config.getSkillManager()!;
this.skillManager = config.getSkillManager();
this.skillManager.addChangeListener(() => {
void this.refreshSkills();
});

View File

@@ -15,6 +15,8 @@ import {
resolveAndValidatePath,
unescapePath,
isSubpath,
shortenPath,
tildeifyPath,
} from './paths.js';
import type { Config } from '../config/config.js';
@@ -596,3 +598,175 @@ describe('resolveAndValidatePath', () => {
}
});
});
describe('tildeifyPath', () => {
it('replaces home directory with tilde', () => {
const homeDir = os.homedir();
const result = tildeifyPath(`${homeDir}/documents/file.txt`);
expect(result).toBe('~/documents/file.txt');
});
it('returns path unchanged if it does not start with home directory', () => {
const result = tildeifyPath('/var/log/app.log');
expect(result).toBe('/var/log/app.log');
});
it('handles exact home directory path', () => {
const homeDir = os.homedir();
const result = tildeifyPath(homeDir);
expect(result).toBe('~');
});
it('handles paths with home directory in the middle', () => {
const homeDir = os.homedir();
const result = tildeifyPath(`/mnt/backup${homeDir}/data`);
// Should not replace home dir in the middle
expect(result).toBe(`/mnt/backup${homeDir}/data`);
});
});
describe('shortenPath', () => {
const sep = path.sep;
const sepForRegex = sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
it('returns path unchanged if it is already short enough', () => {
expect(shortenPath('/short/path', 50)).toBe('/short/path');
expect(shortenPath('/a/b/c.txt', 100)).toBe('/a/b/c.txt');
});
it('returns path unchanged if length equals maxLen', () => {
const testPath = '/exact/length';
expect(shortenPath(testPath, testPath.length)).toBe(testPath);
});
it('shortens long paths by showing start and end with ellipsis in between', () => {
const longPath = `${sep}home${sep}user${sep}projects${sep}qwen-code${sep}packages${sep}core${sep}src${sep}file.ts`;
const result = shortenPath(longPath, 40);
// Should include root + first segment and ellipsis
expect(result).toContain(`${sep}home${sep}...${sep}`);
// Should end with file.ts
expect(result).toContain('file.ts');
// Should be within maxLen
expect(result.length).toBeLessThanOrEqual(40);
});
it('includes as many end segments as possible', () => {
const testPath = `${sep}home${sep}user${sep}workspace${sep}projects${sep}subdir${sep}file.txt`;
const result = shortenPath(testPath, 35);
// Should have: /home/.../subdir/file.txt (fitting as many end segments as possible)
expect(result).toContain('...');
expect(result).toContain('file.txt');
expect(result.length).toBeLessThanOrEqual(35);
});
it('shows all segments when they all fit after including ellipsis space', () => {
const testPath = `${sep}a${sep}b${sep}c${sep}d.txt`;
// This path is short, should not need ellipsis
const result = shortenPath(testPath, 50);
expect(result).toBe(testPath);
expect(result).not.toContain('...');
});
it('handles paths with single segment after root', () => {
const result = shortenPath(
'/verylongfilenamethatshouldbetruncated.txt',
20,
);
expect(result).toContain('...');
expect(result.length).toBeLessThanOrEqual(20);
});
it('handles paths with only root', () => {
expect(shortenPath('/', 10)).toBe('/');
expect(shortenPath('/', 1)).toBe('/');
});
it('handles very short maxLen values', () => {
const result = shortenPath('/home/user/file.txt', 5);
expect(result).toBe('/h...');
expect(result.length).toBe(5);
});
it('handles paths with two segments', () => {
const testPath = `${sep}home${sep}file.txt`;
const result = shortenPath(testPath, 10);
expect(result).toContain('...');
expect(result.length).toBeLessThanOrEqual(10);
});
it('preserves the root directory in shortened paths', () => {
const result = shortenPath(`${sep}a${sep}b${sep}c${sep}d${sep}e.txt`, 15);
expect(result.startsWith(sep)).toBe(true);
});
it('handles relative-looking paths correctly', () => {
// Note: shortenPath works with any string, but typically gets absolute paths
const result = shortenPath('very/long/relative/path/to/file.txt', 20);
expect(result).toContain('...');
expect(result.length).toBeLessThanOrEqual(20);
});
it('creates ellipsis only when segments are actually omitted', () => {
const shortPath = `${sep}a${sep}b${sep}c.txt`;
const result1 = shortenPath(shortPath, 100);
expect(result1).not.toContain('...');
const result2 = shortenPath(shortPath, 8);
expect(result2).toContain('...');
});
it('uses default maxLen of 80 when not specified', () => {
const longPath = Array(100).fill('a').join('');
const result = shortenPath(longPath);
expect(result.length).toBeLessThanOrEqual(80);
});
it('handles paths where even minimum representation is too long', () => {
const path1 = '/verylongdirectoryname/verylongfilename.txt';
const result = shortenPath(path1, 15);
// Should use simple truncation fallback
expect(result).toContain('...');
expect(result.length).toBeLessThanOrEqual(15);
});
it('correctly calculates length including ellipsis', () => {
const testPath = `${sep}home${sep}user${sep}workspace${sep}project${sep}src${sep}components${sep}app.tsx`;
const maxLen = 40;
const result = shortenPath(testPath, maxLen);
expect(result.length).toBeLessThanOrEqual(maxLen);
// If ellipsis is present, verify proper structure
if (result.includes('...')) {
const parts = result.split('...');
expect(parts.length).toBe(2);
expect(parts[0].length + 3 + parts[1].length).toBeLessThanOrEqual(maxLen);
}
});
it('maintains path separator consistency', () => {
const testPath = `${sep}a${sep}b${sep}c${sep}d${sep}e${sep}f.txt`;
const result = shortenPath(testPath, 20);
// All separators should be consistent
const separators = result.match(new RegExp(`\\${sep}`, 'g'));
if (separators) {
separators.forEach((s) => {
expect(s).toBe(sep);
});
}
});
it('example from documentation: /path/to/a/very/long/file.txt', () => {
const testPath = `${sep}path${sep}to${sep}a${sep}very${sep}long${sep}directory${sep}file.txt`;
const result = shortenPath(testPath, 35);
// Should show start and end with ellipsis
expect(result).toMatch(
new RegExp(`^${sepForRegex}path${sepForRegex}\\.\\.\\..+file\\.txt$`),
);
expect(result.length).toBeLessThanOrEqual(35);
});
});

View File

@@ -36,6 +36,7 @@ export function tildeifyPath(path: string): string {
/**
* Shortens a path string if it exceeds maxLen, prioritizing the start and end segments.
* Shows root + first segment + "..." + end segments when middle segments are omitted.
* Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt
*/
export function shortenPath(filePath: string, maxLen: number = 80): string {
@@ -43,65 +44,82 @@ export function shortenPath(filePath: string, maxLen: number = 80): string {
return filePath;
}
const separator = path.sep;
const ellipsis = '...';
// Simple fallback for very short maxLen
if (maxLen < 10) {
return filePath.substring(0, maxLen - 3) + ellipsis;
}
const parsedPath = path.parse(filePath);
const root = parsedPath.root;
const separator = path.sep;
// Get segments of the path *after* the root
const relativePath = filePath.substring(root.length);
const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments
const segments = relativePath.split(separator).filter((s) => s !== '');
// Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
if (segments.length <= 1) {
// Fall back to simple start/end truncation for very short paths or single segments
const keepLen = Math.floor((maxLen - 3) / 2);
// Ensure keepLen is not negative if maxLen is very small
if (keepLen <= 0) {
return filePath.substring(0, maxLen - 3) + '...';
// Handle edge cases: no segments or single segment
if (segments.length === 0) {
return root.length <= maxLen
? root
: root.substring(0, maxLen - 3) + ellipsis;
}
if (segments.length === 1) {
const full = root + segments[0];
if (full.length <= maxLen) {
return full;
}
const keepLen = Math.floor((maxLen - 3) / 2);
const start = full.substring(0, keepLen);
const end = full.substring(full.length - keepLen);
return `${start}${ellipsis}${end}`;
}
// For 2+ segments: build from start and end, insert "..." if there's a gap
const startPart = root + segments[0]; // Always include root and first segment
// Collect segments from the end, working backwards
const endSegments: string[] = [];
for (let i = segments.length - 1; i >= 1; i--) {
const segment = segments[i];
// Calculate what the total would be if we add this segment
const endPart = [segment, ...endSegments].join(separator);
const needsEllipsis = i > 1; // If we're not at segment[1], there's a gap
let candidateResult: string;
if (needsEllipsis) {
candidateResult = startPart + separator + ellipsis + separator + endPart;
} else {
candidateResult = startPart + separator + endPart;
}
if (candidateResult.length <= maxLen) {
endSegments.unshift(segment);
// If we've reached segment[1], we have all segments - return immediately
if (i === 1) {
return candidateResult;
}
} else {
break; // Can't add more segments
}
}
// Build final result
if (endSegments.length === 0) {
// Couldn't fit any end segments - use simple truncation
const keepLen = Math.floor((maxLen - 3) / 2);
const start = filePath.substring(0, keepLen);
const end = filePath.substring(filePath.length - keepLen);
return `${start}...${end}`;
return `${start}${ellipsis}${end}`;
}
const firstDir = segments[0];
const lastSegment = segments[segments.length - 1];
const startComponent = root + firstDir;
const endPartSegments: string[] = [];
// Base length: separator + "..." + lastDir
let currentLength = separator.length + lastSegment.length;
// Iterate backwards through segments (excluding the first one)
for (let i = segments.length - 2; i >= 0; i--) {
const segment = segments[i];
// Length needed if we add this segment: current + separator + segment
const lengthWithSegment = currentLength + separator.length + segment.length;
if (lengthWithSegment <= maxLen) {
endPartSegments.unshift(segment); // Add to the beginning of the end part
currentLength = lengthWithSegment;
} else {
break;
}
}
let result = endPartSegments.join(separator) + separator + lastSegment;
if (currentLength > maxLen) {
return result;
}
// Construct the final path
result = startComponent + separator + result;
// As a final check, if the result is somehow still too long
// truncate the result string from the beginning, prefixing with "...".
if (result.length > maxLen) {
return '...' + result.substring(result.length - maxLen - 3);
}
return result;
// We have some end segments but not all - there's a gap, insert ellipsis
return (
startPart + separator + ellipsis + separator + endSegments.join(separator)
);
}
/**

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.1",
"version": "0.7.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.7.1",
"version": "0.7.0",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {