@hunterzhu/pulse-cli 0.3.0 → 0.4.1

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # @hunterzhu/pulse-cli
2
2
 
3
+ [简体中文](https://github.com/zhuhengtan/Pulse/blob/main/packages/cli/README.zh-CN.md)
4
+
5
+ [Changelog](https://github.com/zhuhengtan/Pulse/blob/main/CHANGELOG.en.md)
6
+
3
7
  **A neural-signal like AI assistant for coding, research and file tasks.**
4
8
 
5
9
  Pulse runs in your project folder, keeps conversations on your machine, and can resume unfinished work. Requires Node.js 22+.
@@ -9,7 +13,7 @@ Pulse runs in your project folder, keeps conversations on your machine, and can
9
13
  Install the CLI globally so the `pulse` command is on your `PATH`:
10
14
 
11
15
  ```bash
12
- npm install -g @hunterzhu/pulse-cli
16
+ npm install -g @hunterzhu/pulse-cli --foreground-scripts
13
17
  pulse --version
14
18
  ```
15
19
 
@@ -49,6 +53,10 @@ pulse --read-only
49
53
 
50
54
  See `pulse --help` for all options.
51
55
 
56
+ ## License
57
+
58
+ This package is licensed under [PolyForm Noncommercial 1.0.0](./LICENSE). Commercial use requires separate permission. This is a source-available noncommercial license, not an OSI-approved open-source license.
59
+
52
60
  ## Links
53
61
 
54
62
  - [Full CLI configuration guide](https://github.com/zhuhengtan/Pulse/blob/main/docs/cli-config.md)
package/dist/bin.js CHANGED
@@ -16,6 +16,7 @@ import { runServiceCommand } from './commands/service.js';
16
16
  import { runTemplateCommand } from './commands/template.js';
17
17
  import { runMcpDoctor } from './commands/mcp.js';
18
18
  import { isSameModulePath } from './utils/is-main-module.js';
19
+ import { formatVersionOutput, shouldUseColor } from './presentation.js';
19
20
  const packageManifest = createRequire(import.meta.url)('../package.json');
20
21
  export const version = packageManifest.version;
21
22
  process.stdout.on('error', (error) => {
@@ -46,7 +47,6 @@ Options:
46
47
  --max-output-tokens <n> maximum generated tokens (default 4096)
47
48
  --reasoning-effort <x> low, medium, or high
48
49
  --max-turns <n> maximum ReAct model/tool turns (default 32)
49
- --execution-mode <mode> serial (default) or parallel-read (up to three read-only lanes)
50
50
  --auto-compact-percent <n> compact automatically at this percent of the context window (1-90, default 90)
51
51
  --format <text|jsonl> output format
52
52
  --resume enter the latest saved session
@@ -234,9 +234,6 @@ export async function hostOptions(parsed) {
234
234
  ? 'auto'
235
235
  : requestedApprovalMode === 'ask' ? 'ask' : config.approvalMode;
236
236
  const allowNetwork = parsed.options['no-network'] === true ? false : parsed.options['allow-network'] === true || process.env.PULSE_ALLOW_NETWORK === '1' ? true : config.allowNetwork;
237
- const executionMode = option(parsed.options, 'execution-mode') ?? process.env.PULSE_EXECUTION_MODE ?? config.executionMode ?? 'serial';
238
- if (executionMode !== 'serial' && executionMode !== 'parallel-read')
239
- throw new Error('INVALID_EXECUTION_MODE: expected serial or parallel-read');
240
237
  const capabilitySettings = config.capabilities ?? {};
241
238
  const skillRoots = capabilitySettings.trustedSkillRoots ?? [];
242
239
  if (!Array.isArray(skillRoots) || skillRoots.some((root) => typeof root !== 'string' || !isAbsolute(expandHome(root) ?? root)))
@@ -305,7 +302,7 @@ export async function hostOptions(parsed) {
305
302
  ...(taskRouting === undefined ? {} : { taskRouting }),
306
303
  capabilityPacks,
307
304
  enabledCapabilityPacks,
308
- capabilityConfig: { skills: skillNames },
305
+ capabilityConfig: capabilitySettings.skills === undefined ? {} : { skills: skillNames },
309
306
  activeProviderCode: providerName,
310
307
  activeModel: activeModelName,
311
308
  ...(mockResponse === undefined ? {} : { mockResponse }),
@@ -315,7 +312,6 @@ export async function hostOptions(parsed) {
315
312
  ...(configuredAutoCompactPercent === undefined ? {} : { autoCompactPercent: Math.min(90, configuredAutoCompactPercent) }),
316
313
  ...(allowNetwork === undefined ? {} : { allowNetwork }),
317
314
  ...(config.networkHosts === undefined ? {} : { networkHosts: config.networkHosts }),
318
- executionMode,
319
315
  };
320
316
  }
321
317
  export async function main() {
@@ -325,7 +321,9 @@ export async function main() {
325
321
  return 0;
326
322
  }
327
323
  if (parsed.options.version || parsed.options.v) {
328
- process.stdout.write(`${version}\n`);
324
+ process.stdout.write(formatVersionOutput(version, {
325
+ color: parsed.options['no-color'] !== true && shouldUseColor(process.env, process.stdout.isTTY === true),
326
+ }));
329
327
  return 0;
330
328
  }
331
329
  if (parsed.command === 'setup') {
@@ -18,11 +18,14 @@ import { useTokenStats } from '../hooks/useTokenStats.js';
18
18
  import { useSlashCommands } from '../hooks/useSlashCommands.js';
19
19
  import { copyToClipboard, isCopyShortcut } from '../utils/clipboard.js';
20
20
  import { emptyInputHistory, navigateInputHistory, rememberInput } from '../utils/inputHistory.js';
21
+ import { skillSuggestions } from '../utils/slashCompletion.js';
21
22
  export function App({ hostOptions, conversationId: initialConversationId, initialTask, resumeOnStart = false, version, }) {
22
23
  const { exit } = useApp();
23
24
  const { stdout } = useStdout();
24
25
  const [terminalRows, setTerminalRows] = useState(stdout.rows || 24);
25
26
  useEffect(() => { const resize = () => setTerminalRows(stdout.rows || 24); stdout.on('resize', resize); return () => { stdout.off('resize', resize); }; }, [stdout]);
27
+ const [skillNames, setSkillNames] = useState([]);
28
+ const [skillError, setSkillError] = useState(null);
26
29
  const [mode, setMode] = useState('chat');
27
30
  const [mouseEnabled, setMouseEnabled] = useState(true);
28
31
  const [selectedText, setSelectedText] = useState('');
@@ -38,6 +41,11 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
38
41
  const startedTask = useRef(false);
39
42
  const startedResume = useRef(false);
40
43
  const { host, error: hostError, ready: hostReady } = useHost(hostOptions);
44
+ const refreshSkills = useCallback(() => {
45
+ if (host)
46
+ void host.listSkills().then(names => { setSkillNames(names); setSkillError(null); }, error => setSkillError(String(error)));
47
+ }, [host]);
48
+ useEffect(refreshSkills, [refreshSkills]);
41
49
  const { conversation, messages, addUserMessage, addAssistantMessage, clearMessages, switchConversation, newConversation, createConversation, error: conversationError, } = useConversation({ host, conversationId: initialConversationId });
42
50
  const { isRunning, error: runError, approvalRequest, askRequest, lanes, approvalSubmitting, sendMessage, resumeActive, approveAction, replyAsk, cancelRun, } = useRun({
43
51
  host,
@@ -127,7 +135,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
127
135
  await sendMessage(initialTask, target.id);
128
136
  })();
129
137
  }, [hostReady, conversation, initialTask, isRunning, addUserMessage, addAssistantMessage, createConversation, sendMessage]);
130
- const { executeCommand, isSlashCommand } = useSlashCommands({
138
+ const { commands, executeCommand, isSlashCommand } = useSlashCommands({
131
139
  onMouse: (arg) => setMouseEnabled((current) => arg === 'off' ? false : arg === 'on' ? true : !current),
132
140
  onCopy: async () => {
133
141
  if (selectedText) {
@@ -455,6 +463,31 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
455
463
  if (isSlashCommand(text)) {
456
464
  const handled = await executeCommand(text);
457
465
  if (!handled) {
466
+ const token = text.split(/\s+/)[0].slice(1).replace(/^skill:/, '');
467
+ let currentSkills = skillNames;
468
+ try {
469
+ if (host) {
470
+ currentSkills = await host.listSkills();
471
+ setSkillNames(currentSkills);
472
+ setSkillError(null);
473
+ }
474
+ }
475
+ catch (error) {
476
+ setSkillError(String(error));
477
+ return;
478
+ }
479
+ if (currentSkills.includes(token)) {
480
+ if (isRunning) {
481
+ addAssistantMessage({ id: `skill-busy-${Date.now()}`, role: 'system', text: '请等当前任务完成,或使用 /cancel 后再调用技能。', createdAt: new Date().toISOString() });
482
+ return;
483
+ }
484
+ const target = conversation ?? await createConversation();
485
+ if (!target)
486
+ return;
487
+ addUserMessage(text);
488
+ await sendMessage(text, target.id);
489
+ return;
490
+ }
458
491
  addAssistantMessage({
459
492
  id: `command-${Date.now()}`,
460
493
  role: 'system',
@@ -470,7 +503,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
470
503
  addUserMessage(text);
471
504
  await sendMessage(text, target.id);
472
505
  }
473
- }, [isSlashCommand, executeCommand, conversation, createConversation, addUserMessage, sendMessage]);
506
+ }, [isSlashCommand, executeCommand, conversation, createConversation, addUserMessage, sendMessage, skillNames, isRunning, addAssistantMessage, host]);
474
507
  useInput((input, key) => {
475
508
  if (isCopyShortcut(input, key)) {
476
509
  if (selectedText)
@@ -541,6 +574,6 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
541
574
  }, onInput: () => {
542
575
  setApprovalChoiceFocused(false);
543
576
  setApprovalInputMode(true);
544
- } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsx(Box, { marginTop: 1, children: _jsx(InputArea, { onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' }) }))] }));
577
+ } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [skillError && _jsxs(Text, { color: "red", children: ["\u6280\u80FD\u7D22\u5F15\u5931\u8D25\uFF1A", skillError] }), _jsx(InputArea, { suggestions: [...commands, ...skillSuggestions(skillNames, commands.flatMap(command => [command, ...(command.aliases ?? []).map(name => ({ name, description: command.description }))]))], onSearchSkills: refreshSkills, onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' })] }))] }));
545
578
  }
546
579
  export default App;
@@ -38,6 +38,7 @@ const COMMANDS = [
38
38
  { name: '/model [name]', desc: '切换模型' },
39
39
  { name: '/thinking [level]', desc: '设置模型思考深度 (low/medium/high/off)' },
40
40
  { name: '/compact', desc: '手动压缩历史;默认在上下文 90% 时自动压缩' },
41
+ { name: '/skill:名称 任务', desc: '按需加载技能;输入 / 搜索,↑↓ 选择,Tab 补全' },
41
42
  { name: '/config', desc: '查看当前运行时配置' },
42
43
  ],
43
44
  },
@@ -1,4 +1,7 @@
1
+ import { type SlashSuggestion } from '../utils/slashCompletion.js';
1
2
  interface Props {
3
+ suggestions?: SlashSuggestion[];
4
+ onSearchSkills?: () => void;
2
5
  onSubmit: (text: string) => void;
3
6
  onRememberInput: (text: string) => void;
4
7
  onNavigateHistory: (direction: 'up' | 'down', currentValue: string) => string | undefined;
@@ -6,5 +9,5 @@ interface Props {
6
9
  focus?: boolean;
7
10
  placeholder?: string;
8
11
  }
9
- export declare function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus, placeholder }: Props): import("react").JSX.Element;
12
+ export declare function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus, placeholder, suggestions, onSearchSkills }: Props): import("react").JSX.Element;
10
13
  export {};
@@ -2,15 +2,44 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import TextInput from './TextInput.js';
4
4
  import { useState } from 'react';
5
+ import { searchSlashSuggestions } from '../utils/slashCompletion.js';
5
6
  import { theme } from '../theme.js';
6
- export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus = true, placeholder }) {
7
+ export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus = true, placeholder, suggestions = [], onSearchSkills }) {
7
8
  const [value, setValue] = useState('');
9
+ const [completionRevision, setCompletionRevision] = useState(0);
10
+ const [selected, setSelected] = useState(0);
11
+ const [dismissed, setDismissed] = useState(false);
12
+ const matches = dismissed ? [] : searchSlashSuggestions(value, suggestions);
13
+ const chosen = Math.min(selected, Math.max(0, matches.length - 1));
14
+ const changeValue = (next) => {
15
+ if (next.startsWith('/') && !value.startsWith('/'))
16
+ onSearchSkills?.();
17
+ setSelected(0);
18
+ setDismissed(false);
19
+ setValue(next);
20
+ };
8
21
  const [accumulatedLines, setAccumulatedLines] = useState([]);
9
22
  useInput((input, key) => {
10
23
  if (disabled || !focus)
11
24
  return;
12
25
  if (key.ctrl || key.meta || key.pageUp || key.pageDown)
13
26
  return;
27
+ if (matches.length && !accumulatedLines.length) {
28
+ if (key.escape) {
29
+ setDismissed(true);
30
+ return;
31
+ }
32
+ if (key.upArrow || key.downArrow) {
33
+ setSelected(Math.max(0, Math.min(matches.length - 1, chosen + (key.upArrow ? -1 : 1))));
34
+ return;
35
+ }
36
+ if (key.tab) {
37
+ setValue(`${matches[chosen].name} `);
38
+ setCompletionRevision(revision => revision + 1);
39
+ setSelected(0);
40
+ return;
41
+ }
42
+ }
14
43
  if (key.return) {
15
44
  if (key.shift) {
16
45
  setAccumulatedLines((current) => [...current, value]);
@@ -35,7 +64,7 @@ export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabl
35
64
  const restoreInput = (text) => {
36
65
  const lines = text.split('\n');
37
66
  setAccumulatedLines(lines.slice(0, -1));
38
- setValue(lines.at(-1) ?? '');
67
+ changeValue(lines.at(-1) ?? '');
39
68
  };
40
69
  const handleSubmit = (text) => {
41
70
  if (text.endsWith('\\')) {
@@ -56,5 +85,5 @@ export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabl
56
85
  };
57
86
  const promptChar = accumulatedLines.length > 0 ? '…' : '›';
58
87
  const promptColor = disabled ? theme.dim : theme.primary;
59
- return (_jsxs(Box, { flexDirection: "column", children: [accumulatedLines.map((line, idx) => (_jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), _jsx(Text, { children: line })] }, idx))), _jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), disabled ? (_jsx(Text, { color: theme.dim, children: placeholder || '处理中...' })) : (_jsx(TextInput, { focus: focus, value: value, onChange: setValue, placeholder: placeholder || '' }))] })] }));
88
+ return (_jsxs(Box, { flexDirection: "column", children: [matches.length > 0 && accumulatedLines.length === 0 && (_jsxs(Box, { flexDirection: "column", children: [matches.slice(Math.max(0, chosen - 5), Math.max(0, chosen - 5) + 6).map(item => (_jsxs(Text, { color: item === matches[chosen] ? theme.primary : theme.dim, children: [item === matches[chosen] ? '› ' : ' ', item.name, " ", item.description] }, item.name))), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 \u9009\u62E9 \u00B7 Tab \u8865\u5168 \u00B7 \u8F93\u5165\u4EFB\u52A1\u540E Enter \u53D1\u9001 \u00B7 Esc \u6536\u8D77\uFF08", matches.length, " \u9879\uFF09"] })] })), accumulatedLines.map((line, idx) => (_jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), _jsx(Text, { children: line })] }, idx))), _jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), disabled ? (_jsx(Text, { color: theme.dim, children: placeholder || '处理中...' })) : (_jsx(TextInput, { focus: focus, value: value, onChange: changeValue, placeholder: placeholder || '' }, completionRevision))] })] }));
60
89
  }
package/dist/config.d.ts CHANGED
@@ -47,7 +47,7 @@ export interface PulseCliMcpServer {
47
47
  export interface PulseCliCapabilities {
48
48
  /** IDs must be explicitly enabled; workspace config cannot enable host extensions. */
49
49
  enabled?: string[];
50
- /** Names of host-installed skills to load as untrusted instructions. */
50
+ /** Optional discovery allow-list of skill directory names; omitted discovers all. Bodies load only on invocation. */
51
51
  skills?: string[];
52
52
  /** Additional absolute roots explicitly trusted by the user for installed skills. */
53
53
  trustedSkillRoots?: string[];
@@ -69,8 +69,6 @@ export interface PulseCliConfig {
69
69
  taskRouting?: Partial<Record<PulseModelTask, string[]>>;
70
70
  capabilities?: PulseCliCapabilities;
71
71
  approvalMode?: 'read-only' | 'ask' | 'auto';
72
- /** Parallelism is opt-in and only exposes tools explicitly classified read-only. */
73
- executionMode?: 'serial' | 'parallel-read';
74
72
  maxTurns?: number;
75
73
  /** Percent of maxContextTokens that triggers automatic history compaction. Clamped to 1–90. */
76
74
  autoCompactPercent?: number;
package/dist/config.js CHANGED
@@ -24,7 +24,6 @@ export const defaultPulseConfig = {
24
24
  },
25
25
  activeModel: 'mock',
26
26
  approvalMode: 'ask',
27
- executionMode: 'serial',
28
27
  maxTurns: 32,
29
28
  autoCompactPercent: 90,
30
29
  allowNetwork: false,
@@ -39,8 +39,14 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
39
39
  const [isRunning, setIsRunning] = useState(false);
40
40
  const [currentStep, setCurrentStep] = useState(null);
41
41
  const [error, setError] = useState(null);
42
- const [approvalRequest, setApprovalRequest] = useState(null);
43
- const [askRequest, setAskRequest] = useState(null);
42
+ const [pendingInteractions, setPendingInteractions] = useState([]);
43
+ const enqueueInteraction = useCallback((item) => {
44
+ setPendingInteractions((current) => current.some((entry) => entry.request.effectId === item.request.effectId) ? current : [...current, item]);
45
+ }, []);
46
+ const removeInteraction = useCallback((effectId) => setPendingInteractions((current) => current.filter((entry) => entry.request.effectId !== effectId)), []);
47
+ const activeInteraction = pendingInteractions[0];
48
+ const approvalRequest = activeInteraction?.kind === 'approval' ? activeInteraction.request : null;
49
+ const askRequest = activeInteraction?.kind === 'ask' ? activeInteraction.request : null;
44
50
  const [approvalSubmitting, setApprovalSubmitting] = useState(false);
45
51
  const [lanes, setLanes] = useState([]);
46
52
  const runRef = useRef(null);
@@ -180,23 +186,21 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
180
186
  return typeof item.label === 'string' && typeof item.value === 'string' ? [{ label: item.label, value: item.value }] : [];
181
187
  })
182
188
  : undefined;
183
- setApprovalRequest(null);
184
- setAskRequest({
185
- effectId,
186
- toolName: typeof input.toolName === 'string' ? input.toolName : `ask.${input.type}`,
187
- type: input.type,
188
- prompt: typeof input.prompt === 'string' ? input.prompt : '请输入你的回答。',
189
- ...(options && options.length ? { options } : {}),
190
- ...(typeof input.min === 'number' ? { min: input.min } : {}),
191
- ...(typeof input.max === 'number' ? { max: input.max } : {}),
192
- ...(typeof input.placeholder === 'string' ? { placeholder: input.placeholder } : {}),
193
- ...(typeof input.defaultValue === 'string' ? { defaultValue: input.defaultValue } : {}),
194
- });
189
+ enqueueInteraction({ kind: 'ask', request: {
190
+ effectId,
191
+ toolName: typeof input.toolName === 'string' ? input.toolName : `ask.${input.type}`,
192
+ type: input.type,
193
+ prompt: typeof input.prompt === 'string' ? input.prompt : '请输入你的回答。',
194
+ ...(options && options.length ? { options } : {}),
195
+ ...(typeof input.min === 'number' ? { min: input.min } : {}),
196
+ ...(typeof input.max === 'number' ? { max: input.max } : {}),
197
+ ...(typeof input.placeholder === 'string' ? { placeholder: input.placeholder } : {}),
198
+ ...(typeof input.defaultValue === 'string' ? { defaultValue: input.defaultValue } : {}),
199
+ } });
195
200
  setCurrentStep('等待你的回答...');
196
201
  addProgress(`waiting:${effectId}`, '等待你的回答…');
197
202
  break;
198
203
  }
199
- setAskRequest(null);
200
204
  const tools = Array.isArray(input.tools)
201
205
  ? input.tools.flatMap((tool) => {
202
206
  if (!tool || typeof tool !== 'object' || Array.isArray(tool))
@@ -212,14 +216,14 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
212
216
  })
213
217
  : [];
214
218
  const firstTool = tools[0];
215
- setApprovalRequest({
216
- effectId,
217
- toolName: tools.length === 1 ? firstTool.name : `${tools.length || 1} 个系统操作`,
218
- toolArgs: firstTool?.input ?? {},
219
- prompt: typeof input.prompt === 'string' ? input.prompt : '是否批准执行?',
220
- ...(typeof input.digest === 'string' ? { digest: input.digest } : {}),
221
- ...(tools.length ? { tools } : {}),
222
- });
219
+ enqueueInteraction({ kind: 'approval', request: {
220
+ effectId,
221
+ toolName: tools.length === 1 ? firstTool.name : `${tools.length || 1} 个系统操作`,
222
+ toolArgs: firstTool?.input ?? {},
223
+ prompt: typeof input.prompt === 'string' ? input.prompt : '是否批准执行?',
224
+ ...(typeof input.digest === 'string' ? { digest: input.digest } : {}),
225
+ ...(tools.length ? { tools } : {}),
226
+ } });
223
227
  setCurrentStep('等待用户审批...');
224
228
  addProgress(`approval:${effectId}`, `等待审批 · ${tools.map((tool) => tool.name).join('、') || '系统操作'}`);
225
229
  break;
@@ -280,16 +284,14 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
280
284
  setError(String(event.data ?? '发生未知错误'));
281
285
  setIsRunning(false);
282
286
  setCurrentStep(null);
283
- setApprovalRequest(null);
284
- setAskRequest(null);
287
+ setPendingInteractions([]);
285
288
  setLanes([]);
286
289
  break;
287
290
  case 'complete':
288
291
  addProgress('finished', event.data && typeof event.data === 'object' && !Array.isArray(event.data) && event.data.status === 'succeeded' ? '任务处理完成。' : '任务已结束。');
289
292
  setIsRunning(false);
290
293
  setCurrentStep(null);
291
- setApprovalRequest(null);
292
- setAskRequest(null);
294
+ setPendingInteractions([]);
293
295
  if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
294
296
  const completion = event.data;
295
297
  const accepted = completion.status === 'succeeded' && (!completion.taskOutcome || (typeof completion.taskOutcome === 'object' && completion.taskOutcome !== null && completion.taskOutcome.status === 'accepted'));
@@ -343,12 +345,11 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
343
345
  break;
344
346
  }
345
347
  }
346
- }, [addAssistantMessage]);
348
+ }, [addAssistantMessage, enqueueInteraction]);
347
349
  const finishRun = useCallback(() => {
348
350
  setIsRunning(false);
349
351
  setCurrentStep(null);
350
- setApprovalRequest(null);
351
- setAskRequest(null);
352
+ setPendingInteractions([]);
352
353
  runRef.current = null;
353
354
  }, []);
354
355
  const sendMessage = useCallback(async (text, targetConversationId) => {
@@ -409,7 +410,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
409
410
  setApprovalSubmitting(true);
410
411
  try {
411
412
  await runRef.current.reply(effectId, { approved, ...(approved ? {} : { reason: reason || '拒绝执行' }) });
412
- setApprovalRequest(null);
413
+ removeInteraction(effectId);
413
414
  setCurrentStep('审批已提交,正在继续...');
414
415
  setError(null);
415
416
  }
@@ -420,14 +421,14 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
420
421
  finally {
421
422
  setApprovalSubmitting(false);
422
423
  }
423
- }, [approvalSubmitting]);
424
+ }, [approvalSubmitting, removeInteraction]);
424
425
  const replyAsk = useCallback(async (effectId, value) => {
425
426
  if (!effectId || !runRef.current || approvalSubmitting)
426
427
  return;
427
428
  setApprovalSubmitting(true);
428
429
  try {
429
430
  await runRef.current.reply(effectId, value);
430
- setAskRequest(null);
431
+ removeInteraction(effectId);
431
432
  setCurrentStep('回答已提交,正在继续...');
432
433
  setError(null);
433
434
  }
@@ -438,7 +439,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
438
439
  finally {
439
440
  setApprovalSubmitting(false);
440
441
  }
441
- }, [approvalSubmitting]);
442
+ }, [approvalSubmitting, removeInteraction]);
442
443
  const cancelRun = useCallback(async () => {
443
444
  if (runRef.current) {
444
445
  await runRef.current.cancel('USER_CANCELLED');
@@ -0,0 +1,11 @@
1
+ export interface PresentationOptions {
2
+ color?: boolean;
3
+ }
4
+ export declare function shouldUseColor(env: NodeJS.ProcessEnv, isTTY: boolean): boolean;
5
+ export declare function formatVersionOutput(version: string, options?: PresentationOptions): string;
6
+ export interface InstallWelcomeOptions extends PresentationOptions {
7
+ version: string;
8
+ configPath?: string;
9
+ error?: string;
10
+ }
11
+ export declare function formatInstallWelcome(options: InstallWelcomeOptions): string;
@@ -0,0 +1,64 @@
1
+ import { releaseHighlights } from './release-highlights.generated.js';
2
+ const bannerLines = [
3
+ '██████╗ ██╗ ██╗██╗ ███████╗███████╗',
4
+ '██╔══██╗██║ ██║██║ ██╔════╝██╔════╝',
5
+ '██████╔╝██║ ██║██║ ███████╗█████╗ ',
6
+ '██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝ ',
7
+ '██║ ╚██████╔╝███████╗███████║███████╗',
8
+ '╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝',
9
+ ];
10
+ function paint(code, enabled, value) {
11
+ return enabled ? `\u001b[${code}m${value}\u001b[0m` : value;
12
+ }
13
+ export function shouldUseColor(env, isTTY) {
14
+ return env.NO_COLOR === undefined && env.FORCE_COLOR !== '0' && (isTTY || env.FORCE_COLOR !== undefined);
15
+ }
16
+ function banner(color) {
17
+ return bannerLines.map((line) => paint('36', color, line)).join('\n');
18
+ }
19
+ export function formatVersionOutput(version, options = {}) {
20
+ const color = options.color === true;
21
+ const highlights = releaseHighlights[version];
22
+ const lines = [
23
+ banner(color),
24
+ '',
25
+ ` ${paint('1', color, 'Pulse')} ${paint('2', color, `v${version}`)}`,
26
+ '',
27
+ ` ${paint('1', color, '本版本亮点 / What’s new in this version')}`,
28
+ ];
29
+ if (highlights?.length) {
30
+ for (const item of highlights)
31
+ lines.push(` • ${item.zh}`, ` ${item.en}`);
32
+ }
33
+ else {
34
+ lines.push(' 暂无此版本的更新亮点记录。', ' No release highlights are available for this version yet.');
35
+ }
36
+ lines.push('');
37
+ return `${lines.join('\n')}\n`;
38
+ }
39
+ export function formatInstallWelcome(options) {
40
+ const { color = false, version, configPath, error } = options;
41
+ const cyan = (value) => paint('36', color, value);
42
+ const dim = (value) => paint('2', color, value);
43
+ const lines = [
44
+ '',
45
+ banner(color),
46
+ '',
47
+ ` ${paint('1', color, 'Pulse')} v${version} — 面向编程、研究与文件工作的 AI 助手`,
48
+ ` ${paint('1', color, 'Pulse')} — an AI assistant for coding, research, and file tasks`,
49
+ '',
50
+ ` ${paint('1', color, '开始使用 / Get started:')}`,
51
+ ` ${cyan('cd project_dir')}`,
52
+ ` ${cyan('pulse')}`,
53
+ '',
54
+ ];
55
+ if (configPath) {
56
+ lines.push(` ${dim('配置已就绪 / Configuration ready at:')}`, ` ${configPath}`);
57
+ }
58
+ else {
59
+ lines.push(` ${dim('配置创建失败 / Configuration could not be created:')} ${error ?? 'Unknown error'}`);
60
+ lines.push(` ${dim('稍后运行 "pulse setup" 创建配置。 / Run "pulse setup" later to create it.')}`);
61
+ }
62
+ lines.push('');
63
+ return `${lines.join('\n')}\n`;
64
+ }
@@ -0,0 +1,4 @@
1
+ export declare const releaseHighlights: Record<string, Array<{
2
+ zh: string;
3
+ en: string;
4
+ }>>;
@@ -0,0 +1,117 @@
1
+ // Generated from CHANGELOG.md and CHANGELOG.en.md by scripts/release/sync-cli-highlights.mjs. Do not edit manually.
2
+ export const releaseHighlights = {
3
+ "0.4.1": [
4
+ {
5
+ "zh": "新增按需发现与调用 Skill 的能力,减少无关指令进入任务上下文。",
6
+ "en": "Added on-demand Skill discovery and invocation to keep unrelated instructions out of task context."
7
+ },
8
+ {
9
+ "zh": "美化安装欢迎界面和版本页,提供中英双语介绍及版本亮点。",
10
+ "en": "Polished the installation welcome screen and version page with bilingual product information and release highlights."
11
+ },
12
+ {
13
+ "zh": "提供中英文分离的更新日志和双语用户文档,并校验版本亮点来源。",
14
+ "en": "Added separate Chinese and English changelogs and bilingual user documentation, with validation for version highlights."
15
+ },
16
+ {
17
+ "zh": "为全部五个 npm 包采用 PolyForm Noncommercial 1.0.0,并随包提供许可证说明。",
18
+ "en": "Licensed all five npm packages under PolyForm Noncommercial 1.0.0 and included package-level license notices."
19
+ }
20
+ ],
21
+ "0.4.0": [
22
+ {
23
+ "zh": "默认使用异步并行 event loop,让模型请求、工具调用等副作用并发推进。",
24
+ "en": "Uses an asynchronous, parallel event loop by default so model requests, tool calls, and other effects can progress concurrently."
25
+ }
26
+ ],
27
+ "0.3.0": [
28
+ {
29
+ "zh": "增强 CLI 交互体验,支持内容选择与复制、推理用量归因、任务进度流式展示和可恢复会话。",
30
+ "en": "Improved CLI interaction with text selection and copying, reasoning-usage attribution, streamed task progress, and resumable sessions."
31
+ }
32
+ ],
33
+ "0.2.1": [
34
+ {
35
+ "zh": "增加安装后的欢迎界面,并完善跨平台本地与云端 CI 验证入口。",
36
+ "en": "Added the post-install welcome screen and improved the shared cross-platform local and cloud CI entry point."
37
+ }
38
+ ],
39
+ "0.2.0": [
40
+ {
41
+ "zh": "扩展 CLI 与 Host 能力,完善会话恢复、人机协作、工具调用和 Windows 沙箱及发布验证。",
42
+ "en": "Expanded CLI and Host capabilities with resumable sessions, human interaction, tool calls, and improved Windows sandbox and release validation."
43
+ }
44
+ ],
45
+ "0.1.11": [
46
+ {
47
+ "zh": "修复 Windows 下打包 CLI 入口加载问题,并改进跨平台安装与发布验证。",
48
+ "en": "Fixed loading the packaged CLI entry point on Windows and improved cross-platform installation and release validation."
49
+ }
50
+ ],
51
+ "0.1.10": [
52
+ {
53
+ "zh": "维护性版本发布;未单独记录用户可见更新。",
54
+ "en": "Maintenance release; no separate user-facing changes were recorded."
55
+ }
56
+ ],
57
+ "0.1.9": [
58
+ {
59
+ "zh": "维护性版本发布;未单独记录用户可见更新。",
60
+ "en": "Maintenance release; no separate user-facing changes were recorded."
61
+ }
62
+ ],
63
+ "0.1.8": [
64
+ {
65
+ "zh": "增加 npm 安装后的首次配置初始化流程。",
66
+ "en": "Added first-run configuration initialization after npm installation."
67
+ }
68
+ ],
69
+ "0.1.7": [
70
+ {
71
+ "zh": "增加完整屏幕 CLI 工作区、模型配置与会话交互能力。",
72
+ "en": "Added a full-screen CLI workspace, model configuration, and interactive session capabilities."
73
+ }
74
+ ],
75
+ "0.1.6": [
76
+ {
77
+ "zh": "增加人类输入仲裁和主动 CLI 交互,并强化上下文、持久化与流式工具调用。",
78
+ "en": "Added human-input arbitration and active CLI interaction, while strengthening context, persistence, and streamed tool calls."
79
+ }
80
+ ],
81
+ "0.1.5": [
82
+ {
83
+ "zh": "改进 Provider 工具名称兼容性。",
84
+ "en": "Improved compatibility of tool names across providers."
85
+ }
86
+ ],
87
+ "0.1.4": [
88
+ {
89
+ "zh": "修复 Provider 安全工具名称的适配问题。",
90
+ "en": "Fixed adapter handling for provider-safe tool names."
91
+ }
92
+ ],
93
+ "0.1.3": [
94
+ {
95
+ "zh": "完善 CLI 工具暴露、用户文件目录和 Provider 错误反馈。",
96
+ "en": "Improved CLI tool exposure, user-file locations, and provider error reporting."
97
+ }
98
+ ],
99
+ "0.1.2": [
100
+ {
101
+ "zh": "修复 CLI 显示已安装包版本的问题。",
102
+ "en": "Fixed CLI reporting of the installed package version."
103
+ }
104
+ ],
105
+ "0.1.1": [
106
+ {
107
+ "zh": "完善首次运行配置创建及基础 npm 工作区使用体验。",
108
+ "en": "Improved first-run configuration setup and basic npm workspace usage."
109
+ }
110
+ ],
111
+ "0.1.0": [
112
+ {
113
+ "zh": "建立 Pulse CLI 初始 npm 发布包和仓库发布元数据。",
114
+ "en": "Established the initial Pulse CLI npm package and repository release metadata."
115
+ }
116
+ ]
117
+ };
@@ -0,0 +1,6 @@
1
+ export interface SlashSuggestion {
2
+ name: string;
3
+ description: string;
4
+ }
5
+ export declare function skillSuggestions(names: readonly string[], commands: readonly SlashSuggestion[]): SlashSuggestion[];
6
+ export declare function searchSlashSuggestions(value: string, items: readonly SlashSuggestion[]): SlashSuggestion[];
@@ -0,0 +1,11 @@
1
+ export function skillSuggestions(names, commands) {
2
+ const reserved = new Set(commands.map(item => item.name.toLowerCase()));
3
+ return names.map(name => ({ name: reserved.has(`/${name}`.toLowerCase()) ? `/skill:${name}` : `/${name}`, description: '技能 · 提交时加载' }));
4
+ }
5
+ export function searchSlashSuggestions(value, items) {
6
+ if (!/^\/\S*$/.test(value))
7
+ return [];
8
+ const query = value.slice(1).toLowerCase();
9
+ return items.filter(item => item.name.slice(1).toLowerCase().includes(query))
10
+ .sort((a, b) => Number(b.name.slice(1).toLowerCase().startsWith(query)) - Number(a.name.slice(1).toLowerCase().startsWith(query)) || a.name.localeCompare(b.name));
11
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "A local-first AI task assistant for coding, research, and file workflows.",
5
+ "license": "PolyForm-Noncommercial-1.0.0",
5
6
  "keywords": [
6
7
  "ai",
7
8
  "agent",
@@ -27,7 +28,7 @@
27
28
  "scripts"
28
29
  ],
29
30
  "scripts": {
30
- "build": "tsc -p tsconfig.json",
31
+ "build": "pnpm --dir ../.. changelog:sync && tsc -p tsconfig.json",
31
32
  "postinstall": "node ./scripts/postinstall.mjs"
32
33
  },
33
34
  "publishConfig": {
@@ -35,7 +36,7 @@
35
36
  "registry": "https://registry.npmjs.org"
36
37
  },
37
38
  "dependencies": {
38
- "@hunterzhu/pulse-server": "0.3.0",
39
+ "@hunterzhu/pulse-server": "0.4.1",
39
40
  "chalk": "^5.4.1",
40
41
  "highlight.js": "^11.10.0",
41
42
  "ink": "^7.1.1",
@@ -1,58 +1,15 @@
1
+ import { createRequire } from 'node:module'
1
2
  import { ensurePulseUserConfig, defaultPulseConfigPath } from '../dist/config.js'
3
+ import { formatInstallWelcome, shouldUseColor } from '../dist/presentation.js'
2
4
 
3
- // Install-time welcome screen. Colors stay off for piped or captured install
4
- // logs and can be forced with FORCE_COLOR for debugging.
5
- const colorEnabled =
6
- process.env.NO_COLOR === undefined &&
7
- process.env.FORCE_COLOR !== '0' &&
8
- (process.stdout.isTTY === true || process.env.FORCE_COLOR !== undefined)
9
-
10
- const paint = (code) => (text) => (colorEnabled ? `\u001b[${code}m${text}\u001b[0m` : text)
11
- const cyan = paint('36')
12
- const bold = paint('1')
13
- const dim = paint('2')
14
-
15
- const banner = [
16
- '██████╗ ██╗ ██╗██╗ ███████╗███████╗',
17
- '██╔══██╗██║ ██║██║ ██╔════╝██╔════╝',
18
- '██████╔╝██║ ██║██║ ███████╗█████╗ ',
19
- '██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝ ',
20
- '██║ ╚██████╔╝███████╗███████║███████╗',
21
- '╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝',
22
- ].map((line) => cyan(line)).join('\n')
23
-
24
- function printWelcome({ configPath, error } = {}) {
25
- const lines = [
26
- '',
27
- banner,
28
- '',
29
- ` ${bold('Pulse')} - a neural-signal like AI assistant for coding, research and file tasks.`,
30
- '',
31
- ` ${dim('Get started:')}`,
32
- ` ${cyan('cd project_dir')}`,
33
- ` ${cyan('pulse')}`,
34
- '',
35
- ]
36
-
37
- if (configPath) {
38
- // Print the fully resolved absolute path on its own, uncolored line so it
39
- // can be copied straight out of the terminal without ANSI escape codes.
40
- lines.push(` ${dim('Config ready at')}`)
41
- lines.push(` ${configPath}`)
42
- } else {
43
- lines.push(` ${dim('Config not created:')} ${error}`)
44
- lines.push(` ${dim('Run "pulse setup" later to create it.')}`)
45
- }
46
-
47
- lines.push('')
48
- console.log(lines.join('\n'))
49
- }
5
+ const { version } = createRequire(import.meta.url)('../package.json')
6
+ const color = shouldUseColor(process.env, process.stdout.isTTY === true)
50
7
 
51
8
  try {
52
9
  const configPath = defaultPulseConfigPath()
53
10
  await ensurePulseUserConfig(configPath)
54
- printWelcome({ configPath })
11
+ process.stdout.write(formatInstallWelcome({ version, configPath, color }))
55
12
  } catch (error) {
56
13
  const message = error instanceof Error ? error.message : String(error)
57
- printWelcome({ error: message })
14
+ process.stdout.write(formatInstallWelcome({ version, error: message, color }))
58
15
  }