Skip to content

Commit 4df95a8

Browse files
committed
v0.7.28
- Added /goal command and related features - File list supports multiple selection with spaces - Optimized some display effects and memory leak issues
1 parent acd59b1 commit 4df95a8

22 files changed

Lines changed: 1154 additions & 573 deletions

.github/workflows/publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ jobs:
5656
5757
### What's New
5858
59-
- Compatible with the .agents/skills/ directory
60-
- Expose all request scheme thinking configurations to StatusLine
61-
- Optimize the ace-file-outline tool
59+
- Added /goal command and related features
60+
- File list supports multiple selection with spaces
61+
- Optimized some display effects and memory leak issues
6262
6363
### Installation
6464
```bash

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "snow-ai",
3-
"version": "0.7.27",
3+
"version": "0.7.28",
44
"description": "Agentic coding in your terminal",
55
"license": "MIT",
66
"bin": {

source/cli.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,15 @@ import {spawn} from 'child_process';
168168
import {readFileSync} from 'fs';
169169
import {join} from 'path';
170170
import {fileURLToPath} from 'url';
171+
import {runLegacyConfigMigration} from './utils/config/legacyConfigMigration.js';
172+
173+
// Migrate legacy split .snow/*.json files into the unified settings.json before
174+
// anything else touches config. Safe no-op when nothing legacy is present.
175+
try {
176+
runLegacyConfigMigration();
177+
} catch {
178+
// Migration failures should never block startup.
179+
}
171180

172181
// Read version from package.json
173182
const __dirname = fileURLToPath(new URL('.', import.meta.url));

source/prompt/shared/promptHelpers.ts

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import fs from 'fs';
66
import path from 'path';
77
import os from 'os';
88
import {loadCodebaseConfig} from '../../utils/config/codebaseConfig.js';
9+
import {readSettings} from '../../utils/config/unifiedSettings.js';
910

1011
/**
1112
* Get the system prompt with ROLE.md content if it exists
@@ -39,25 +40,15 @@ export function getSystemPromptWithRole(
3940

4041
const getActiveRolePath = (location: 'project' | 'global'): string | null => {
4142
try {
43+
// ROLE.md / ROLE-<id>.md live in: project root (project scope) or ~/.snow (global scope).
44+
// activeRoleId is now stored in the unified settings.json (.snow/settings.json).
4245
const baseDir =
4346
location === 'project'
4447
? process.cwd()
4548
: path.join(os.homedir(), '.snow');
46-
const configPath =
47-
location === 'project'
48-
? path.join(baseDir, '.snow', 'role.json')
49-
: path.join(baseDir, 'role.json');
50-
51-
let activeRoleId: string | undefined;
52-
if (fs.existsSync(configPath)) {
53-
try {
54-
const raw = fs.readFileSync(configPath, 'utf-8');
55-
const parsed = JSON.parse(raw) as {activeRoleId?: string};
56-
activeRoleId = parsed.activeRoleId;
57-
} catch {
58-
// ignore
59-
}
60-
}
49+
50+
const settings = readSettings(location);
51+
const activeRoleId = settings.role?.activeRoleId;
6152

6253
if (!activeRoleId || activeRoleId === 'active') {
6354
return path.join(baseDir, 'ROLE.md');
@@ -242,30 +233,16 @@ export function getOverrideRoleContent(): string | null {
242233
location: 'project' | 'global',
243234
): {path: string; isOverride: boolean} | null => {
244235
try {
236+
// Role metadata moved to unified settings.json (.snow/settings.json).
245237
const baseDir =
246238
location === 'project'
247239
? process.cwd()
248240
: path.join(os.homedir(), '.snow');
249-
const configPath =
250-
location === 'project'
251-
? path.join(baseDir, '.snow', 'role.json')
252-
: path.join(baseDir, 'role.json');
253-
254-
let activeRoleId: string | undefined;
255-
let overrideRoleIds: string[] = [];
256-
if (fs.existsSync(configPath)) {
257-
try {
258-
const raw = fs.readFileSync(configPath, 'utf-8');
259-
const parsed = JSON.parse(raw) as {
260-
activeRoleId?: string;
261-
overrideRoleIds?: string[];
262-
};
263-
activeRoleId = parsed.activeRoleId;
264-
overrideRoleIds = parsed.overrideRoleIds || [];
265-
} catch {
266-
// ignore
267-
}
268-
}
241+
242+
const settings = readSettings(location);
243+
const roleSettings = settings.role ?? {};
244+
const activeRoleId = roleSettings.activeRoleId;
245+
const overrideRoleIds = roleSettings.overrideRoleIds || [];
269246

270247
const resolvedActiveId =
271248
!activeRoleId || activeRoleId === 'active' ? 'active' : activeRoleId;

source/ui/components/chat/ChatInput.tsx

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,30 @@ export default function ChatInput({
849849
return match[2] && match[2].length > 0 ? hint : ` ${hint}`;
850850
}, [buffer.text]);
851851

852+
// 当输入为以 `/cmd` 开头且 cmd 命中已注册指令时,计算高亮长度(含开头的 `/`)。
853+
// 用于在输入框中以主题色高亮“完整指令”片段,方便用户确认命令已被识别。
854+
const completedCommandLength = useMemo(() => {
855+
const text = buffer.text;
856+
if (!text.startsWith('/')) return 0;
857+
const match = text.match(/^\/([a-zA-Z0-9_-]+)(?:\s|$)/);
858+
if (!match) return 0;
859+
const cmd = match[1] ?? '';
860+
if (!cmd) return 0;
861+
const allCommands = getAllCommands();
862+
// 精确匹配(如 /help、/clear、/branch ...)
863+
const exact = allCommands.some(c => c.name === cmd);
864+
if (exact) return 1 + cmd.length;
865+
// 前缀型指令(如 agent-、todo-、skills-):cmd 以 `name` 开头且长度更长
866+
const prefixHit = allCommands.some(
867+
c =>
868+
c.name.endsWith('-') &&
869+
cmd.length > c.name.length &&
870+
cmd.startsWith(c.name),
871+
);
872+
if (prefixHit) return 1 + cmd.length;
873+
return 0;
874+
}, [buffer.text, getAllCommands]);
875+
852876
const renderContent = () => {
853877
if (buffer.text.length > 0) {
854878
// Use visual lines for proper wrapping and multi-line support
@@ -889,13 +913,114 @@ export default function ChatInput({
889913
);
890914
}
891915

916+
// 渲染单行内容的辅助函数:同时高亮
917+
// 1. 首行已识别的完整指令 `/cmd`
918+
// 2. 各类 `[...]` 占位符标签(Paste / image / Skill / GitLine / » running-agent)
919+
// 3. `#agent_xxx` 裸文本子代理标签(词边界上)
920+
const renderLineSegments = (line: string, isFirstLine: boolean) => {
921+
type Token = {text: string; highlight: boolean};
922+
const tokens: Token[] = [];
923+
let plainBuf = '';
924+
const flushPlain = () => {
925+
if (plainBuf) {
926+
tokens.push({text: plainBuf, highlight: false});
927+
plainBuf = '';
928+
}
929+
};
930+
931+
let i = 0;
932+
933+
// 1) 首行完整指令高亮
934+
if (
935+
isFirstLine &&
936+
completedCommandLength > 0 &&
937+
line.length >= completedCommandLength
938+
) {
939+
tokens.push({
940+
text: line.slice(0, completedCommandLength),
941+
highlight: true,
942+
});
943+
i = completedCommandLength;
944+
}
945+
946+
const isPlaceholderTag = (tag: string) =>
947+
/^\[Paste \d+ lines #\d+\]$/.test(tag) ||
948+
/^\[image #\d+\]$/.test(tag) ||
949+
/^\[Skill:[^\]]+\]$/.test(tag) ||
950+
/^\[GitLine:[^\]]+\]$/.test(tag) ||
951+
/^\[»[^\]]*\]$/.test(tag);
952+
953+
while (i < line.length) {
954+
const ch = line[i];
955+
956+
// 2) [...] 占位符标签
957+
if (ch === '[') {
958+
const closeIdx = line.indexOf(']', i + 1);
959+
if (closeIdx !== -1) {
960+
const tagText = line.slice(i, closeIdx + 1);
961+
if (isPlaceholderTag(tagText)) {
962+
flushPlain();
963+
// 标签本身高亮;可能紧跟一个分隔空格,空格不高亮
964+
tokens.push({text: tagText, highlight: true});
965+
i = closeIdx + 1;
966+
continue;
967+
}
968+
}
969+
}
970+
971+
// 3) #agent 裸文本标签:需要词边界(前面是行首或空白,后面是行末或空白)
972+
if (ch === '#') {
973+
const prevCh = i === 0 ? '' : line[i - 1] ?? '';
974+
const leftBoundary = i === 0 || /\s/.test(prevCh);
975+
if (leftBoundary) {
976+
const rest = line.slice(i);
977+
const m = rest.match(/^#[A-Za-z][\w-]*/);
978+
if (m) {
979+
const nextIdx = i + m[0].length;
980+
const nextCh = line[nextIdx];
981+
const rightBoundary = !nextCh || /\s/.test(nextCh);
982+
if (rightBoundary) {
983+
flushPlain();
984+
tokens.push({text: m[0], highlight: true});
985+
i = nextIdx;
986+
continue;
987+
}
988+
}
989+
}
990+
}
991+
992+
plainBuf += ch;
993+
i++;
994+
}
995+
flushPlain();
996+
997+
if (tokens.length === 0) {
998+
return <Text>{line || ' '}</Text>;
999+
}
1000+
1001+
return (
1002+
<>
1003+
{tokens.map((tok, idx) =>
1004+
tok.highlight ? (
1005+
<Text key={idx} color={theme.colors.menuInfo} bold>
1006+
{tok.text}
1007+
</Text>
1008+
) : (
1009+
<Text key={idx}>{tok.text}</Text>
1010+
),
1011+
)}
1012+
</>
1013+
);
1014+
};
1015+
8921016
for (let i = startLine; i < endLine; i++) {
8931017
const line = visualLines[i] || '';
1018+
const isFirstLine = i === 0;
8941019

8951020
if (i === cursorRow) {
8961021
renderedLines.push(
8971022
<Box key={i} flexDirection="row">
898-
<Text>{line || ' '}</Text>
1023+
{renderLineSegments(line, isFirstLine)}
8991024
{commandArgsHint && i === visualLines.length - 1 ? (
9001025
<Text color={theme.colors.menuSecondary} dimColor>
9011026
{commandArgsHint}
@@ -904,7 +1029,11 @@ export default function ChatInput({
9041029
</Box>,
9051030
);
9061031
} else {
907-
renderedLines.push(<Text key={i}>{line || ' '}</Text>);
1032+
renderedLines.push(
1033+
<Box key={i} flexDirection="row">
1034+
{renderLineSegments(line, isFirstLine)}
1035+
</Box>,
1036+
);
9081037
}
9091038
}
9101039

source/ui/components/panels/MCPInfoPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ export default function MCPInfoPanel({onClose}: Props) {
569569
{selectedServiceForTools.name === 'filesystem' && (
570570
<Text color={theme.colors.menuSecondary} dimColor>
571571
replaceedit: default off — Tab enables (writes
572-
.snow/opt-in-mcp-tools.json).
572+
.snow/settings.json optInMCPTools).
573573
</Text>
574574
)}
575575
</Box>

source/ui/pages/MCPConfigScreen.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import React, {useEffect, useState} from 'react';
22
import {Box, Text, useInput} from 'ink';
33
import {spawn, execSync} from 'child_process';
4-
import {writeFileSync, readFileSync, existsSync, mkdirSync} from 'fs';
4+
import {
5+
writeFileSync,
6+
readFileSync,
7+
existsSync,
8+
mkdirSync,
9+
unlinkSync,
10+
} from 'fs';
511
import {join} from 'path';
612
import {platform} from 'os';
713
import {
814
getGlobalMCPConfig,
915
getProjectMCPConfig,
10-
getGlobalMCPConfigFilePath,
16+
updateMCPConfig,
1117
validateMCPConfig,
1218
type MCPConfigScope,
1319
} from '../../utils/config/apiConfig.js';
@@ -76,11 +82,17 @@ function getSystemEditor(): string | null {
7682
return null;
7783
}
7884

85+
/**
86+
* The "config file" for MCP is now a section inside the unified `settings.json`.
87+
* For the in-IDE editor flow we keep using a sidecar draft file so the user can
88+
* still edit only the MCP portion — the parsed result is written back through
89+
* `updateMCPConfig` (see openEditorForScope below), which targets settings.json.
90+
*/
7991
function getConfigFilePath(scope: MCPConfigScope): string {
8092
if (scope === 'project') {
81-
return join(process.cwd(), '.snow', 'mcp-config.json');
93+
return join(process.cwd(), '.snow', 'mcp-config.draft.json');
8294
}
83-
return getGlobalMCPConfigFilePath();
95+
return join(process.cwd(), '.snow', 'mcp-config.global.draft.json');
8496
}
8597

8698
function getConfigByScope(scope: MCPConfigScope) {
@@ -162,13 +174,14 @@ function openEditorForScope(
162174
const validationErrors = validateMCPConfig(parsedConfig);
163175

164176
if (validationErrors.length === 0) {
177+
// Persist parsed MCP config back into the unified settings.json
178+
updateMCPConfig(parsedConfig, scope);
165179
const scopeLabel =
166180
scope === 'project'
167181
? i18nMessages.scopeProjectLabel
168182
: i18nMessages.scopeGlobalLabel;
169183
console.log(i18nMessages.savedSuccess.replace('{scope}', scopeLabel));
170184
} else {
171-
writeFileSync(configFilePath, originalContent, 'utf8');
172185
console.error(
173186
i18nMessages.configErrors.replace(
174187
'{errors}',
@@ -178,9 +191,16 @@ function openEditorForScope(
178191
console.error(i18nMessages.reverted);
179192
}
180193
} catch {
181-
writeFileSync(configFilePath, originalContent, 'utf8');
182194
console.error(i18nMessages.invalidJson);
183195
}
196+
197+
// The draft file only exists as a scratch area for the external editor.
198+
// Clean it up so it doesn't show up in the project tree.
199+
try {
200+
unlinkSync(configFilePath);
201+
} catch {
202+
// ignore cleanup errors
203+
}
184204
}
185205

186206
onBack();
@@ -207,12 +227,12 @@ export default function MCPConfigScreen({onBack}: Props) {
207227
const options: Array<{label: string; desc: string; scope: MCPConfigScope}> = [
208228
{
209229
label: t.mcpConfigScreen.scopeProject,
210-
desc: '.snow/mcp-config.json',
230+
desc: '.snow/settings.json (mcpServers)',
211231
scope: 'project',
212232
},
213233
{
214234
label: t.mcpConfigScreen.scopeGlobal,
215-
desc: '~/.snow/mcp-config.json',
235+
desc: '~/.snow/settings.json (mcpServers)',
216236
scope: 'global',
217237
},
218238
];

0 commit comments

Comments
 (0)