winter-super-cli 2026.5.24 → 2026.5.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/WINTER.md +6 -0
- package/bin/winter.js +77 -220
- package/package.json +1 -1
- package/resources/local/manifest.json +60 -57
- package/src/ai/providers.js +64 -13
- package/src/ai/providers.test.js +35 -0
- package/src/cli/commands.js +61 -3
- package/src/cli/commands.test.js +179 -0
- package/src/cli/config.js +12 -0
- package/src/cli/repl.js +475 -150
- package/src/cli/repl.test.js +234 -2
- package/src/cli/snowflake-logo.js +15 -7
- package/src/cli/terminal-ui.js +125 -0
- package/src/cli/terminal-ui.test.js +33 -0
- package/src/plugins/manager.js +3 -1
- package/src/session/manager.js +44 -0
- package/src/session/manager.test.js +72 -0
- package/src/tools/executor.js +1 -1
- package/src/tools/executor.test.js +110 -0
- package/resources/local/claude/settings.json +0 -33
- package/resources/local/claude/todos/022bdc3c-e2c0-4a20-a74f-b348ed022c75-agent-022bdc3c-e2c0-4a20-a74f-b348ed022c75.json +0 -1
- package/resources/local/claude/todos/316f0e7d-5512-49fa-8c7f-edc75b777612-agent-316f0e7d-5512-49fa-8c7f-edc75b777612.json +0 -1
- package/resources/local/claude/todos/3676dc17-fca1-4692-934b-ce35e1965af6-agent-3676dc17-fca1-4692-934b-ce35e1965af6.json +0 -1
- package/resources/local/claude/todos/464493de-7f2a-45cf-93e8-ad73214afa10-agent-464493de-7f2a-45cf-93e8-ad73214afa10.json +0 -1
- package/resources/local/claude/todos/51f2e7a7-3f31-4692-a9b2-d3f3906aafea-agent-51f2e7a7-3f31-4692-a9b2-d3f3906aafea.json +0 -1
- package/resources/local/claude/todos/64a67dce-3d62-4a98-a548-b9c91a8e87e8-agent-64a67dce-3d62-4a98-a548-b9c91a8e87e8.json +0 -1
- package/resources/local/claude/todos/727a06e6-0ac2-41ca-8b81-2c14e4d40182-agent-727a06e6-0ac2-41ca-8b81-2c14e4d40182.json +0 -1
- package/resources/local/claude/todos/7d34d296-9b5a-4525-9b68-600d2ae20b59-agent-7d34d296-9b5a-4525-9b68-600d2ae20b59.json +0 -1
- package/resources/local/claude/todos/8c0606f1-5bcc-4176-8125-c5174fd69002-agent-8c0606f1-5bcc-4176-8125-c5174fd69002.json +0 -1
- package/resources/local/claude/todos/905aab16-5225-43f6-8ae4-c94491fd3a6f-agent-905aab16-5225-43f6-8ae4-c94491fd3a6f.json +0 -1
- package/resources/local/claude/todos/9dbe93f0-d62c-4c12-b4eb-0eecc437d625-agent-9dbe93f0-d62c-4c12-b4eb-0eecc437d625.json +0 -1
- package/resources/local/claude/todos/ad48500f-02a5-4f18-970b-82fb595d171f-agent-ad48500f-02a5-4f18-970b-82fb595d171f.json +0 -1
- package/resources/local/claude/todos/af86ea71-9907-4066-907c-68055e6c0081-agent-af86ea71-9907-4066-907c-68055e6c0081.json +0 -1
- package/resources/local/claude/todos/dbb0dc16-5d71-4f1d-a56c-db0741b3d485-agent-dbb0dc16-5d71-4f1d-a56c-db0741b3d485.json +0 -1
- package/resources/local/claude/todos/ff1ac487-eb0f-4c63-9360-fbb0a81bb5ae-agent-ff1ac487-eb0f-4c63-9360-fbb0a81bb5ae.json +0 -1
- package/resources/local/codex/config.toml +0 -84
- package/resources/local/codex/memories/MEMORY.md +0 -972
- package/resources/local/codex/memories/extensions/ad_hoc/instructions.md +0 -13
- package/resources/local/codex/memories/memory_summary.md +0 -188
- package/resources/local/codex/memories/raw_memories.md +0 -1488
- package/resources/local/codex/memories/rollout_summaries/2026-03-27T04-05-14-Iirb-nsis_full_installer_build_cpp_ocr_translator.md +0 -46
- package/resources/local/codex/memories/rollout_summaries/2026-03-28T06-18-17-Si3U-my_translator_overlay_lockfix_portable_nsis.md +0 -112
- package/resources/local/codex/memories/rollout_summaries/2026-04-15T06-42-11-2JMi-qelasy_timeout_and_watch_control_stability.md +0 -90
- package/resources/local/codex/memories/rollout_summaries/2026-04-16T03-12-59-z6Wi-request_all_row_click_detail_navigation.md +0 -42
- package/resources/local/codex/memories/rollout_summaries/2026-04-17T05-49-03-tNBk-my_translator_project_readability_audio_latency_clear_button.md +0 -75
- package/resources/local/codex/memories/rollout_summaries/2026-04-21T04-05-04-EXnh-nsis_packaging_harfbuzz_dll_qml_runtime_debug.md +0 -108
- package/resources/local/codex/memories/rollout_summaries/2026-04-22T03-48-40-VnNG-openclaw_opencode_sync_and_runtime_repair.md +0 -86
- package/resources/local/codex/memories/rollout_summaries/2026-04-22T06-49-49-R8yZ-web_book_user_portal_and_lint_fixes.md +0 -82
- package/resources/local/codex/memories/rollout_summaries/2026-04-22T06-50-35-ZaS1-smoke_admin_rbac_refund_connection_refused.md +0 -35
- package/resources/local/codex/memories/rollout_summaries/2026-04-22T11-05-04-aotT-nextjs_build_fix_statswidget_leaflet_ssr.md +0 -78
- package/resources/local/codex/memories/rollout_summaries/2026-04-23T03-22-24-a5q4-ui_still_looks_cloudflare_only.md +0 -41
- package/resources/local/codex/memories/rollout_summaries/2026-04-23T04-35-47-amlb-bayre247_hero_slide_above_search_form.md +0 -49
- package/resources/local/codex/memories/rollout_summaries/2026-04-23T04-59-21-lZWv-ocr_backend_parity_easyocr_tesseract_paddle_fallback.md +0 -92
- package/resources/local/codex/memories/rollout_summaries/2026-04-23T07-36-22-tPuo-request_workflow_editor_drag_edge_smaller_arrows_roadmap.md +0 -72
- package/resources/local/codex/memories/rollout_summaries/2026-04-24T08-01-05-Gb3B-checkin_shifts_workdays_assignments_and_checkout_overhaul.md +0 -90
- package/resources/local/codex/memories/rollout_summaries/2026-04-25T03-39-02-mbDr-web_book_refund_admin_popup_pagination_responsiveness.md +0 -151
- package/resources/local/codex/memories/rollout_summaries/2026-04-25T09-20-30-4usS-tool_scv_9router_custom_provider_and_paddle_ocr.md +0 -130
- package/resources/local/codex/memories/rollout_summaries/2026-05-06T10-19-38-mt2X-find_db_config_in_web_book_app_env.md +0 -40
- package/resources/local/codex/memories/rollout_summaries/2026-05-06T11-10-23-TkwP-goirong_backend_title_crash_and_client_audio_tcp_tunnel_debu.md +0 -85
- package/resources/local/codex/memories/rollout_summaries/2026-05-09T07-52-18-On1F-chakra_git_cleanup_readme_bilingual_publish_config.md +0 -88
- package/resources/local/codex/memories/rollout_summaries/2026-05-11T08-05-34-oMEl-check_crack_gui_logo_onefile_build.md +0 -68
- package/resources/local/codex/memories/skills/windows-packaged-app-smoke-check/SKILL.md +0 -72
package/src/cli/repl.test.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
3
6
|
|
|
4
7
|
import { WinterREPL } from './repl.js';
|
|
5
8
|
|
|
@@ -23,6 +26,104 @@ test('resource paths point at bundled project resources', () => {
|
|
|
23
26
|
assert.equal(paths.designs, 'E:\\dev\\app\\winter\\resources\\local\\awesome-design-md\\design-md');
|
|
24
27
|
});
|
|
25
28
|
|
|
29
|
+
test('project context includes winter.md rules', async () => {
|
|
30
|
+
const root = await mkdtemp(path.join(tmpdir(), 'winter-context-'));
|
|
31
|
+
await writeFile(
|
|
32
|
+
path.join(root, 'winter.md'),
|
|
33
|
+
'# Winter Rules\n\n- Always use Vietnamese\n- Keep changes surgical\n'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const repl = new WinterREPL({ projectPath: root });
|
|
37
|
+
const context = await repl.getProjectContext();
|
|
38
|
+
|
|
39
|
+
assert.match(context, /\[winter\.md\]/);
|
|
40
|
+
assert.match(context, /Always use Vietnamese/);
|
|
41
|
+
assert.match(context, /Keep changes surgical/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('project context includes local resource manifest summary', async () => {
|
|
45
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
46
|
+
const context = await repl.getProjectContext();
|
|
47
|
+
|
|
48
|
+
assert.match(context, /\[Local Resources\]/);
|
|
49
|
+
assert.match(context, /agents\.md/);
|
|
50
|
+
assert.match(context, /awesome-design-md/);
|
|
51
|
+
assert.match(context, /codex/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('local resource context indexes Claude and Codex resource roots', async () => {
|
|
55
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
56
|
+
const context = await repl.getLocalResourceContext();
|
|
57
|
+
|
|
58
|
+
assert.match(context, /Claude skills/);
|
|
59
|
+
assert.match(context, /skill-creator/);
|
|
60
|
+
assert.match(context, /vercel-react-best-practices/);
|
|
61
|
+
assert.match(context, /Codex skills/);
|
|
62
|
+
assert.match(context, /vibefigma/);
|
|
63
|
+
assert.match(context, /Codex memories/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('inferStartupSkills promotes design skills for React-like projects', async () => {
|
|
67
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
68
|
+
repl.getStartupSkillCatalog = async () => new Set([
|
|
69
|
+
'coding',
|
|
70
|
+
'debug',
|
|
71
|
+
'refactor',
|
|
72
|
+
'test',
|
|
73
|
+
'design',
|
|
74
|
+
'web-design-guidelines',
|
|
75
|
+
'vercel-react-best-practices',
|
|
76
|
+
]);
|
|
77
|
+
repl.getProjectSignals = async () => ['react', 'next', 'tsx', 'ui'];
|
|
78
|
+
|
|
79
|
+
const snapshot = await repl.inferStartupSkills();
|
|
80
|
+
|
|
81
|
+
assert(snapshot.activeSkills.includes('coding'));
|
|
82
|
+
assert(snapshot.activeSkills.includes('web-design-guidelines'));
|
|
83
|
+
assert(snapshot.activeSkills.includes('vercel-react-best-practices'));
|
|
84
|
+
assert(snapshot.activeSkills.includes('design'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('bootstrapProjectCapabilities creates a startup plan and stores skills', async () => {
|
|
88
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
89
|
+
repl.getStartupSkillCatalog = async () => new Set(['coding', 'debug', 'refactor', 'test']);
|
|
90
|
+
repl.getProjectSignals = async () => ['node', 'cli'];
|
|
91
|
+
|
|
92
|
+
const contextStore = {};
|
|
93
|
+
const plans = [];
|
|
94
|
+
const memoryWrites = [];
|
|
95
|
+
repl.session = {
|
|
96
|
+
getContext: () => contextStore,
|
|
97
|
+
getPlans: () => plans,
|
|
98
|
+
createPlan: async (title, description) => {
|
|
99
|
+
const plan = { id: 'plan-1', title, description };
|
|
100
|
+
plans.push(plan);
|
|
101
|
+
return plan;
|
|
102
|
+
},
|
|
103
|
+
addPlanStep: async () => {},
|
|
104
|
+
updateContext: async (key, value) => {
|
|
105
|
+
contextStore[key] = value;
|
|
106
|
+
},
|
|
107
|
+
replaceMemory: async (prefix, content) => {
|
|
108
|
+
memoryWrites.push({ prefix, content });
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
await repl.bootstrapProjectCapabilities();
|
|
113
|
+
|
|
114
|
+
assert.equal(plans.length, 1);
|
|
115
|
+
assert.equal(contextStore.bootstrapPlan.title, 'Bootstrap project context');
|
|
116
|
+
assert.deepEqual(contextStore.activeSkills, ['coding', 'debug', 'refactor', 'test']);
|
|
117
|
+
assert.match(memoryWrites[0].content, /Auto-applied skills/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('shouldUseTools keeps agent mode enabled by default', () => {
|
|
121
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
122
|
+
|
|
123
|
+
assert.equal(repl.shouldUseTools('hello'), true);
|
|
124
|
+
assert.equal(repl.shouldUseTools('just chat'), true);
|
|
125
|
+
});
|
|
126
|
+
|
|
26
127
|
test('extractModelIdsFromCache reads model slugs without service tier ids', () => {
|
|
27
128
|
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
28
129
|
const raw = JSON.stringify({
|
|
@@ -41,6 +142,25 @@ test('extractModelIdsFromCache reads model slugs without service tier ids', () =
|
|
|
41
142
|
]);
|
|
42
143
|
});
|
|
43
144
|
|
|
145
|
+
test('system prompt compresses oversized memories and project context', () => {
|
|
146
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
147
|
+
repl.session = {
|
|
148
|
+
getSessionId: () => 'test-session',
|
|
149
|
+
getMemory: () => Array.from({ length: 24 }, (_, index) => ({
|
|
150
|
+
text: `Memory ${index + 1}: ${'x'.repeat(2500)}`,
|
|
151
|
+
})),
|
|
152
|
+
getPlans: () => [{ status: 'pending', title: 'Huge plan', description: 'y'.repeat(1200) }],
|
|
153
|
+
getContext: () => ({ activeSkills: ['coding', 'debug'], bootstrapPlan: { title: 'Bootstrap', description: 'Inspect everything' } }),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const prompt = repl.getSystemPrompt('Project context ' + 'z'.repeat(18000));
|
|
157
|
+
|
|
158
|
+
assert(prompt.length < 30000);
|
|
159
|
+
assert.match(prompt, /Memories \(Important Context\)/);
|
|
160
|
+
assert.match(prompt, /truncated/i);
|
|
161
|
+
assert.match(prompt, /project context truncated/i);
|
|
162
|
+
});
|
|
163
|
+
|
|
44
164
|
test('readCachedModels returns bundled cache model ids', async () => {
|
|
45
165
|
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
46
166
|
const models = await repl.readCachedModels(repl.getResourcePaths().codex.models);
|
|
@@ -49,10 +169,41 @@ test('readCachedModels returns bundled cache model ids', async () => {
|
|
|
49
169
|
assert(!models.includes('priority'));
|
|
50
170
|
});
|
|
51
171
|
|
|
52
|
-
test('
|
|
172
|
+
test('provider slash command switches and persists configured provider', async () => {
|
|
173
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
174
|
+
const saved = [];
|
|
175
|
+
repl.config = {
|
|
176
|
+
setDefaultProvider: async provider => saved.push(provider),
|
|
177
|
+
};
|
|
178
|
+
repl.ai = {
|
|
179
|
+
active: 'ollama',
|
|
180
|
+
providers: {
|
|
181
|
+
custom: { model: 'custom-model', ready: true },
|
|
182
|
+
ollama: { model: 'llama3', ready: true },
|
|
183
|
+
},
|
|
184
|
+
async switchProvider(name) {
|
|
185
|
+
if (!this.providers[name]) return null;
|
|
186
|
+
this.active = name;
|
|
187
|
+
return name;
|
|
188
|
+
},
|
|
189
|
+
getActiveProvider() {
|
|
190
|
+
return this.active;
|
|
191
|
+
},
|
|
192
|
+
listProviders() {
|
|
193
|
+
return Object.entries(this.providers).map(([name, provider]) => ({ name, ...provider }));
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
await repl.handleSlashCommand('/provider custom');
|
|
198
|
+
|
|
199
|
+
assert.equal(repl.ai.getActiveProvider(), 'custom');
|
|
200
|
+
assert.deepEqual(saved, ['custom']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('shouldUseTools keeps agent mode enabled by default', () => {
|
|
53
204
|
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
54
205
|
|
|
55
|
-
assert.equal(repl.shouldUseTools('trả lời đúng một từ: ok'),
|
|
206
|
+
assert.equal(repl.shouldUseTools('trả lời đúng một từ: ok'), true);
|
|
56
207
|
assert.equal(repl.shouldUseTools('sửa lỗi trong src/cli/repl.js rồi chạy test'), true);
|
|
57
208
|
assert.equal(repl.shouldUseTools('git push lên github đi'), true);
|
|
58
209
|
});
|
|
@@ -166,6 +317,87 @@ test('runConversation executes streamed tool calls then streams final answer', a
|
|
|
166
317
|
assert.deepEqual(executed, [{ name: 'Read', args: { file_path: 'README.md' } }]);
|
|
167
318
|
});
|
|
168
319
|
|
|
320
|
+
test('runConversation executes multiple tool calls across multiple turns before answering', async () => {
|
|
321
|
+
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
322
|
+
repl.simulateTyping = async (text) => {
|
|
323
|
+
process.stdout.write(text);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
let streamCount = 0;
|
|
327
|
+
const executed = [];
|
|
328
|
+
repl.ai = {
|
|
329
|
+
tools: [],
|
|
330
|
+
providers: { custom: { model: 'test-model' } },
|
|
331
|
+
getActiveProvider: () => 'custom',
|
|
332
|
+
setTools(tools) {
|
|
333
|
+
this.tools = tools;
|
|
334
|
+
},
|
|
335
|
+
async *streamRequest() {
|
|
336
|
+
streamCount++;
|
|
337
|
+
if (streamCount === 1) {
|
|
338
|
+
yield {
|
|
339
|
+
raw: {
|
|
340
|
+
choices: [{
|
|
341
|
+
delta: {
|
|
342
|
+
tool_calls: [{
|
|
343
|
+
index: 0,
|
|
344
|
+
id: 'call-read',
|
|
345
|
+
type: 'function',
|
|
346
|
+
function: { name: 'Read', arguments: '{"file_path":"README.md"}' },
|
|
347
|
+
}],
|
|
348
|
+
},
|
|
349
|
+
finish_reason: 'tool_calls',
|
|
350
|
+
}],
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (streamCount === 2) {
|
|
357
|
+
yield {
|
|
358
|
+
raw: {
|
|
359
|
+
choices: [{
|
|
360
|
+
delta: {
|
|
361
|
+
tool_calls: [{
|
|
362
|
+
index: 0,
|
|
363
|
+
id: 'call-grep',
|
|
364
|
+
type: 'function',
|
|
365
|
+
function: { name: 'Grep', arguments: '{"pattern":"Winter","path":"README.md"}' },
|
|
366
|
+
}],
|
|
367
|
+
},
|
|
368
|
+
finish_reason: 'tool_calls',
|
|
369
|
+
}],
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
yield { content: 'Finished', usage: { prompt_tokens: 6, completion_tokens: 2, total_tokens: 8 } };
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
repl.tools = {
|
|
379
|
+
normalizeToolName: name => name,
|
|
380
|
+
async execute(name, args) {
|
|
381
|
+
executed.push({ name, args });
|
|
382
|
+
if (name === 'Read') {
|
|
383
|
+
return { success: true, path: args.file_path, content: 'Winter CLI\n', lines: 1, size: 10 };
|
|
384
|
+
}
|
|
385
|
+
if (name === 'Grep') {
|
|
386
|
+
return { success: true, pattern: args.pattern, path: args.path, matches: ['README.md:1:Winter CLI'], count: 1 };
|
|
387
|
+
}
|
|
388
|
+
return { success: true };
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const answer = await repl.runConversation([{ role: 'user', content: 'analyze README and search it' }], 'Test', [{ name: 'Read' }, { name: 'Grep' }]);
|
|
393
|
+
|
|
394
|
+
assert.equal(answer, 'Finished');
|
|
395
|
+
assert.deepEqual(executed, [
|
|
396
|
+
{ name: 'Read', args: { file_path: 'README.md' } },
|
|
397
|
+
{ name: 'Grep', args: { pattern: 'Winter', path: 'README.md' } },
|
|
398
|
+
]);
|
|
399
|
+
});
|
|
400
|
+
|
|
169
401
|
test('runConversation reports malformed tool arguments instead of executing empty args', async () => {
|
|
170
402
|
const repl = new WinterREPL({ projectPath: process.cwd() });
|
|
171
403
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { renderBox, terminalWidth } from './terminal-ui.js';
|
|
2
|
+
|
|
1
3
|
export const colors = {
|
|
2
4
|
reset: '\x1b[0m',
|
|
3
5
|
bright: '\x1b[1m',
|
|
@@ -52,7 +54,6 @@ export function welcomeBanner(version, info = {}) {
|
|
|
52
54
|
// Tính toán chiều rộng động (nhỏ hơn 5% cửa sổ, tối thiểu 60, tối đa 100 cho đẹp)
|
|
53
55
|
const columns = process.stdout.columns || 80;
|
|
54
56
|
const W = Math.max(60, Math.min(Math.floor(columns * 0.95), 100));
|
|
55
|
-
const line = '═'.repeat(W);
|
|
56
57
|
const dot = `${colors.green}●${colors.reset}`;
|
|
57
58
|
|
|
58
59
|
// Căn giữa Snowflake Art
|
|
@@ -69,16 +70,23 @@ export function welcomeBanner(version, info = {}) {
|
|
|
69
70
|
const subtitle = `Build by Atus | fb: iam.anhtu | github: anhtu1707`;
|
|
70
71
|
const subPadding = Math.max(0, Math.floor((W - subtitle.length) / 2));
|
|
71
72
|
|
|
73
|
+
const infoWidth = Math.max(60, Math.min(terminalWidth(60, 100, 80), 100));
|
|
72
74
|
const banner = `${colors.cyan}${centeredArt}${colors.reset}
|
|
73
75
|
|
|
74
76
|
${' '.repeat(titlePadding)}${colors.bright}${colors.magenta}W I N T E R${colors.reset} ${colors.dim}v${version}${colors.reset}
|
|
75
77
|
${' '.repeat(subPadding)}${colors.dim}${subtitle}${colors.reset}
|
|
76
|
-
${
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
${renderBox({
|
|
79
|
+
title: '',
|
|
80
|
+
width: infoWidth,
|
|
81
|
+
borderColor: colors.blue,
|
|
82
|
+
titleColor: colors.blue,
|
|
83
|
+
body: [
|
|
84
|
+
`${dot} ${colors.cyan}Project:${colors.reset} ${colors.green}${displayPath}${colors.reset}`,
|
|
85
|
+
`${dot} ${colors.cyan}Model: ${colors.reset} ${model} ${colors.dim}(${provider})${colors.reset}`,
|
|
86
|
+
`${dot} ${colors.cyan}Session:${colors.reset} ${colors.yellow}${pId}${colors.reset}`,
|
|
87
|
+
`${colors.dim}Gõ ${colors.cyan}/help${colors.dim} để xem lệnh · ${colors.cyan}/auto${colors.dim} chế độ tự sửa · ${colors.cyan}ESC${colors.dim} để hủy${colors.reset}`,
|
|
88
|
+
],
|
|
89
|
+
})}
|
|
82
90
|
`;
|
|
83
91
|
return banner;
|
|
84
92
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
2
|
+
|
|
3
|
+
export function stripAnsi(text) {
|
|
4
|
+
return String(text ?? '').replace(ANSI_PATTERN, '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function visibleWidth(text) {
|
|
8
|
+
return Array.from(stripAnsi(text)).length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function terminalWidth(min = 72, max = 120, fallback = 88) {
|
|
12
|
+
const columns = process.stdout.columns || fallback;
|
|
13
|
+
return Math.max(min, Math.min(columns - 2, max));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function padVisible(text, width, fill = ' ') {
|
|
17
|
+
const visible = visibleWidth(text);
|
|
18
|
+
const padCount = Math.max(0, width - visible);
|
|
19
|
+
return `${text}${fill.repeat(padCount)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function wrapText(text, width) {
|
|
23
|
+
const output = [];
|
|
24
|
+
const lines = String(text ?? '').split(/\r?\n/);
|
|
25
|
+
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const plain = stripAnsi(line);
|
|
28
|
+
if (visibleWidth(plain) <= width) {
|
|
29
|
+
output.push(line);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const words = plain.split(/\s+/).filter(Boolean);
|
|
34
|
+
if (words.length === 0) {
|
|
35
|
+
output.push(plain.slice(0, width));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let current = '';
|
|
40
|
+
for (const word of words) {
|
|
41
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
42
|
+
if (visibleWidth(candidate) <= width) {
|
|
43
|
+
current = candidate;
|
|
44
|
+
} else {
|
|
45
|
+
if (current) output.push(current);
|
|
46
|
+
if (visibleWidth(word) > width) {
|
|
47
|
+
const chunks = chunkText(word, width);
|
|
48
|
+
output.push(...chunks.slice(0, -1));
|
|
49
|
+
current = chunks[chunks.length - 1] || '';
|
|
50
|
+
} else {
|
|
51
|
+
current = word;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (current) output.push(current);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return output;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function chunkText(text, width) {
|
|
62
|
+
const chars = Array.from(stripAnsi(text));
|
|
63
|
+
const chunks = [];
|
|
64
|
+
for (let i = 0; i < chars.length; i += width) {
|
|
65
|
+
chunks.push(chars.slice(i, i + width).join(''));
|
|
66
|
+
}
|
|
67
|
+
return chunks.length > 0 ? chunks : [''];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function renderBox({ title = '', body = [], width, borderColor = '\x1b[35m', titleColor = '\x1b[36m', reset = '\x1b[0m' }) {
|
|
71
|
+
const innerWidth = Math.max(28, (width || terminalWidth()) - 4);
|
|
72
|
+
const top = `${borderColor}╭${'─'.repeat(innerWidth)}╮${reset}`;
|
|
73
|
+
const bottom = `${borderColor}╰${'─'.repeat(innerWidth)}╯${reset}`;
|
|
74
|
+
const lines = [];
|
|
75
|
+
const titleText = title ? ` ${title} ` : '';
|
|
76
|
+
|
|
77
|
+
if (titleText) {
|
|
78
|
+
const wrappedTitle = wrapText(titleText, innerWidth);
|
|
79
|
+
wrappedTitle.forEach((segment, index) => {
|
|
80
|
+
const plainSegment = stripAnsi(segment);
|
|
81
|
+
const visible = visibleWidth(plainSegment);
|
|
82
|
+
const padding = Math.max(0, innerWidth - visible);
|
|
83
|
+
const left = index === 0 ? Math.floor(padding / 2) : 0;
|
|
84
|
+
const right = index === 0 ? padding - left : padding;
|
|
85
|
+
lines.push(`${borderColor}│${reset}${' '.repeat(left)}${titleColor}${plainSegment}${reset}${' '.repeat(right)}${borderColor}│${reset}`);
|
|
86
|
+
});
|
|
87
|
+
lines.push(`${borderColor}├${'─'.repeat(innerWidth)}┤${reset}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const item of body) {
|
|
91
|
+
const rawText = String(item ?? '');
|
|
92
|
+
if (visibleWidth(rawText) <= innerWidth) {
|
|
93
|
+
const visible = visibleWidth(rawText);
|
|
94
|
+
const padding = Math.max(0, innerWidth - visible);
|
|
95
|
+
lines.push(`${borderColor}│${reset} ${rawText}${' '.repeat(Math.max(0, padding - 1))}${borderColor}│${reset}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const wrapped = wrapText(rawText, innerWidth);
|
|
100
|
+
if (wrapped.length === 0) {
|
|
101
|
+
lines.push(`${borderColor}│${reset} ${' '.repeat(Math.max(0, innerWidth - 1))}${borderColor}│${reset}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const segment of wrapped) {
|
|
106
|
+
const text = stripAnsi(segment);
|
|
107
|
+
const visible = visibleWidth(text);
|
|
108
|
+
const padding = Math.max(0, innerWidth - visible);
|
|
109
|
+
lines.push(`${borderColor}│${reset} ${text}${' '.repeat(Math.max(0, padding - 1))}${borderColor}│${reset}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return [top, ...lines, bottom].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function renderKeyValueRows(rows, width, colors) {
|
|
117
|
+
const innerWidth = Math.max(28, (width || terminalWidth()) - 4);
|
|
118
|
+
return rows.map(([left, right]) => {
|
|
119
|
+
const leftWidth = Math.floor(innerWidth * 0.5);
|
|
120
|
+
const rightWidth = innerWidth - leftWidth - 1;
|
|
121
|
+
const leftText = padVisible(left, leftWidth);
|
|
122
|
+
const rightText = padVisible(right, rightWidth);
|
|
123
|
+
return `${colors.border}│${colors.reset} ${leftText}${colors.spacer}${rightText} ${colors.border}│${colors.reset}`;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { renderBox, stripAnsi, visibleWidth, wrapText } from './terminal-ui.js';
|
|
4
|
+
|
|
5
|
+
test('visibleWidth ignores ANSI styling', () => {
|
|
6
|
+
assert.equal(visibleWidth('\u001b[36mWinter\u001b[0m'), 6);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('wrapText splits long lines by visible width', () => {
|
|
10
|
+
assert.deepEqual(wrapText('winter cli is surprisingly compact', 10), [
|
|
11
|
+
'winter cli',
|
|
12
|
+
'is',
|
|
13
|
+
'surprising',
|
|
14
|
+
'ly compact',
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('renderBox keeps borders balanced', () => {
|
|
19
|
+
const box = renderBox({
|
|
20
|
+
title: 'Demo',
|
|
21
|
+
width: 40,
|
|
22
|
+
body: ['hello world'],
|
|
23
|
+
borderColor: '',
|
|
24
|
+
titleColor: '',
|
|
25
|
+
reset: '',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const lines = box.split('\n');
|
|
29
|
+
assert.equal(stripAnsi(lines[0]).startsWith('╭'), true);
|
|
30
|
+
assert.equal(stripAnsi(lines[lines.length - 1]).startsWith('╰'), true);
|
|
31
|
+
assert.equal(stripAnsi(lines[1]).includes('Demo'), true);
|
|
32
|
+
assert.equal(stripAnsi(lines[3]).includes('hello world'), true);
|
|
33
|
+
});
|
package/src/plugins/manager.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { promises as fs } from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { homedir } from 'os';
|
|
9
|
+
import { pathToFileURL } from 'url';
|
|
9
10
|
import { colors, statusIcons } from '../cli/snowflake-logo.js';
|
|
10
11
|
|
|
11
12
|
export class PluginManager {
|
|
@@ -61,7 +62,8 @@ export class PluginManager {
|
|
|
61
62
|
for (const file of files) {
|
|
62
63
|
if (file.endsWith('.js')) {
|
|
63
64
|
try {
|
|
64
|
-
const
|
|
65
|
+
const pluginPath = pathToFileURL(path.join(this.pluginsDir, file)).href;
|
|
66
|
+
const plugin = await import(pluginPath);
|
|
65
67
|
plugins.push({
|
|
66
68
|
name: plugin.default?.name || file.replace('.js', ''),
|
|
67
69
|
version: plugin.default?.version || '1.0.0',
|
package/src/session/manager.js
CHANGED
|
@@ -33,6 +33,7 @@ export class SessionManager {
|
|
|
33
33
|
if (options.sessionId) {
|
|
34
34
|
const success = await this.loadSession(options.sessionId);
|
|
35
35
|
if (success) {
|
|
36
|
+
await this.rememberProject(this.currentSession?.project || options.project || process.cwd());
|
|
36
37
|
this.initialized = true;
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
@@ -41,6 +42,7 @@ export class SessionManager {
|
|
|
41
42
|
|
|
42
43
|
// Luôn tạo session mới nếu không yêu cầu load hoặc load thất bại
|
|
43
44
|
await this.newSession(options);
|
|
45
|
+
await this.rememberProject(options.project || this.currentSession?.project || process.cwd());
|
|
44
46
|
this.initialized = true;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -86,6 +88,8 @@ export class SessionManager {
|
|
|
86
88
|
this.plans = session.plans;
|
|
87
89
|
this.memory = session.memory;
|
|
88
90
|
|
|
91
|
+
await this.rememberProject(session.project || options.project || process.cwd());
|
|
92
|
+
|
|
89
93
|
// Update current session pointer
|
|
90
94
|
const currentPath = path.join(this.sessionsDir, 'active', 'current.json');
|
|
91
95
|
await fs.writeFile(currentPath, JSON.stringify({ id: sessionId }));
|
|
@@ -148,6 +152,27 @@ export class SessionManager {
|
|
|
148
152
|
await this.saveSession();
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
// Backwards-compatible alias used by other modules
|
|
156
|
+
async addMemory(text, type = 'info') {
|
|
157
|
+
return this.addToMemory(text, type);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Replace previous memory entries that start with a given prefix
|
|
161
|
+
// Handles both legacy string entries and object entries with `text`.
|
|
162
|
+
async replaceMemory(prefix, content, type = 'info') {
|
|
163
|
+
const mem = this.memory || [];
|
|
164
|
+
const filtered = mem.filter(m => {
|
|
165
|
+
if (!m) return true;
|
|
166
|
+
if (typeof m === 'string') return !m.startsWith(prefix);
|
|
167
|
+
if (typeof m === 'object' && m.text) return !m.text.startsWith(prefix);
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.memory = filtered;
|
|
172
|
+
await this.addToMemory(`${prefix}:
|
|
173
|
+
${content}`, type);
|
|
174
|
+
}
|
|
175
|
+
|
|
151
176
|
async updateContext(key, value) {
|
|
152
177
|
this.context[key] = {
|
|
153
178
|
value,
|
|
@@ -269,10 +294,29 @@ export class SessionManager {
|
|
|
269
294
|
this.plans = session.plans || [];
|
|
270
295
|
this.memory = session.memory || [];
|
|
271
296
|
|
|
297
|
+
await this.rememberProject(session.project || process.cwd());
|
|
298
|
+
|
|
272
299
|
await this.saveSession();
|
|
273
300
|
return session;
|
|
274
301
|
}
|
|
275
302
|
|
|
303
|
+
async rememberProject(projectPath) {
|
|
304
|
+
if (!projectPath) return;
|
|
305
|
+
|
|
306
|
+
if (this.config?.setProjectCurrent) {
|
|
307
|
+
await this.config.setProjectCurrent(projectPath);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (this.config?.load && this.config?.save) {
|
|
312
|
+
const config = await this.config.load();
|
|
313
|
+
config.project = config.project || {};
|
|
314
|
+
config.project.current = projectPath;
|
|
315
|
+
config.project.lastOpenedAt = new Date().toISOString();
|
|
316
|
+
await this.config.save(config);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
276
320
|
getSessionId() {
|
|
277
321
|
return this.currentSession?.id || 'none';
|
|
278
322
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { SessionManager } from './manager.js';
|
|
8
|
+
|
|
9
|
+
function createConfigStub() {
|
|
10
|
+
const state = {};
|
|
11
|
+
return {
|
|
12
|
+
state,
|
|
13
|
+
async load() {
|
|
14
|
+
return state;
|
|
15
|
+
},
|
|
16
|
+
async save(config) {
|
|
17
|
+
Object.assign(state, config);
|
|
18
|
+
},
|
|
19
|
+
async setProjectCurrent(projectPath) {
|
|
20
|
+
state.project = state.project || {};
|
|
21
|
+
state.project.current = projectPath;
|
|
22
|
+
state.project.lastOpenedAt = 'now';
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('new sessions remember the current project path in config', async () => {
|
|
28
|
+
const root = await mkdtemp(path.join(tmpdir(), 'winter-session-project-'));
|
|
29
|
+
const config = createConfigStub();
|
|
30
|
+
const session = new SessionManager(config);
|
|
31
|
+
session.winterDir = path.join(root, '.winter');
|
|
32
|
+
session.sessionsDir = path.join(session.winterDir, 'sessions');
|
|
33
|
+
|
|
34
|
+
const projectPath = path.join(root, 'demo-project');
|
|
35
|
+
const created = await session.init({ project: projectPath });
|
|
36
|
+
|
|
37
|
+
assert.equal(created, undefined);
|
|
38
|
+
assert.equal(session.currentSession.project, projectPath);
|
|
39
|
+
assert.equal(config.state.project.current, projectPath);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('switchSession updates the remembered project anchor', async () => {
|
|
43
|
+
const root = await mkdtemp(path.join(tmpdir(), 'winter-session-switch-'));
|
|
44
|
+
const config = createConfigStub();
|
|
45
|
+
const session = new SessionManager(config);
|
|
46
|
+
session.winterDir = path.join(root, '.winter');
|
|
47
|
+
session.sessionsDir = path.join(session.winterDir, 'sessions');
|
|
48
|
+
|
|
49
|
+
const firstProject = path.join(root, 'project-a');
|
|
50
|
+
const secondProject = path.join(root, 'project-b');
|
|
51
|
+
|
|
52
|
+
await session.init({ project: firstProject });
|
|
53
|
+
const firstSessionId = session.getSessionId();
|
|
54
|
+
|
|
55
|
+
const secondSessionPath = path.join(session.sessionsDir, 'active', 'session-b.json');
|
|
56
|
+
await writeFile(secondSessionPath, JSON.stringify({
|
|
57
|
+
id: 'session-b',
|
|
58
|
+
createdAt: new Date().toISOString(),
|
|
59
|
+
updatedAt: new Date().toISOString(),
|
|
60
|
+
project: secondProject,
|
|
61
|
+
context: {},
|
|
62
|
+
plans: [],
|
|
63
|
+
memory: [],
|
|
64
|
+
history: [],
|
|
65
|
+
}, null, 2));
|
|
66
|
+
|
|
67
|
+
await session.switchSession('session-b');
|
|
68
|
+
|
|
69
|
+
assert.notEqual(firstSessionId, 'session-b');
|
|
70
|
+
assert.equal(session.getSessionId(), 'session-b');
|
|
71
|
+
assert.equal(config.state.project.current, secondProject);
|
|
72
|
+
});
|
package/src/tools/executor.js
CHANGED
|
@@ -998,7 +998,7 @@ export class ToolExecutor {
|
|
|
998
998
|
success: true,
|
|
999
999
|
url,
|
|
1000
1000
|
content: cleanText.substring(0, 15000), // Cho phép đọc dài hơn
|
|
1001
|
-
length:
|
|
1001
|
+
length: cleanText.length
|
|
1002
1002
|
};
|
|
1003
1003
|
} catch (error) {
|
|
1004
1004
|
return { success: false, error: error.message, url };
|