mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(shell): enable PTY by default and various enhancements
### Shell & Interactive Terminal Improvements - PTY shell is now enabled by default instead of disabled - Improved shell output rendering, process termination, and added fallback warning - Background commands now properly capture subprocess PIDs on non-Windows ### Coding Plan Improvements - Simplified auth message, added /model tip, improved system info display - Reordered model list to prioritize glm-5, kimi-k2.5, MiniMax-M2.5 - Model selection is now preserved when updating if the model still exists ### Other Changes - Added shared symlink utility; debug logs now have latest alias - Unknown settings warnings go to debug log instead of user-facing warnings - Fixed subagent confirmation state detection - Removed debug UI from AgentCreationWizard Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
991ae9febc
commit
b48e3caa75
31 changed files with 729 additions and 314 deletions
|
|
@ -448,7 +448,7 @@ describe('Settings Loading and Merging', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should warn about unknown top-level keys in a v2 settings file', () => {
|
||||
it('should silently ignore unknown top-level keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
|
|
@ -466,13 +466,7 @@ describe('Settings Loading and Merging', () => {
|
|||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Unknown setting 'someUnknownKey' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(getSettingsWarnings(settings)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not warn for valid v2 container keys', () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
QWEN_DIR,
|
||||
getErrorMessage,
|
||||
Storage,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
|
|
@ -32,6 +33,8 @@ import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
|||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SETTINGS');
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
||||
|
|
@ -564,7 +567,7 @@ function getSettingsFileKeyWarnings(
|
|||
);
|
||||
}
|
||||
|
||||
// Unknown top-level keys.
|
||||
// Unknown top-level keys — log silently to debug output.
|
||||
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (key === SETTINGS_VERSION_KEY) {
|
||||
|
|
@ -577,8 +580,8 @@ function getSettingsFileKeyWarnings(
|
|||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||
debugLogger.warn(
|
||||
`Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -822,9 +822,9 @@ const SETTINGS_SCHEMA = {
|
|||
label: 'Interactive Shell (PTY)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Use node-pty for an interactive shell experience. Fallback to child_process still applies.',
|
||||
'Use node-pty for an interactive shell experience. Falls back to child_process if PTY is unavailable.',
|
||||
showInDialog: true,
|
||||
},
|
||||
pager: {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,42 @@ export function generateCodingPlanTemplate(
|
|||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan] glm-5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan] kimi-k2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[Bailian Coding Plan] qwen3-coder-plus',
|
||||
|
|
@ -106,42 +142,6 @@ export function generateCodingPlanTemplate(
|
|||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan] glm-5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan] kimi-k2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1457,6 +1457,10 @@ export default {
|
|||
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'{{region}}-Konfiguration erfolgreich aktualisiert.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Tipp: Verwenden Sie /model, um zwischen verfügbaren Coding Plan-Modellen zu wechseln.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1446,6 +1446,10 @@ export default {
|
|||
'New model configurations are available for {{region}}. Update now?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'{{region}} configuration updated successfully.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Tip: Use /model to switch between available Coding Plan models.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -964,6 +964,10 @@ export default {
|
|||
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'{{region}} の設定が正常に更新されました。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1451,6 +1451,10 @@ export default {
|
|||
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'Configuração do {{region}} atualizada com sucesso.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Dica: Use /model para alternar entre os modelos disponíveis do Coding Plan.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1461,6 +1461,10 @@ export default {
|
|||
'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'Конфигурация {{region}} успешно обновлена.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Совет: Используйте /model для переключения между доступными моделями Coding Plan.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1279,6 +1279,9 @@ export default {
|
|||
'{{region}} 有新的模型配置可用。是否立即更新?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。',
|
||||
'{{region}} configuration updated successfully.': '{{region}} 配置更新成功。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'提示:使用 /model 切换可用的 Coding Plan 模型。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ import type {
|
|||
InsightProgressCallback,
|
||||
} from '../types/StaticInsightTypes.js';
|
||||
|
||||
import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const logger = createDebugLogger('StaticInsightGenerator');
|
||||
import { updateSymlink, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class StaticInsightGenerator {
|
||||
private dataProcessor: DataProcessor;
|
||||
|
|
@ -54,40 +52,12 @@ export class StaticInsightGenerator {
|
|||
return outputPath;
|
||||
}
|
||||
|
||||
// Create or update the "latest" alias (symlink preferred, copy as fallback)
|
||||
private async updateLatestAlias(
|
||||
private async updateInsightSymlink(
|
||||
outputDir: string,
|
||||
targetPath: string,
|
||||
): Promise<void> {
|
||||
const latestPath = path.join(outputDir, 'insight.html');
|
||||
const relativeTarget = path.relative(outputDir, targetPath);
|
||||
|
||||
// Remove existing file/symlink if it exists
|
||||
try {
|
||||
await fs.unlink(latestPath);
|
||||
} catch {
|
||||
// File doesn't exist, ignore
|
||||
}
|
||||
|
||||
// Try symlink first (preferred - lightweight, always points to latest)
|
||||
try {
|
||||
await fs.symlink(relativeTarget, latestPath);
|
||||
logger.debug('Created insight symlink:', relativeTarget);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Failed to create insight symlink, falling back to copy:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: copy file (works everywhere, uses more disk space)
|
||||
try {
|
||||
await fs.copyFile(targetPath, latestPath);
|
||||
logger.debug('Created insight copy:', targetPath);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to create insight latest alias:', error);
|
||||
}
|
||||
await updateSymlink(latestPath, targetPath);
|
||||
}
|
||||
|
||||
// Generate the static insight HTML file
|
||||
|
|
@ -116,8 +86,7 @@ export class StaticInsightGenerator {
|
|||
// Write the HTML file
|
||||
await fs.writeFile(outputPath, html, 'utf-8');
|
||||
|
||||
// Update latest alias (symlink preferred, copy as fallback)
|
||||
await this.updateLatestAlias(outputDir, outputPath);
|
||||
await this.updateInsightSymlink(outputDir, outputPath);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -389,13 +389,24 @@ export const useAuthCommand = (
|
|||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.',
|
||||
{ region: t('Alibaba Cloud Coding Plan') },
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Hint about /model command
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Tip: Use /model to switch between available Coding Plan models.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Log success
|
||||
const authEvent = new AuthEvent(
|
||||
AuthType.USE_OPENAI,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -32,7 +32,7 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -53,7 +53,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -74,7 +74,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false* │
|
||||
│ ▼ │
|
||||
|
|
@ -95,7 +95,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in System) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -116,7 +116,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in Workspace) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -137,7 +137,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -200,7 +200,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE true* │
|
||||
│ ▼ │
|
||||
|
|
|
|||
|
|
@ -120,45 +120,6 @@ export function AgentCreationWizard({
|
|||
);
|
||||
}, [state.currentStep, state.generationMethod]);
|
||||
|
||||
const renderDebugContent = useCallback(() => {
|
||||
if (process.env['NODE_ENV'] !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderStyle="single" borderColor={theme.status.warning} padding={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.warning} bold>
|
||||
Debug Info:
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>Step: {state.currentStep}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Can Proceed: {state.canProceed ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Generating: {state.isGenerating ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>Location: {state.location}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Method: {state.generationMethod}
|
||||
</Text>
|
||||
{state.validationErrors.length > 0 && (
|
||||
<Text color={theme.status.error}>
|
||||
Errors: {state.validationErrors.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
state.currentStep,
|
||||
state.canProceed,
|
||||
state.isGenerating,
|
||||
state.location,
|
||||
state.generationMethod,
|
||||
state.validationErrors,
|
||||
]);
|
||||
|
||||
const renderStepFooter = useCallback(() => {
|
||||
const getNavigationInstructions = () => {
|
||||
// Special case: During generation in description input step, only show cancel option
|
||||
|
|
@ -331,7 +292,6 @@ export function AgentCreationWizard({
|
|||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderDebugContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -481,6 +481,111 @@ describe('useCodingPlanUpdates', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should show "model preserved" message when current model exists in new template', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'qwen3.5-plus',
|
||||
baseUrl: chinaConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
],
|
||||
};
|
||||
// Simulate the user's current model being one that exists in the new template
|
||||
mockConfig.getModel.mockReturnValue('qwen3.5-plus');
|
||||
mockConfig.refreshAuth.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show plain success message without "switched"
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: expect.stringContaining('updated successfully'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockAddItem).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: expect.stringContaining('switched'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Reset mock
|
||||
mockConfig.getModel.mockReturnValue('qwen-max');
|
||||
});
|
||||
|
||||
it('should show "model switched" message when current model is not in new template', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
version: 'old-version-hash',
|
||||
};
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'removed-model',
|
||||
baseUrl: chinaConfig.baseUrl,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
],
|
||||
};
|
||||
// The user's current model no longer exists in the new template
|
||||
mockConfig.getModel.mockReturnValue('removed-model');
|
||||
mockConfig.refreshAuth.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show "model switched" message
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: expect.stringContaining('switched'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Reset mock
|
||||
mockConfig.getModel.mockReturnValue('qwen-max');
|
||||
});
|
||||
|
||||
it('should handle update errors gracefully', async () => {
|
||||
mockSettings.merged.codingPlan = {
|
||||
region: CodingPlanRegion.CHINA,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export function useCodingPlanUpdates(
|
|||
/**
|
||||
* Execute the Coding Plan configuration update.
|
||||
* Removes old Coding Plan configs and replaces them with new ones from the template.
|
||||
* Preserves the user's current model selection if it still exists in the new template.
|
||||
* Uses the region from settings.codingPlan.region (defaults to CHINA).
|
||||
*/
|
||||
const executeUpdate = useCallback(
|
||||
|
|
@ -82,6 +83,12 @@ export function useCodingPlanUpdates(
|
|||
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
|
||||
] as Array<Record<string, unknown>>;
|
||||
|
||||
// Record the user's current model before the update
|
||||
const previousModel = config.getModel();
|
||||
const previousModelStillAvailable = newConfigs.some(
|
||||
(cfg) => cfg.id === previousModel,
|
||||
);
|
||||
|
||||
// Hot-reload model providers configuration first (in-memory only)
|
||||
const updatedModelProviders = {
|
||||
...(settings.merged.modelProviders as
|
||||
|
|
@ -112,12 +119,34 @@ export function useCodingPlanUpdates(
|
|||
|
||||
const activeModel = config.getModel();
|
||||
|
||||
if (previousModelStillAvailable && activeModel === previousModel) {
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t('{{region}} configuration updated successfully.', {
|
||||
region: t('Alibaba Cloud Coding Plan'),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t(
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
|
||||
{ region: t('Alibaba Cloud Coding Plan'), model: activeModel },
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t(
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
|
||||
{ region: t('Alibaba Cloud Coding Plan'), model: activeModel },
|
||||
'Tip: Use /model to switch between available Coding Plan models.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
type TrackedToolCall,
|
||||
type TrackedCompletedToolCall,
|
||||
type TrackedCancelledToolCall,
|
||||
type TrackedExecutingToolCall,
|
||||
type TrackedWaitingToolCall,
|
||||
} from './useReactToolScheduler.js';
|
||||
import { promises as fs } from 'node:fs';
|
||||
|
|
@ -358,6 +359,23 @@ export const useGeminiStream = (
|
|||
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
|
||||
return StreamingState.WaitingForConfirmation;
|
||||
}
|
||||
// Check if any executing subagent task has a pending confirmation
|
||||
if (
|
||||
toolCalls.some((tc) => {
|
||||
if (tc.status !== 'executing') return false;
|
||||
const liveOutput = (tc as TrackedExecutingToolCall).liveOutput;
|
||||
return (
|
||||
typeof liveOutput === 'object' &&
|
||||
liveOutput !== null &&
|
||||
'type' in liveOutput &&
|
||||
liveOutput.type === 'task_execution' &&
|
||||
'pendingConfirmation' in liveOutput &&
|
||||
liveOutput.pendingConfirmation != null
|
||||
);
|
||||
})
|
||||
) {
|
||||
return StreamingState.WaitingForConfirmation;
|
||||
}
|
||||
if (
|
||||
isResponding ||
|
||||
toolCalls.some(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export interface SystemInfo {
|
|||
export interface ExtendedSystemInfo extends SystemInfo {
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
apiKeyEnvKey?: string;
|
||||
gitCommit?: string;
|
||||
proxy?: string;
|
||||
}
|
||||
|
|
@ -154,12 +155,14 @@ export async function getExtendedSystemInfo(
|
|||
// For bug reports, use sandbox name without prefix
|
||||
const sandboxEnv = getSandboxEnv(true);
|
||||
|
||||
// Get base URL if using OpenAI auth
|
||||
const baseUrl =
|
||||
// Get base URL and apiKeyEnvKey if using OpenAI or Anthropic auth
|
||||
const contentGeneratorConfig =
|
||||
baseInfo.selectedAuthType === AuthType.USE_OPENAI ||
|
||||
baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC
|
||||
? context.services.config?.getContentGeneratorConfig()?.baseUrl
|
||||
? context.services.config?.getContentGeneratorConfig()
|
||||
: undefined;
|
||||
const baseUrl = contentGeneratorConfig?.baseUrl;
|
||||
const apiKeyEnvKey = contentGeneratorConfig?.apiKeyEnvKey;
|
||||
|
||||
// Get git commit info
|
||||
const gitCommit =
|
||||
|
|
@ -172,6 +175,7 @@ export async function getExtendedSystemInfo(
|
|||
sandboxEnv,
|
||||
memoryUsage,
|
||||
baseUrl,
|
||||
apiKeyEnvKey,
|
||||
gitCommit,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import type { ExtendedSystemInfo } from './systemInfo.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { isCodingPlanConfig } from '../constants/codingPlan.js';
|
||||
|
||||
/**
|
||||
* Field configuration for system information display
|
||||
|
|
@ -30,6 +31,7 @@ export function getSystemInfoFields(
|
|||
addField(fields, t('IDE Client'), info.ideClient);
|
||||
addField(fields, t('OS'), formatOs(info));
|
||||
addField(fields, t('Auth'), formatAuth(info));
|
||||
addField(fields, t('Base URL'), formatBaseUrl(info));
|
||||
addField(fields, t('Model'), info.modelVersion);
|
||||
addField(fields, t('Session ID'), info.sessionId);
|
||||
addField(fields, t('Sandbox'), info.sandboxEnv);
|
||||
|
|
@ -86,15 +88,34 @@ function formatAuth(info: ExtendedSystemInfo): string {
|
|||
if (!info.selectedAuthType) {
|
||||
return '';
|
||||
}
|
||||
const authType = formatAuthType(info.selectedAuthType);
|
||||
if (!info.baseUrl) {
|
||||
return authType;
|
||||
|
||||
if (isCodingPlanConfig(info.baseUrl, info.apiKeyEnvKey)) {
|
||||
return t('Alibaba Cloud Coding Plan');
|
||||
}
|
||||
return `${authType} (${info.baseUrl})`;
|
||||
|
||||
if (
|
||||
info.selectedAuthType.startsWith('oauth') ||
|
||||
info.selectedAuthType === 'qwen-oauth'
|
||||
) {
|
||||
return 'Qwen OAuth';
|
||||
}
|
||||
|
||||
return `API Key - ${info.selectedAuthType}`;
|
||||
}
|
||||
|
||||
function formatAuthType(authType: string): string {
|
||||
return authType.startsWith('oauth') ? 'OAuth' : authType;
|
||||
function formatBaseUrl(info: ExtendedSystemInfo): string {
|
||||
if (!info.selectedAuthType || !info.baseUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (
|
||||
info.selectedAuthType.startsWith('oauth') ||
|
||||
info.selectedAuthType === 'qwen-oauth'
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return info.baseUrl;
|
||||
}
|
||||
|
||||
function formatProxy(proxy?: string): string {
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ export class Config {
|
|||
this.webSearch = params.webSearch;
|
||||
this.useRipgrep = params.useRipgrep ?? true;
|
||||
this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true;
|
||||
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
|
||||
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? true;
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
|
||||
this.shellExecutionConfig = {
|
||||
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export * from './utils/paths.js';
|
|||
export * from './utils/schemaValidator.js';
|
||||
export * from './utils/errors.js';
|
||||
export * from './utils/debugLogger.js';
|
||||
export * from './utils/symlink.js';
|
||||
export * from './utils/getFolderStructure.js';
|
||||
export * from './utils/memoryDiscovery.js';
|
||||
export * from './utils/gitIgnoreParser.js';
|
||||
|
|
@ -287,6 +288,7 @@ export * from './utils/tool-utils.js';
|
|||
export * from './utils/workspaceContext.js';
|
||||
export * from './utils/yaml-parser.js';
|
||||
export * from './utils/jsonl-utils.js';
|
||||
export * from './utils/symlink.js';
|
||||
|
||||
// ============================================================================
|
||||
// OAuth & Authentication
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
|
|||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockGetPty = vi.hoisted(() => vi.fn());
|
||||
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
|
||||
const mockGetShellConfiguration = vi.hoisted(() =>
|
||||
vi.fn().mockReturnValue({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
}),
|
||||
);
|
||||
|
||||
// Top-level Mocks
|
||||
vi.mock('@lydell/node-pty', () => ({
|
||||
|
|
@ -54,6 +61,9 @@ vi.mock('../utils/getPty.js', () => ({
|
|||
vi.mock('../utils/terminalSerializer.js', () => ({
|
||||
serializeTerminalToObject: mockSerializeTerminalToObject,
|
||||
}));
|
||||
vi.mock('../utils/shell-utils.js', () => ({
|
||||
getShellConfiguration: mockGetShellConfiguration,
|
||||
}));
|
||||
|
||||
const mockProcessKill = vi
|
||||
.spyOn(process, 'kill')
|
||||
|
|
@ -410,15 +420,25 @@ describe('ShellExecutionService', () => {
|
|||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use cmd.exe on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
mockGetShellConfiguration.mockReturnValue({
|
||||
executable: 'cmd.exe',
|
||||
argsPrefix: ['/d', '/s', '/c'],
|
||||
shell: 'cmd',
|
||||
});
|
||||
await simulateExecution('dir "foo bar"', (pty) =>
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
|
||||
);
|
||||
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
'/c dir "foo bar"',
|
||||
['/d', '/s', '/c', 'dir "foo bar"'],
|
||||
expect.any(Object),
|
||||
);
|
||||
mockGetShellConfiguration.mockReturnValue({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use bash on Linux', async () => {
|
||||
|
|
@ -822,18 +842,28 @@ describe('ShellExecutionService child_process fallback', () => {
|
|||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use cmd.exe and hide window on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
mockGetShellConfiguration.mockReturnValue({
|
||||
executable: 'cmd.exe',
|
||||
argsPrefix: ['/d', '/s', '/c'],
|
||||
shell: 'cmd',
|
||||
});
|
||||
await simulateExecution('dir "foo bar"', (cp) =>
|
||||
cp.emit('exit', 0, null),
|
||||
);
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
['/c', 'dir "foo bar"'],
|
||||
['/d', '/s', '/c', 'dir "foo bar"'],
|
||||
expect.objectContaining({
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
}),
|
||||
);
|
||||
mockGetShellConfiguration.mockReturnValue({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use bash and detached process group on Linux', async () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import os from 'node:os';
|
|||
import type { IPty } from '@lydell/node-pty';
|
||||
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
|
||||
import { isBinary } from '../utils/textUtils.js';
|
||||
import { getShellConfiguration } from '../utils/shell-utils.js';
|
||||
import pkg from '@xterm/headless';
|
||||
import {
|
||||
serializeTerminalToObject,
|
||||
|
|
@ -223,14 +224,12 @@ export class ShellExecutionService {
|
|||
): ShellExecutionHandle {
|
||||
try {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const shell = isWindows ? 'cmd.exe' : 'bash';
|
||||
const shellArgs = isWindows
|
||||
? ['/c', commandToExecute]
|
||||
: ['-c', commandToExecute];
|
||||
const { executable, argsPrefix } = getShellConfiguration();
|
||||
const shellArgs = [...argsPrefix, commandToExecute];
|
||||
|
||||
// Note: CodeQL flags this as js/shell-command-injection-from-environment.
|
||||
// This is intentional - CLI tool executes user-provided shell commands.
|
||||
const child = cpSpawn(shell, shellArgs, {
|
||||
const child = cpSpawn(executable, shellArgs, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: isWindows,
|
||||
|
|
@ -419,13 +418,10 @@ export class ShellExecutionService {
|
|||
try {
|
||||
const cols = shellExecutionConfig.terminalWidth ?? 80;
|
||||
const rows = shellExecutionConfig.terminalHeight ?? 30;
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const shell = isWindows ? 'cmd.exe' : 'bash';
|
||||
const args = isWindows
|
||||
? `/c ${commandToExecute}`
|
||||
: ['-c', commandToExecute];
|
||||
const { executable, argsPrefix } = getShellConfiguration();
|
||||
const args = [...argsPrefix, commandToExecute];
|
||||
|
||||
const ptyProcess = ptyInfo.module.spawn(shell, args, {
|
||||
const ptyProcess = ptyInfo.module.spawn(executable, args, {
|
||||
cwd,
|
||||
name: 'xterm',
|
||||
cols,
|
||||
|
|
@ -435,6 +431,7 @@ export class ShellExecutionService {
|
|||
QWEN_CODE: '1',
|
||||
TERM: 'xterm-256color',
|
||||
PAGER: shellExecutionConfig.pager ?? 'cat',
|
||||
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
|
||||
},
|
||||
handleFlowControl: true,
|
||||
});
|
||||
|
|
@ -463,85 +460,107 @@ export class ShellExecutionService {
|
|||
let hasStartedOutput = false;
|
||||
let renderTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const render = (finalRender = false) => {
|
||||
if (renderTimeout) {
|
||||
clearTimeout(renderTimeout);
|
||||
const RENDER_THROTTLE_MS = 100;
|
||||
|
||||
const renderFn = () => {
|
||||
if (!isStreamingRawContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderFn = () => {
|
||||
if (!isStreamingRawContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shellExecutionConfig.disableDynamicLineTrimming) {
|
||||
if (!hasStartedOutput) {
|
||||
const bufferText = getFullBufferText(headlessTerminal);
|
||||
if (bufferText.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
hasStartedOutput = true;
|
||||
if (!shellExecutionConfig.disableDynamicLineTrimming) {
|
||||
if (!hasStartedOutput) {
|
||||
const bufferText = getFullBufferText(headlessTerminal);
|
||||
if (bufferText.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
hasStartedOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
let newOutput: AnsiOutput;
|
||||
if (shellExecutionConfig.showColor) {
|
||||
newOutput = serializeTerminalToObject(headlessTerminal);
|
||||
} else {
|
||||
const buffer = headlessTerminal.buffer.active;
|
||||
const lines: AnsiOutput = [];
|
||||
for (let y = 0; y < headlessTerminal.rows; y++) {
|
||||
const line = buffer.getLine(buffer.viewportY + y);
|
||||
const lineContent = line ? line.translateToString(true) : '';
|
||||
lines.push([
|
||||
{
|
||||
text: lineContent,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
fg: '',
|
||||
bg: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
newOutput = lines;
|
||||
}
|
||||
|
||||
let lastNonEmptyLine = -1;
|
||||
for (let i = newOutput.length - 1; i >= 0; i--) {
|
||||
const line = newOutput[i];
|
||||
if (
|
||||
line
|
||||
.map((segment) => segment.text)
|
||||
.join('')
|
||||
.trim().length > 0
|
||||
) {
|
||||
lastNonEmptyLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
|
||||
|
||||
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
|
||||
? newOutput
|
||||
: trimmedOutput;
|
||||
|
||||
// Using stringify for a quick deep comparison.
|
||||
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
|
||||
output = finalOutput;
|
||||
onOutputEvent({
|
||||
type: 'data',
|
||||
chunk: finalOutput,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (finalRender) {
|
||||
renderFn();
|
||||
let newOutput: AnsiOutput;
|
||||
if (shellExecutionConfig.showColor) {
|
||||
newOutput = serializeTerminalToObject(headlessTerminal);
|
||||
} else {
|
||||
renderTimeout = setTimeout(renderFn, 17);
|
||||
const buffer = headlessTerminal.buffer.active;
|
||||
const lines: AnsiOutput = [];
|
||||
for (let y = 0; y < headlessTerminal.rows; y++) {
|
||||
const line = buffer.getLine(buffer.viewportY + y);
|
||||
const lineContent = line ? line.translateToString(true) : '';
|
||||
lines.push([
|
||||
{
|
||||
text: lineContent,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
fg: '',
|
||||
bg: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
newOutput = lines;
|
||||
}
|
||||
|
||||
let lastNonEmptyLine = -1;
|
||||
for (let i = newOutput.length - 1; i >= 0; i--) {
|
||||
const line = newOutput[i];
|
||||
if (
|
||||
line
|
||||
.map((segment) => segment.text)
|
||||
.join('')
|
||||
.trim().length > 0
|
||||
) {
|
||||
lastNonEmptyLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
|
||||
|
||||
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
|
||||
? newOutput
|
||||
: trimmedOutput;
|
||||
|
||||
// Using stringify for a quick deep comparison.
|
||||
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
|
||||
output = finalOutput;
|
||||
onOutputEvent({
|
||||
type: 'data',
|
||||
chunk: finalOutput,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Throttle: render immediately on first call, then at most
|
||||
// once per RENDER_THROTTLE_MS during continuous output.
|
||||
// A trailing render is scheduled to ensure the final state
|
||||
// is always displayed.
|
||||
let pendingTrailingRender = false;
|
||||
|
||||
const render = (finalRender = false) => {
|
||||
if (finalRender) {
|
||||
if (renderTimeout) {
|
||||
clearTimeout(renderTimeout);
|
||||
renderTimeout = null;
|
||||
}
|
||||
renderFn();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderTimeout) {
|
||||
// No active throttle — render now and start throttle window
|
||||
renderFn();
|
||||
renderTimeout = setTimeout(() => {
|
||||
renderTimeout = null;
|
||||
if (pendingTrailingRender) {
|
||||
pendingTrailingRender = false;
|
||||
render();
|
||||
}
|
||||
}, RENDER_THROTTLE_MS);
|
||||
} else {
|
||||
// Throttled — mark that we need a trailing render
|
||||
pendingTrailingRender = true;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -610,7 +629,7 @@ export class ShellExecutionService {
|
|||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
|
||||
processingChain.then(() => {
|
||||
const finalize = () => {
|
||||
render(true);
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
|
|
@ -626,6 +645,18 @@ export class ShellExecutionService {
|
|||
(ptyInfo?.name as 'node-pty' | 'lydell-node-pty') ??
|
||||
'node-pty',
|
||||
});
|
||||
};
|
||||
|
||||
// Always try to flush pending terminal writes before
|
||||
// finalizing so result.output is as complete as possible.
|
||||
// Race against abort or a short timeout to avoid hanging.
|
||||
const processingComplete = processingChain.then(() => 'processed');
|
||||
const deadline = new Promise<'timeout'>((res) =>
|
||||
setTimeout(() => res('timeout'), SIGKILL_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
void Promise.race([processingComplete, deadline]).then(() => {
|
||||
finalize();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -636,11 +667,18 @@ export class ShellExecutionService {
|
|||
ptyProcess.kill();
|
||||
} else {
|
||||
try {
|
||||
// Kill the entire process group
|
||||
process.kill(-ptyProcess.pid, 'SIGINT');
|
||||
// Send SIGTERM first to allow graceful shutdown
|
||||
process.kill(-ptyProcess.pid, 'SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!exited) {
|
||||
// Escalate to SIGKILL if still running
|
||||
process.kill(-ptyProcess.pid, 'SIGKILL');
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to killing just the process if the group kill fails
|
||||
ptyProcess.kill('SIGINT');
|
||||
if (!exited) {
|
||||
ptyProcess.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -652,19 +690,28 @@ export class ShellExecutionService {
|
|||
return { pid: ptyProcess.pid, result };
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
return {
|
||||
pid: undefined,
|
||||
result: Promise.resolve({
|
||||
error,
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
aborted: false,
|
||||
if (error.message.includes('posix_spawnp failed')) {
|
||||
onOutputEvent({
|
||||
type: 'data',
|
||||
chunk:
|
||||
'[WARNING] PTY execution failed, falling back to child_process. This may be due to sandbox restrictions.\n',
|
||||
});
|
||||
throw e;
|
||||
} else {
|
||||
return {
|
||||
pid: undefined,
|
||||
executionMethod: 'none',
|
||||
}),
|
||||
};
|
||||
result: Promise.resolve({
|
||||
error,
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
aborted: false,
|
||||
pid: undefined,
|
||||
executionMethod: 'none',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -268,10 +268,10 @@ describe('ShellTool', () => {
|
|||
resolveExecutionPromise(fullResult);
|
||||
};
|
||||
|
||||
it('should wrap command on linux and parse pgrep output', async () => {
|
||||
it('should wrap background command on linux and parse pgrep output', async () => {
|
||||
const invocation = shellTool.build({
|
||||
command: 'my-command &',
|
||||
is_background: false,
|
||||
command: 'my-command',
|
||||
is_background: true,
|
||||
});
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({ pid: 54321 });
|
||||
|
|
@ -291,7 +291,7 @@ describe('ShellTool', () => {
|
|||
false,
|
||||
{},
|
||||
);
|
||||
expect(result.llmContent).toContain('Background PIDs: 54322');
|
||||
expect(result.llmContent).toContain('PIDs: 54322');
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
});
|
||||
|
||||
|
|
@ -353,15 +353,11 @@ describe('ShellTool', () => {
|
|||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({ pid: 54321 });
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
||||
|
||||
await promise;
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||
const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||
// Foreground commands should not be wrapped with pgrep
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
wrappedCommand,
|
||||
'npm test',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
|
|
@ -383,10 +379,9 @@ describe('ShellTool', () => {
|
|||
resolveShellExecution();
|
||||
await promise;
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||
const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||
// Foreground commands should not be wrapped with pgrep
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
wrappedCommand,
|
||||
'ls',
|
||||
'/test/dir/subdir',
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
|
|
@ -733,7 +728,6 @@ describe('ShellTool', () => {
|
|||
|
||||
await promise;
|
||||
|
||||
// On Linux, commands are wrapped with pgrep functionality
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm install'),
|
||||
expect.any(String),
|
||||
|
|
@ -762,7 +756,6 @@ describe('ShellTool', () => {
|
|||
|
||||
await promise;
|
||||
|
||||
// On Linux, commands are wrapped with pgrep functionality
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining('git commit'),
|
||||
expect.any(String),
|
||||
|
|
@ -828,7 +821,6 @@ describe('ShellTool', () => {
|
|||
|
||||
await promise;
|
||||
|
||||
// On Linux, commands are wrapped with pgrep functionality
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining('git commit -m "Initial commit"'),
|
||||
expect.any(String),
|
||||
|
|
|
|||
|
|
@ -181,15 +181,16 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||
finalCommand = finalCommand.trim().replace(/&+$/, '').trim();
|
||||
}
|
||||
|
||||
// pgrep is not available on Windows, so we can't get background PIDs
|
||||
const commandToExecute = isWindows
|
||||
? finalCommand
|
||||
: (() => {
|
||||
// wrap command to append subprocess pids (via pgrep) to temporary file
|
||||
let command = finalCommand.trim();
|
||||
if (!command.endsWith('&')) command += ';';
|
||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||
})();
|
||||
// On non-Windows background commands, wrap with pgrep to capture
|
||||
// subprocess PIDs so we can report them to the user.
|
||||
const commandToExecute =
|
||||
!isWindows && shouldRunInBackground
|
||||
? (() => {
|
||||
let command = finalCommand.trim();
|
||||
if (!command.endsWith('&')) command += ';';
|
||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||
})()
|
||||
: finalCommand;
|
||||
|
||||
const cwd = this.params.directory || this.config.getTargetDir();
|
||||
|
||||
|
|
@ -240,7 +241,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||
}
|
||||
},
|
||||
combinedSignal,
|
||||
this.config.getShouldUseNodePtyShell(),
|
||||
shouldRunInBackground
|
||||
? false
|
||||
: this.config.getShouldUseNodePtyShell(),
|
||||
shellExecutionConfig ?? {},
|
||||
);
|
||||
|
||||
|
|
@ -248,14 +251,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||
setPidCallback(pid);
|
||||
}
|
||||
|
||||
if (shouldRunInBackground) {
|
||||
// For background tasks, return immediately with PID info
|
||||
// Note: We cannot reliably detect startup errors for background processes
|
||||
// since their stdio is typically detached/ignored
|
||||
// On Windows, background commands rely on early return since there's
|
||||
// no & backgrounding or pgrep. Awaiting would block until completion.
|
||||
if (shouldRunInBackground && isWindows) {
|
||||
const pidMsg = pid ? ` PID: ${pid}` : '';
|
||||
const killHint = isWindows
|
||||
? ' (Use taskkill /F /T /PID <pid> to stop)'
|
||||
: ' (Use kill <pid> to stop)';
|
||||
const killHint = ' (Use taskkill /F /T /PID <pid> to stop)';
|
||||
|
||||
return {
|
||||
llmContent: `Background command started.${pidMsg}${killHint}`,
|
||||
|
|
@ -265,27 +265,42 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||
|
||||
const result = await resultPromise;
|
||||
|
||||
const backgroundPIDs: number[] = [];
|
||||
if (os.platform() !== 'win32') {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
const pgrepLines = fs
|
||||
.readFileSync(tempFilePath, 'utf8')
|
||||
.split(EOL)
|
||||
.filter(Boolean);
|
||||
for (const line of pgrepLines) {
|
||||
if (!/^\d+$/.test(line)) {
|
||||
debugLogger.warn(`pgrep: ${line}`);
|
||||
if (shouldRunInBackground) {
|
||||
// Read subprocess PIDs captured by the pgrep wrapper (non-Windows only)
|
||||
const backgroundPIDs: number[] = [];
|
||||
if (!isWindows) {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
const pgrepLines = fs
|
||||
.readFileSync(tempFilePath, 'utf8')
|
||||
.split(EOL)
|
||||
.filter(Boolean);
|
||||
for (const line of pgrepLines) {
|
||||
if (!/^\d+$/.test(line)) {
|
||||
debugLogger.warn(`pgrep: ${line}`);
|
||||
continue;
|
||||
}
|
||||
const bgPid = Number(line);
|
||||
if (bgPid !== result.pid) {
|
||||
backgroundPIDs.push(bgPid);
|
||||
}
|
||||
}
|
||||
const pid = Number(line);
|
||||
if (pid !== result.pid) {
|
||||
backgroundPIDs.push(pid);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!signal.aborted) {
|
||||
} else if (!signal.aborted) {
|
||||
debugLogger.warn('missing pgrep output');
|
||||
}
|
||||
}
|
||||
|
||||
const bgPidMsg =
|
||||
backgroundPIDs.length > 0
|
||||
? ` PIDs: ${backgroundPIDs.join(', ')}`
|
||||
: pid
|
||||
? ` PID: ${pid}`
|
||||
: '';
|
||||
const killHint = ' (Use kill <pid> to stop)';
|
||||
|
||||
return {
|
||||
llmContent: `Background command started.${bgPidMsg}${killHint}`,
|
||||
returnDisplay: `Background command started.${bgPidMsg}${killHint}`,
|
||||
};
|
||||
}
|
||||
|
||||
let llmContent = '';
|
||||
|
|
@ -327,9 +342,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||
`Error: ${finalError}`, // Use the cleaned error string.
|
||||
`Exit Code: ${result.exitCode ?? '(none)'}`,
|
||||
`Signal: ${result.signal ?? '(none)'}`,
|
||||
`Background PIDs: ${
|
||||
backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'
|
||||
}`,
|
||||
`Process Group PGID: ${result.pid ?? '(none)'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type DebugLogSession,
|
||||
} from './debugLogger.js';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Storage } from '../config/storage.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
|
|
@ -23,6 +24,9 @@ vi.mock('node:fs', async (importOriginal) => {
|
|||
...actual.promises,
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
appendFile: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
symlink: vi.fn().mockResolvedValue(undefined),
|
||||
copyFile: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -154,6 +158,7 @@ describe('debugLogger', () => {
|
|||
});
|
||||
|
||||
it('returns true when mkdir fails', async () => {
|
||||
resetDebugLoggingState();
|
||||
vi.mocked(fs.mkdir).mockRejectedValueOnce(new Error('Permission denied'));
|
||||
|
||||
const logger = createDebugLogger();
|
||||
|
|
@ -196,6 +201,55 @@ describe('debugLogger', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('latest debug log symlink', () => {
|
||||
const expectedLatestPath = path.join(Storage.getGlobalDebugDir(), 'latest');
|
||||
|
||||
it('creates a symlink to the current session log file', async () => {
|
||||
resetDebugLoggingState();
|
||||
setDebugLogSession(mockSession);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(expectedLatestPath);
|
||||
expect(fs.symlink).toHaveBeenCalledWith(
|
||||
'test-session-123.txt',
|
||||
expectedLatestPath,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create symlink when session is cleared', async () => {
|
||||
vi.clearAllMocks();
|
||||
resetDebugLoggingState();
|
||||
setDebugLogSession(null);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(fs.symlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fall back to copy when symlink fails', async () => {
|
||||
resetDebugLoggingState();
|
||||
vi.mocked(fs.symlink).mockRejectedValueOnce(new Error('EPERM'));
|
||||
|
||||
setDebugLogSession(mockSession);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(fs.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create symlink when debug logging is disabled', async () => {
|
||||
process.env['QWEN_DEBUG_LOG_FILE'] = '0';
|
||||
vi.clearAllMocks();
|
||||
resetDebugLoggingState();
|
||||
setDebugLogSession(mockSession);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(fs.symlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetDebugLoggingState', () => {
|
||||
it('resets the degraded state', async () => {
|
||||
vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full'));
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import util from 'node:util';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { updateSymlink } from './symlink.js';
|
||||
|
||||
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
|
|
@ -115,6 +117,23 @@ export function resetDebugLoggingState(): void {
|
|||
ensureDebugDirPromise = null;
|
||||
}
|
||||
|
||||
const DEBUG_LATEST_ALIAS = 'latest';
|
||||
|
||||
function updateLatestDebugLogAlias(sessionId: string): void {
|
||||
if (!isDebugLogFileEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aliasPath = path.join(Storage.getGlobalDebugDir(), DEBUG_LATEST_ALIAS);
|
||||
const targetPath = Storage.getDebugLogPath(sessionId);
|
||||
|
||||
void ensureDebugDirExists()
|
||||
.then(() => updateSymlink(aliasPath, targetPath, { fallbackCopy: false }))
|
||||
.catch(() => {
|
||||
// Best-effort; don't degrade overall logging
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the process-wide debug log session used by createDebugLogger().
|
||||
*
|
||||
|
|
@ -125,6 +144,9 @@ export function setDebugLogSession(
|
|||
session: DebugLogSession | null | undefined,
|
||||
) {
|
||||
globalSession = session ?? null;
|
||||
if (session) {
|
||||
updateLatestDebugLogAlias(session.getSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
60
packages/core/src/utils/symlink.ts
Normal file
60
packages/core/src/utils/symlink.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface UpdateSymlinkOptions {
|
||||
/**
|
||||
* When true, falls back to copying the file if symlinks are not
|
||||
* available (e.g. Windows without elevated privileges).
|
||||
* Disable this for targets that keep changing after creation (like log
|
||||
* files) where a one-time copy would be immediately stale.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
fallbackCopy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace a symlink at {@link linkPath} pointing to
|
||||
* {@link targetPath}.
|
||||
*
|
||||
* The symlink uses a relative target so it stays valid even when the
|
||||
* parent directory is moved.
|
||||
*
|
||||
* All errors are swallowed — the operation is strictly best-effort.
|
||||
*/
|
||||
export async function updateSymlink(
|
||||
linkPath: string,
|
||||
targetPath: string,
|
||||
options?: UpdateSymlinkOptions,
|
||||
): Promise<void> {
|
||||
const { fallbackCopy = true } = options ?? {};
|
||||
const linkDir = path.dirname(linkPath);
|
||||
const relativeTarget = path.relative(linkDir, targetPath);
|
||||
|
||||
try {
|
||||
await fs.unlink(linkPath);
|
||||
} catch {
|
||||
// File doesn't exist, ignore
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.symlink(relativeTarget, linkPath);
|
||||
return;
|
||||
} catch {
|
||||
// Symlink not supported, try fallback
|
||||
}
|
||||
|
||||
if (fallbackCopy) {
|
||||
try {
|
||||
await fs.copyFile(targetPath, linkPath);
|
||||
} catch {
|
||||
// Best-effort; swallow error
|
||||
}
|
||||
}
|
||||
}
|
||||
24
test-scripts/heartbeat.sh
Executable file
24
test-scripts/heartbeat.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
# Heartbeat script for testing interactive shell PTY behavior
|
||||
# - Emits a numbered line every second
|
||||
# - Writes to both stdout and a log file for verification
|
||||
# - Handles SIGTERM/SIGINT gracefully
|
||||
|
||||
LOG="/tmp/heartbeat_test.log"
|
||||
echo "started at $(date)" > "$LOG"
|
||||
|
||||
cleanup() {
|
||||
echo "received signal, shutting down" >> "$LOG"
|
||||
echo "[heartbeat] shutting down"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
i=1
|
||||
while true; do
|
||||
echo "[heartbeat] tick $i ($(date +%H:%M:%S))"
|
||||
echo "tick $i at $(date +%H:%M:%S)" >> "$LOG"
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
16
test-scripts/progress.sh
Executable file
16
test-scripts/progress.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
# Progress bar script that overwrites the same line using \r
|
||||
# Tests PTY's ability to handle carriage return / cursor movement
|
||||
|
||||
total=20
|
||||
for ((i = 1; i <= total; i++)); do
|
||||
pct=$((i * 100 / total))
|
||||
filled=$((pct / 5))
|
||||
empty=$((20 - filled))
|
||||
bar=$(printf '%0.s#' $(seq 1 $filled 2>/dev/null))
|
||||
space=$(printf '%0.s-' $(seq 1 $empty 2>/dev/null))
|
||||
printf "\r[%s%s] %3d%% (%d/%d)" "$bar" "$space" "$pct" "$i" "$total"
|
||||
sleep 0.5
|
||||
done
|
||||
echo ""
|
||||
echo "Done!"
|
||||
19
test-scripts/rapid-output.sh
Executable file
19
test-scripts/rapid-output.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/bash
|
||||
# Rapid output script for testing render throttle behavior
|
||||
# Outputs 200 lines as fast as possible, then pauses, then outputs more
|
||||
|
||||
echo "=== Phase 1: Rapid burst (200 lines) ==="
|
||||
for i in $(seq 1 200); do
|
||||
echo "line $i: $(date +%H:%M:%S.%N)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Phase 2: Pause 2s ==="
|
||||
sleep 2
|
||||
|
||||
echo "=== Phase 3: Rapid burst with progress overwrite ==="
|
||||
for i in $(seq 1 100); do
|
||||
printf "\rProcessing item %3d/100..." "$i"
|
||||
done
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
Loading…
Add table
Add a link
Reference in a new issue