@hunterzhu/pulse-cli 0.1.11 → 0.2.0

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.
Files changed (37) hide show
  1. package/dist/bin.d.ts +1 -1
  2. package/dist/bin.js +103 -4
  3. package/dist/commands/mcp.d.ts +2 -0
  4. package/dist/commands/mcp.js +29 -0
  5. package/dist/commands/resume.js +5 -1
  6. package/dist/commands/run.js +7 -1
  7. package/dist/commands/scheduled.d.ts +2 -0
  8. package/dist/commands/scheduled.js +131 -0
  9. package/dist/commands/service.d.ts +2 -0
  10. package/dist/commands/service.js +81 -0
  11. package/dist/commands/template.d.ts +2 -0
  12. package/dist/commands/template.js +31 -0
  13. package/dist/components/App.js +7 -2
  14. package/dist/components/AskPrompt.js +1 -1
  15. package/dist/components/Header.d.ts +1 -1
  16. package/dist/components/Header.js +2 -3
  17. package/dist/components/HelpView.js +2 -1
  18. package/dist/components/InputArea.js +3 -1
  19. package/dist/components/MessageList.d.ts +2 -1
  20. package/dist/components/MessageList.js +2 -2
  21. package/dist/components/MessageViewport.d.ts +2 -6
  22. package/dist/components/MessageViewport.js +42 -69
  23. package/dist/components/StatusHud.d.ts +2 -1
  24. package/dist/components/StatusHud.js +4 -8
  25. package/dist/components/TextInput.d.ts +9 -0
  26. package/dist/components/TextInput.js +46 -0
  27. package/dist/config.d.ts +34 -0
  28. package/dist/config.js +4 -0
  29. package/dist/hooks/useRun.js +33 -1
  30. package/dist/hooks/useSlashCommands.d.ts +1 -0
  31. package/dist/hooks/useSlashCommands.js +1 -0
  32. package/dist/utils/markdown.js +1 -1
  33. package/dist/utils/mouse.d.ts +6 -0
  34. package/dist/utils/mouse.js +11 -0
  35. package/dist/utils/transcript.d.ts +11 -0
  36. package/dist/utils/transcript.js +37 -0
  37. package/package.json +18 -6
package/dist/bin.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
+ import { type LocalHostOptions } from '@hunterzhu/pulse-server';
3
3
  export declare const version: string;
4
4
  export interface Parsed {
5
5
  command: string;
package/dist/bin.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
- import { resolve } from 'node:path';
4
+ import { isAbsolute, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
6
7
  import { ensurePulseUserConfig, expandHome, loadPulseConfig } from './config.js';
7
8
  import { runInteractive } from './commands/interactive.js';
8
9
  import { runOneShot } from './commands/run.js';
@@ -10,6 +11,10 @@ import { runDoctor } from './commands/doctor.js';
10
11
  import { runSessions } from './commands/sessions.js';
11
12
  import { runResume } from './commands/resume.js';
12
13
  import { runSetup } from './commands/setup.js';
14
+ import { runScheduledCommand } from './commands/scheduled.js';
15
+ import { runServiceCommand } from './commands/service.js';
16
+ import { runTemplateCommand } from './commands/template.js';
17
+ import { runMcpDoctor } from './commands/mcp.js';
13
18
  import { isSameModulePath } from './utils/is-main-module.js';
14
19
  const packageManifest = createRequire(import.meta.url)('../package.json');
15
20
  export const version = packageManifest.version;
@@ -26,6 +31,11 @@ Usage:
26
31
  pulse sessions [options] list saved conversations
27
32
  pulse resume <conversation-id> [task] continue or recover a conversation
28
33
  pulse doctor [options] check local configuration
34
+ pulse schedule <add|list|pause|resume|remove|daemon> [options]
35
+ manage recurring tasks (daemon requires read-only or auto approval)
36
+ pulse service <install|status|start|stop|uninstall>
37
+ pulse template <list|show|run> [name] [values...]
38
+ pulse mcp doctor [server-id]
29
39
 
30
40
  Options:
31
41
  --cwd <path> workspace directory
@@ -36,6 +46,7 @@ Options:
36
46
  --max-output-tokens <n> maximum generated tokens (default 4096)
37
47
  --reasoning-effort <x> low, medium, or high
38
48
  --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)
39
50
  --auto-compact-percent <n> compact automatically at this percent of the context window (1-90, default 90)
40
51
  --format <text|jsonl> output format
41
52
  --resume enter the latest saved session
@@ -43,10 +54,12 @@ Options:
43
54
  --auto-approve approve local writes and shell execution on your behalf
44
55
  --approval-mode <mode> read-only, ask, or auto
45
56
  --allow-network enable public web search and fetch tools
57
+ --no-network disable network tools even when user config enables them
46
58
  --trust-workspace treat workspace .pulse/config.json as user-trusted
47
59
  --system-prompt <text> custom system instructions
48
60
  --system-prompt-file <path> load custom system instructions from file
49
61
  --mock-response <text> deterministic response for local debugging
62
+ --mock-task-assessment <json> deterministic verifier result for local debugging
50
63
  --live doctor: make one real provider request
51
64
  --no-color disable terminal styling
52
65
  --help, -h show this help
@@ -91,7 +104,7 @@ export function parse(argv) {
91
104
  options[key] = true;
92
105
  continue;
93
106
  }
94
- if (!command && ['run', 'sessions', 'resume', 'doctor', 'setup'].includes(arg)) {
107
+ if (!command && ['run', 'sessions', 'resume', 'doctor', 'setup', 'schedule', 'service', 'template', 'mcp'].includes(arg)) {
95
108
  command = arg;
96
109
  }
97
110
  else {
@@ -124,6 +137,8 @@ export async function hostOptions(parsed) {
124
137
  if (!profile)
125
138
  throw new Error(`MODEL_PROVIDER_NOT_FOUND:${providerName}`);
126
139
  const model = modelSelection.modelCode;
140
+ if (modelSelection.pricing && (!/^[A-Z]{3}$/.test(modelSelection.pricing.currency) || !Number.isFinite(modelSelection.pricing.inputPerMillion) || modelSelection.pricing.inputPerMillion < 0 || !Number.isFinite(modelSelection.pricing.outputPerMillion) || modelSelection.pricing.outputPerMillion < 0 || !modelSelection.pricing.version.trim()))
141
+ throw new Error(`INVALID_MODEL_PRICING:${activeModelName}`);
127
142
  const baseURL = profile.baseURL;
128
143
  const contextTokens = option(parsed.options, 'context-tokens') ?? process.env.PULSE_CONTEXT_TOKENS;
129
144
  const maxOutputTokens = option(parsed.options, 'max-output-tokens') ?? process.env.PULSE_MAX_OUTPUT_TOKENS;
@@ -180,18 +195,86 @@ export async function hostOptions(parsed) {
180
195
  if (modelDisplayNames.has(displayName))
181
196
  throw new Error(`DUPLICATE_MODEL_DISPLAY_NAME:${displayName}`);
182
197
  modelDisplayNames.add(displayName);
183
- return [[displayName, { provider: item.provider, model: item.modelCode }]];
198
+ return [[displayName, {
199
+ provider: item.provider,
200
+ model: item.modelCode,
201
+ ...(item.maxContextTokens === undefined ? {} : { maxContextTokens: item.maxContextTokens }),
202
+ ...(item.maxOutputTokens === undefined ? {} : { maxOutputTokens: item.maxOutputTokens }),
203
+ ...(item.reasoningEffort === undefined ? {} : { reasoningEffort: item.reasoningEffort }),
204
+ }]];
184
205
  }));
206
+ const taskRouting = config.taskRouting;
207
+ for (const [task, models] of Object.entries(taskRouting ?? {})) {
208
+ if (!Array.isArray(models) || models.length === 0 || models.some((model) => typeof model !== 'string' || !providerModels[model])) {
209
+ throw new Error(`INVALID_TASK_MODEL_ROUTE:${task}`);
210
+ }
211
+ if (new Set(models).size !== models.length)
212
+ throw new Error(`DUPLICATE_TASK_MODEL_ROUTE_CANDIDATE:${task}`);
213
+ }
185
214
  const cwd = requestedCwd ?? expandHome(config.cwd);
186
215
  const dataDir = expandHome(option(parsed.options, 'data-dir') ?? process.env.PULSE_DATA_DIR ?? config.dataDir);
187
216
  const mockResponse = option(parsed.options, 'mock-response');
217
+ const mockTaskAssessment = option(parsed.options, 'mock-task-assessment');
218
+ let mockTaskAssessments;
219
+ if (mockTaskAssessment !== undefined) {
220
+ try {
221
+ const value = JSON.parse(mockTaskAssessment);
222
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => !item || typeof item !== 'object' || Array.isArray(item)))
223
+ throw new Error('INVALID');
224
+ mockTaskAssessments = value;
225
+ }
226
+ catch {
227
+ throw new Error('INVALID_MOCK_TASK_ASSESSMENT: expected a non-empty JSON array of assessment objects');
228
+ }
229
+ }
188
230
  const requestedApprovalMode = option(parsed.options, 'approval-mode') ?? process.env.PULSE_APPROVAL_MODE;
189
231
  const approvalMode = requestedApprovalMode === 'read-only' || parsed.options['read-only'] === true
190
232
  ? 'read-only'
191
233
  : requestedApprovalMode === 'auto' || parsed.options['auto-approve'] === true || process.env.PULSE_AUTO_APPROVE === '1'
192
234
  ? 'auto'
193
235
  : requestedApprovalMode === 'ask' ? 'ask' : config.approvalMode;
194
- const allowNetwork = parsed.options['allow-network'] === true || process.env.PULSE_ALLOW_NETWORK === '1' ? true : config.allowNetwork;
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
+ const capabilitySettings = config.capabilities ?? {};
241
+ const skillRoots = capabilitySettings.trustedSkillRoots ?? [];
242
+ if (!Array.isArray(skillRoots) || skillRoots.some((root) => typeof root !== 'string' || !isAbsolute(expandHome(root) ?? root)))
243
+ throw new Error('SKILL_TRUSTED_ROOT_MUST_BE_ABSOLUTE');
244
+ const enabledCapabilityPacks = capabilitySettings.enabled ?? [];
245
+ if (!Array.isArray(enabledCapabilityPacks) || enabledCapabilityPacks.some((id) => typeof id !== 'string') || new Set(enabledCapabilityPacks).size !== enabledCapabilityPacks.length)
246
+ throw new Error('INVALID_ENABLED_CAPABILITY_LIST');
247
+ const skillNames = capabilitySettings.skills ?? [];
248
+ if (!Array.isArray(skillNames) || skillNames.some((name) => typeof name !== 'string'))
249
+ throw new Error('INVALID_SKILL_SELECTION');
250
+ const capabilityPacks = [createPdfCapabilityPack(), createSpreadsheetCapabilityPack(), createSkillCapabilityPack({ trustedRoots: skillRoots.map((root) => resolve(expandHome(root) ?? root)) })];
251
+ for (const [id, server] of Object.entries(capabilitySettings.mcpServers ?? {})) {
252
+ if (!server || typeof server.command !== 'string' || server.command.trim().length === 0 || (server.args !== undefined && (!Array.isArray(server.args) || server.args.some((arg) => typeof arg !== 'string'))))
253
+ throw new Error(`INVALID_MCP_SERVER_CONFIG:${id}`);
254
+ if (server.toolPolicies && Object.entries(server.toolPolicies).some(([name, policy]) => !name || !['read', 'write', 'external'].includes(policy)))
255
+ throw new Error(`INVALID_MCP_TOOL_POLICY:${id}`);
256
+ const resolvedEnv = { ...(server.env ?? {}) };
257
+ for (const [childName, sourceName] of Object.entries(server.envFrom ?? {})) {
258
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(childName) || !/^[A-Z_][A-Z0-9_]*$/i.test(sourceName))
259
+ throw new Error(`INVALID_MCP_ENV_REFERENCE:${id}`);
260
+ const value = process.env[sourceName];
261
+ if (value === undefined)
262
+ throw new Error(`MCP_ENVIRONMENT_REQUIRED:${id}:${sourceName}`);
263
+ resolvedEnv[childName] = value;
264
+ }
265
+ capabilityPacks.push(createMcpCapabilityPack(id, {
266
+ command: server.command,
267
+ args: server.args ?? [],
268
+ ...(server.cwd === undefined ? {} : { cwd: resolve(expandHome(server.cwd) ?? server.cwd) }),
269
+ ...(Object.keys(resolvedEnv).length === 0 ? {} : { env: resolvedEnv }),
270
+ ...(server.timeoutMs === undefined ? {} : { timeoutMs: server.timeoutMs }),
271
+ ...(server.toolPolicies === undefined ? {} : { toolPolicies: server.toolPolicies }),
272
+ }));
273
+ }
274
+ for (const id of enabledCapabilityPacks) {
275
+ if (!capabilityPacks.some((pack) => pack.manifest.id === id))
276
+ throw new Error(`UNKNOWN_ENABLED_CAPABILITY:${id}`);
277
+ }
195
278
  const rawSystemPrompt = option(parsed.options, 'system-prompt') ?? process.env.PULSE_SYSTEM_PROMPT ?? config.systemPrompt;
196
279
  const rawSystemPromptFile = option(parsed.options, 'system-prompt-file') ?? process.env.PULSE_SYSTEM_PROMPT_FILE ?? config.systemPromptFile;
197
280
  let systemPrompt = rawSystemPrompt;
@@ -218,13 +301,20 @@ export async function hostOptions(parsed) {
218
301
  provider,
219
302
  providerProfiles,
220
303
  providerModels,
304
+ modelPricing: Object.fromEntries(Object.values(config.models ?? {}).flatMap((item) => item.pricing ? [[item.displayName, item.pricing]] : [])),
305
+ ...(taskRouting === undefined ? {} : { taskRouting }),
306
+ capabilityPacks,
307
+ enabledCapabilityPacks,
308
+ capabilityConfig: { skills: skillNames },
221
309
  activeProviderCode: providerName,
222
310
  activeModel: activeModelName,
223
311
  ...(mockResponse === undefined ? {} : { mockResponse }),
312
+ ...(mockTaskAssessments === undefined ? {} : { mockTaskAssessments }),
224
313
  ...(approvalMode === undefined ? {} : { approvalMode }),
225
314
  ...(configuredMaxTurns === undefined ? {} : { maxTurns: configuredMaxTurns }),
226
315
  ...(configuredAutoCompactPercent === undefined ? {} : { autoCompactPercent: Math.min(90, configuredAutoCompactPercent) }),
227
316
  ...(allowNetwork === undefined ? {} : { allowNetwork }),
317
+ executionMode,
228
318
  };
229
319
  }
230
320
  export async function main() {
@@ -247,6 +337,15 @@ export async function main() {
247
337
  if (parsed.command === 'sessions') {
248
338
  return runSessions(options, option(parsed.options, 'format') ?? 'text', version);
249
339
  }
340
+ if (parsed.command === 'schedule') {
341
+ return runScheduledCommand(options, parsed.positionals, parsed.options);
342
+ }
343
+ if (parsed.command === 'service')
344
+ return runServiceCommand(options, parsed.positionals);
345
+ if (parsed.command === 'mcp' && parsed.positionals[0] === 'doctor')
346
+ return runMcpDoctor(options, parsed.positionals.slice(1));
347
+ if (parsed.command === 'template')
348
+ return runTemplateCommand(options, parsed.positionals, option(parsed.options, 'format') ?? 'text', version);
250
349
  if (parsed.command === 'run') {
251
350
  const task = parsed.positionals.join(' ').trim();
252
351
  if (!task)
@@ -0,0 +1,2 @@
1
+ import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
+ export declare function runMcpDoctor(options: LocalHostOptions, ids: string[]): Promise<number>;
@@ -0,0 +1,29 @@
1
+ export async function runMcpDoctor(options, ids) {
2
+ const selected = options.enabledCapabilityPacks ?? [];
3
+ const requested = ids[0] ? selected.filter(id => id === ids[0]) : selected.filter(id => options.capabilityPacks?.some(pack => pack.manifest.id === id && pack.manifest.kind === 'mcp'));
4
+ if (ids[0] && !requested.includes(ids[0]))
5
+ throw new Error(`MCP_SERVER_NOT_ENABLED:${ids[0]}`);
6
+ const rows = [];
7
+ for (const id of requested) {
8
+ const pack = options.capabilityPacks?.find(item => item.manifest.id === id && item.manifest.kind === 'mcp');
9
+ if (!pack)
10
+ continue;
11
+ let activation;
12
+ try {
13
+ activation = await pack.activate({ workspaceRoot: options.cwd ?? process.cwd(), config: options.capabilityConfig ?? {}, signal: new AbortController().signal });
14
+ rows.push({ id, status: 'connected', tools: activation.tools.map(tool => tool.manifest.name) });
15
+ }
16
+ catch (error) {
17
+ rows.push({ id, status: 'failed', error: error instanceof Error ? error.message.replace(/(API[_-]?KEY|TOKEN|SECRET|PASSWORD)\s*[:=]\s*[^\s,;]+/gi, '$1=[REDACTED]') : 'MCP_CONNECTION_FAILED' });
18
+ }
19
+ finally {
20
+ await activation?.dispose?.();
21
+ }
22
+ }
23
+ if (!rows.length) {
24
+ process.stdout.write('No enabled MCP servers. Configure capabilities.mcpServers and capabilities.enabled.\n');
25
+ return 1;
26
+ }
27
+ process.stdout.write(`${JSON.stringify({ servers: rows }, null, 2)}\n`);
28
+ return rows.some(row => row.status !== 'connected') ? 1 : 0;
29
+ }
@@ -47,12 +47,14 @@ export async function runResume(options, conversationId, task, format = 'text',
47
47
  }
48
48
  }
49
49
  const outcome = await run.outcome();
50
+ const taskOutcome = await run.taskOutcome();
50
51
  if (format === 'jsonl') {
51
52
  process.stdout.write(`${JSON.stringify({
52
53
  schemaVersion: 1,
53
54
  type: 'result',
54
55
  runId: run.id,
55
56
  status: outcome.status,
57
+ taskOutcome: taskOutcome ?? null,
56
58
  text: outcome.text ?? null,
57
59
  error: outcome.error ?? null,
58
60
  })}\n`);
@@ -66,7 +68,9 @@ export async function runResume(options, conversationId, task, format = 'text',
66
68
  else {
67
69
  process.stdout.write(`\n[${outcome.status}]\n`);
68
70
  }
69
- return outcome.status === 'succeeded' ? 0 : outcome.status === 'cancelled' ? 3 : 1;
71
+ if (outcome.status === 'cancelled')
72
+ return 3;
73
+ return outcome.status === 'succeeded' && (!taskOutcome || taskOutcome.status === 'accepted') ? 0 : 1;
70
74
  }
71
75
  catch (error) {
72
76
  process.stderr.write(`[错误] ${error instanceof Error ? error.message : String(error)}\n`);
@@ -49,12 +49,16 @@ export async function runOneShot(options, task, format = 'text', version = '0.1.
49
49
  }
50
50
  }
51
51
  const outcome = await run.outcome();
52
+ const taskOutcome = await run.taskOutcome();
53
+ const usage = await run.usage();
52
54
  if (format === 'jsonl') {
53
55
  process.stdout.write(`${JSON.stringify({
54
56
  schemaVersion: 1,
55
57
  type: 'result',
56
58
  runId: run.id,
57
59
  status: outcome.status,
60
+ taskOutcome: taskOutcome ?? null,
61
+ usage,
58
62
  text: outcome.text ?? null,
59
63
  error: outcome.error ?? null,
60
64
  })}\n`);
@@ -68,7 +72,9 @@ export async function runOneShot(options, task, format = 'text', version = '0.1.
68
72
  else {
69
73
  process.stdout.write(`\n[${outcome.status}]\n`);
70
74
  }
71
- return outcome.status === 'succeeded' ? 0 : outcome.status === 'cancelled' ? 3 : 1;
75
+ if (outcome.status === 'cancelled')
76
+ return 3;
77
+ return outcome.status === 'succeeded' && (!taskOutcome || taskOutcome.status === 'accepted') ? 0 : 1;
72
78
  }
73
79
  finally {
74
80
  unbindSignals();
@@ -0,0 +1,2 @@
1
+ import { type LocalHostOptions } from '@hunterzhu/pulse-server';
2
+ export declare function runScheduledCommand(options: LocalHostOptions, args: string[], flags: Record<string, string | boolean>): Promise<number>;
@@ -0,0 +1,131 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+ import { createLocalHost, MAX_SCHEDULED_TASK_INTERVAL_MS, MIN_SCHEDULED_TASK_INTERVAL_MS, ScheduledTaskStore, ScheduledTaskWorker, pulseDataPath } from '@hunterzhu/pulse-server';
4
+ function parseInterval(raw) {
5
+ if (!raw)
6
+ throw new Error('SCHEDULE_EVERY_REQUIRED');
7
+ const match = /^(\d+)(ms|s|m|h|d)$/.exec(raw.trim());
8
+ if (!match)
9
+ throw new Error('INVALID_SCHEDULE_INTERVAL: use 30s, 15m, 2h, or 1d');
10
+ const value = Number(match[1]);
11
+ const multiplier = match[2] === 'ms' ? 1 : match[2] === 's' ? 1_000 : match[2] === 'm' ? 60_000 : match[2] === 'h' ? 3_600_000 : 86_400_000;
12
+ const intervalMs = value * multiplier;
13
+ if (!Number.isSafeInteger(intervalMs) || intervalMs < MIN_SCHEDULED_TASK_INTERVAL_MS || intervalMs > MAX_SCHEDULED_TASK_INTERVAL_MS)
14
+ throw new Error('SCHEDULE_INTERVAL_OUT_OF_RANGE');
15
+ return intervalMs;
16
+ }
17
+ function storeFor(options) {
18
+ return new ScheduledTaskStore(join(resolve(options.dataDir ?? pulseDataPath()), 'scheduled-tasks.json'));
19
+ }
20
+ function output(value, format) {
21
+ process.stdout.write(format === 'jsonl' ? `${JSON.stringify(value)}\n` : `${JSON.stringify(value, null, 2)}\n`);
22
+ }
23
+ export async function runScheduledCommand(options, args, flags) {
24
+ const [action, id] = args;
25
+ if (!action)
26
+ throw new Error('SCHEDULE_ACTION_REQUIRED');
27
+ const store = storeFor(options);
28
+ if (action === 'add') {
29
+ const name = typeof flags.name === 'string' ? flags.name : undefined;
30
+ const instruction = args.slice(1).join(' ').trim();
31
+ if (!instruction)
32
+ throw new Error('SCHEDULE_TASK_REQUIRED');
33
+ const task = await store.create({ workspace: await realpath(resolve(options.cwd ?? process.cwd())), name: name ?? instruction.slice(0, 120), instruction, intervalMs: parseInterval(typeof flags.every === 'string' ? flags.every : undefined) });
34
+ output(task, typeof flags.format === 'string' ? flags.format : 'text');
35
+ return 0;
36
+ }
37
+ if (action === 'list') {
38
+ output(await store.list(), typeof flags.format === 'string' ? flags.format : 'text');
39
+ return 0;
40
+ }
41
+ if (action === 'recover') {
42
+ if (!id)
43
+ throw new Error('SCHEDULE_RECOVER_REQUIRES_ID');
44
+ if (flags['confirm-stopped'] !== true)
45
+ throw new Error('SCHEDULE_RECOVER_REQUIRES_CONFIRMATION: stop the worker and child processes, then pass --confirm-stopped');
46
+ const task = await store.get(id);
47
+ if (!task?.run?.quarantined)
48
+ throw new Error(`SCHEDULED_TASK_NOT_QUARANTINED:${id}`);
49
+ const recovered = await store.recoverQuarantined(id, task.run.id);
50
+ if (!recovered)
51
+ throw new Error('SCHEDULED_TASK_RECOVERY_RACE');
52
+ output(recovered, typeof flags.format === 'string' ? flags.format : 'text');
53
+ return 0;
54
+ }
55
+ if (action === 'pause' || action === 'resume' || action === 'remove') {
56
+ if (!id)
57
+ throw new Error(`SCHEDULE_${action.toUpperCase()}_REQUIRES_ID`);
58
+ const result = action === 'remove' ? await store.remove(id) : await store.pause(id, action === 'pause');
59
+ if (!result)
60
+ throw new Error(`SCHEDULED_TASK_NOT_FOUND:${id}`);
61
+ output(result, typeof flags.format === 'string' ? flags.format : 'text');
62
+ return 0;
63
+ }
64
+ if (action !== 'daemon' && action !== 'run-once')
65
+ throw new Error(`UNKNOWN_SCHEDULE_ACTION:${action}`);
66
+ if (options.approvalMode !== 'read-only' && options.approvalMode !== 'auto')
67
+ throw new Error('SCHEDULED_EXECUTION_REQUIRES_READ_ONLY_OR_AUTO_APPROVAL');
68
+ const host = createLocalHost(options);
69
+ await host.init();
70
+ let currentRun;
71
+ const worker = new ScheduledTaskWorker(store, async (task, signal) => {
72
+ if (signal.aborted)
73
+ throw Object.assign(new Error('SCHEDULED_RUN_ABORTED'), { code: 'SCHEDULED_RUN_ABORTED' });
74
+ if (!task.workspace)
75
+ throw new Error('SCHEDULED_TASK_WORKSPACE_REQUIRED: recreate this legacy task');
76
+ const conversation = await host.createConversation({ cwd: task.workspace, title: `Scheduled: ${task.name}` });
77
+ const run = await host.sendMessage(conversation.id, { text: task.instruction });
78
+ currentRun = run;
79
+ const abort = () => { void run.cancel('SCHEDULED_WORKER_STOPPING').catch(() => { }); };
80
+ signal.addEventListener('abort', abort, { once: true });
81
+ if (signal.aborted)
82
+ abort();
83
+ try {
84
+ for await (const _event of run.events) {
85
+ if (signal.aborted) {
86
+ await run.cancel('SCHEDULED_WORKER_STOPPING');
87
+ throw Object.assign(new Error('SCHEDULED_RUN_ABORTED'), { code: 'SCHEDULED_RUN_ABORTED' });
88
+ }
89
+ }
90
+ const outcome = await run.outcome();
91
+ const taskOutcome = await run.taskOutcome();
92
+ if (signal.aborted)
93
+ throw new Error('SCHEDULED_RUN_ABORTED');
94
+ if (outcome.status !== 'succeeded')
95
+ throw Object.assign(new Error(`RUNTIME_${outcome.status.toUpperCase()}`), { code: `RUNTIME_${outcome.status.toUpperCase()}` });
96
+ if (taskOutcome?.status !== 'accepted')
97
+ throw Object.assign(new Error(`TASK_${(taskOutcome?.status ?? 'unverified').toUpperCase()}`), { code: `TASK_${(taskOutcome?.status ?? 'unverified').toUpperCase()}` });
98
+ }
99
+ catch (error) {
100
+ // Runtime cancellation may settle before an uncooperative tool stops.
101
+ // Keep the durable claim until this worker process has exited.
102
+ if (signal.aborted)
103
+ throw Object.assign(new Error('SCHEDULED_TASK_CANCELLATION_UNCONFIRMED'), { code: 'SCHEDULED_TASK_CANCELLATION_UNCONFIRMED', cause: error });
104
+ throw error;
105
+ }
106
+ finally {
107
+ signal.removeEventListener('abort', abort);
108
+ if (currentRun === run)
109
+ currentRun = undefined;
110
+ }
111
+ });
112
+ const controller = new AbortController();
113
+ const stop = () => { controller.abort(); void currentRun?.cancel('SCHEDULED_WORKER_STOPPING'); };
114
+ process.once('SIGINT', stop);
115
+ process.once('SIGTERM', stop);
116
+ let runOnceSummary;
117
+ try {
118
+ if (action === 'run-once') {
119
+ runOnceSummary = await worker.runOnce(controller.signal);
120
+ output(runOnceSummary, typeof flags.format === 'string' ? flags.format : 'text');
121
+ }
122
+ else
123
+ await worker.start(controller.signal);
124
+ }
125
+ finally {
126
+ process.removeListener('SIGINT', stop);
127
+ process.removeListener('SIGTERM', stop);
128
+ await host.close();
129
+ }
130
+ return runOnceSummary && runOnceSummary.failed > 0 ? 1 : 0;
131
+ }
@@ -0,0 +1,2 @@
1
+ import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
+ export declare function runServiceCommand(options: LocalHostOptions, args: string[]): Promise<number>;
@@ -0,0 +1,81 @@
1
+ import { access, mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawnSync } from 'node:child_process';
6
+ const serviceId = 'com.hunterzhu.pulse.scheduler';
7
+ function checked(file, args) { const result = spawnSync(file, args, { encoding: 'utf8' }); if (result.status !== 0)
8
+ throw new Error(`${file} ${args[0]} failed: ${(result.stderr || result.stdout || '').trim()}`); }
9
+ function xml(value) { return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;'); }
10
+ function quoteSystemd(value) { return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`; }
11
+ export async function runServiceCommand(options, args) {
12
+ const [action] = args;
13
+ if (!['install', 'status', 'start', 'stop', 'uninstall'].includes(action ?? ''))
14
+ throw new Error('SERVICE_ACTION_REQUIRED');
15
+ const platform = process.platform;
16
+ const home = process.env.PULSE_HOME ?? homedir();
17
+ const executable = process.execPath;
18
+ const entry = fileURLToPath(new URL('../bin.js', import.meta.url));
19
+ const config = process.env.PULSE_CONFIG ?? join(home, '.pulse', 'config.json');
20
+ const data = options.dataDir ?? process.env.PULSE_DATA_DIR ?? join(home, '.pulse', 'data');
21
+ const cwd = options.cwd ?? process.cwd();
22
+ if (action === 'install') {
23
+ if (!['darwin', 'linux', 'win32'].includes(platform))
24
+ throw new Error(`SERVICE_PLATFORM_UNSUPPORTED:${platform}`);
25
+ if (platform === 'darwin') {
26
+ const directory = join(home, 'Library', 'LaunchAgents');
27
+ const file = join(directory, `${serviceId}.plist`);
28
+ await mkdir(directory, { recursive: true });
29
+ await writeFile(file, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${serviceId}</string><key>ProgramArguments</key><array>${[executable, entry, 'schedule', 'daemon', '--approval-mode', options.approvalMode === 'auto' ? 'auto' : 'read-only'].map(v => `<string>${xml(v)}</string>`).join('')}</array><key>WorkingDirectory</key><string>${xml(cwd)}</string><key>EnvironmentVariables</key><dict><key>PULSE_HOME</key><string>${xml(home)}</string><key>PULSE_CONFIG</key><string>${xml(config)}</string><key>PULSE_DATA_DIR</key><string>${xml(data)}</string></dict><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>30</integer><key>StandardOutPath</key><string>${xml(join(data, 'service.log'))}</string><key>StandardErrorPath</key><string>${xml(join(data, 'service.error.log'))}</string></dict></plist>\n`, { mode: 0o600 });
30
+ await mkdir(data, { recursive: true });
31
+ checked('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? 0}`, file]);
32
+ }
33
+ else if (platform === 'linux') {
34
+ const directory = join(home, '.config', 'systemd', 'user');
35
+ const file = join(directory, 'pulse-scheduler.service');
36
+ await mkdir(directory, { recursive: true });
37
+ await writeFile(file, `[Unit]\nDescription=Pulse scheduled task worker\n\n[Service]\nType=simple\nWorkingDirectory=${quoteSystemd(cwd)}\nEnvironment=${quoteSystemd(`PULSE_HOME=${home}`)}\nEnvironment=${quoteSystemd(`PULSE_CONFIG=${config}`)}\nEnvironment=${quoteSystemd(`PULSE_DATA_DIR=${data}`)}\nExecStart=${quoteSystemd(executable)} ${quoteSystemd(entry)} schedule daemon --approval-mode ${options.approvalMode === 'auto' ? 'auto' : 'read-only'}\nRestart=on-failure\nRestartSec=30s\n\n[Install]\nWantedBy=default.target\n`, { mode: 0o600 });
38
+ checked('systemctl', ['--user', 'daemon-reload']);
39
+ checked('systemctl', ['--user', 'enable', '--now', 'pulse-scheduler.service']);
40
+ }
41
+ else {
42
+ const task = 'Pulse Scheduler';
43
+ const command = `"${executable}" "${entry}" schedule daemon --approval-mode ${options.approvalMode === 'auto' ? 'auto' : 'read-only'}`;
44
+ checked('schtasks.exe', ['/Create', '/F', '/SC', 'ONLOGON', '/TN', task, '/TR', command]);
45
+ }
46
+ process.stdout.write(`Installed user scheduler service (${platform}); status: pulse service status\n`);
47
+ return 0;
48
+ }
49
+ if (platform === 'darwin') {
50
+ const file = join(home, 'Library', 'LaunchAgents', `${serviceId}.plist`);
51
+ if (action === 'status' && await access(file).then(() => false, () => true)) {
52
+ process.stdout.write('Scheduler service is not installed.\n');
53
+ return 0;
54
+ }
55
+ if (action === 'uninstall') {
56
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid?.() ?? 0}`, file], { stdio: 'ignore' });
57
+ await rm(file, { force: true });
58
+ return 0;
59
+ }
60
+ const result = spawnSync('launchctl', [action === 'status' ? 'print' : action === 'start' ? 'kickstart' : 'kill', ...(action === 'start' ? ['-k'] : action === 'stop' ? ['SIGTERM'] : []), `gui/${process.getuid?.() ?? 0}/${serviceId}`], { encoding: 'utf8' });
61
+ process.stdout.write(result.stdout || result.stderr || `${action}: ${result.status === 0 ? 'ok' : 'not running'}\n`);
62
+ return result.status === 0 ? 0 : 1;
63
+ }
64
+ if (platform === 'linux') {
65
+ if (action === 'status' && await access(join(home, '.config', 'systemd', 'user', 'pulse-scheduler.service')).then(() => false, () => true)) {
66
+ process.stdout.write('Scheduler service is not installed.\n');
67
+ return 0;
68
+ }
69
+ const verb = action === 'status' ? 'status' : action === 'start' ? 'start' : action === 'stop' ? 'stop' : 'disable';
70
+ const result = spawnSync('systemctl', ['--user', verb, ...(action === 'uninstall' ? ['--now'] : []), 'pulse-scheduler.service'], { encoding: 'utf8' });
71
+ if (action === 'uninstall')
72
+ await rm(join(home, '.config', 'systemd', 'user', 'pulse-scheduler.service'), { force: true });
73
+ process.stdout.write(result.stdout || result.stderr || '');
74
+ return result.status === 0 ? 0 : 1;
75
+ }
76
+ const task = 'Pulse Scheduler';
77
+ const verb = action === 'status' ? '/Query' : action === 'start' ? '/Run' : action === 'stop' ? '/End' : '/Delete';
78
+ const result = spawnSync('schtasks.exe', [verb, '/TN', task, ...(action === 'uninstall' ? ['/F'] : [])], { encoding: 'utf8' });
79
+ process.stdout.write(result.stdout || result.stderr || '');
80
+ return result.status === 0 ? 0 : 1;
81
+ }
@@ -0,0 +1,2 @@
1
+ import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
+ export declare function runTemplateCommand(options: LocalHostOptions, args: string[], format: string, version: string): Promise<number>;
@@ -0,0 +1,31 @@
1
+ import { runOneShot } from './run.js';
2
+ const templates = [
3
+ { version: 1, name: 'code-review', description: '审查当前工作区中的代码变更并给出有证据的问题清单。', parameters: ['focus'], deliverables: ['按严重程度排序的 findings', '文件路径与行号'], acceptance: ['每条问题有可复核代码证据', '不把风格偏好列作缺陷'], prompt: '审查当前工作区代码变更。重点:{{focus}}。只报告能从源码证明的问题,按严重度排序,给出路径和行号;若没有发现,说明审查范围和未覆盖部分。' },
4
+ { version: 1, name: 'research', description: '围绕问题检索资料并整理可追溯结论。', parameters: ['question', 'sources'], deliverables: ['结论摘要', '来源及支持的主张'], acceptance: ['区分事实与推断', '每项关键结论附来源'], prompt: '研究问题:{{question}}。优先使用这些来源范围:{{sources}}。给出结论、证据链接、更新时间和不确定性;无法核验的内容标明未知。' },
5
+ { version: 1, name: 'file-organization', description: '按规则整理工作区文件,先规划并遵守工具审批。', parameters: ['rules'], deliverables: ['整理后的文件', '移动清单'], acceptance: ['不覆盖已有目标', '总结每次移动'], prompt: '按以下规则整理当前工作区文件:{{rules}}。先检查现状并制定计划,再执行获批的移动;遇到目标冲突时跳过并报告。' },
6
+ { version: 1, name: 'project-health', description: '检查项目入口、构建、测试和近期变更,报告健康状态。', parameters: ['scope'], deliverables: ['检查结果', '风险与建议'], acceptance: ['运行检查前说明具体命令', '区分已验证和推测'], prompt: '检查项目健康状态,范围:{{scope}}。阅读项目脚本与文档,运行适用的本地只读检查,汇报证据、失败和未覆盖项;不得安装、发布或修改文件。' },
7
+ ];
8
+ function render(template, values) {
9
+ if (values.length !== template.parameters.length)
10
+ throw new Error(`TEMPLATE_ARGUMENTS_REQUIRED:${template.name}:${template.parameters.join(',')}`);
11
+ let prompt = template.prompt;
12
+ template.parameters.forEach((name, index) => { prompt = prompt.replaceAll(`{{${name}}}`, values[index] ?? ''); });
13
+ return prompt;
14
+ }
15
+ export async function runTemplateCommand(options, args, format, version) {
16
+ const [action, name, ...values] = args;
17
+ if (action === 'list') {
18
+ process.stdout.write(templates.map(item => `${item.name}\t${item.description}`).join('\n') + '\n');
19
+ return 0;
20
+ }
21
+ const template = templates.find(item => item.name === name);
22
+ if (!template)
23
+ throw new Error(action === 'run' || action === 'show' ? `TEMPLATE_NOT_FOUND:${name ?? ''}` : 'TEMPLATE_ACTION_REQUIRED');
24
+ if (action === 'show') {
25
+ process.stdout.write(`${JSON.stringify(template, null, 2)}\n`);
26
+ return 0;
27
+ }
28
+ if (action !== 'run')
29
+ throw new Error('TEMPLATE_ACTION_REQUIRED');
30
+ return runOneShot(options, render(template, values), format, version);
31
+ }
@@ -1,6 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback, useRef } from 'react';
3
- import { Box, Text, useApp, useInput } from 'ink';
3
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
4
4
  import { Header } from './Header.js';
5
5
  import { Welcome } from './Welcome.js';
6
6
  import { MessageList } from './MessageList.js';
@@ -18,7 +18,11 @@ import { useTokenStats } from '../hooks/useTokenStats.js';
18
18
  import { useSlashCommands } from '../hooks/useSlashCommands.js';
19
19
  export function App({ hostOptions, conversationId: initialConversationId, initialTask, resumeOnStart = false, version, }) {
20
20
  const { exit } = useApp();
21
+ const { stdout } = useStdout();
22
+ const [terminalRows, setTerminalRows] = useState(stdout.rows || 24);
23
+ useEffect(() => { const resize = () => setTerminalRows(stdout.rows || 24); stdout.on('resize', resize); return () => { stdout.off('resize', resize); }; }, [stdout]);
21
24
  const [mode, setMode] = useState('chat');
25
+ const [mouseEnabled, setMouseEnabled] = useState(true);
22
26
  const [showThinking, setShowThinking] = useState(false);
23
27
  const [verbosity, setVerbosity] = useState('normal');
24
28
  const [sessionsList, setSessionsList] = useState([]);
@@ -95,6 +99,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
95
99
  })();
96
100
  }, [hostReady, conversation, initialTask, isRunning, addUserMessage, addAssistantMessage, createConversation, sendMessage]);
97
101
  const { executeCommand, isSlashCommand } = useSlashCommands({
102
+ onMouse: (arg) => setMouseEnabled((current) => arg === 'off' ? false : arg === 'on' ? true : !current),
98
103
  onHelp: () => setMode('help'),
99
104
  onSessions: async () => {
100
105
  await loadSessions();
@@ -480,7 +485,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
480
485
  const title = conversation?.summary.title ?? 'New conversation';
481
486
  const modelName = host?.getModel() || hostOptions.provider?.defaultModel || hostOptions.provider?.provider || 'default';
482
487
  const providerName = host?.getProvider() || hostOptions.activeProviderCode || hostOptions.provider?.provider || 'default';
483
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsx(Header, { title: title, cwd: cwd, model: modelName, approvalMode: hostOptions.approvalMode ?? 'ask' }), messages.length === 0 && !isRunning && (_jsx(Welcome, { cwd: cwd, model: modelName, version: version })), messages.length > 0 && (_jsx(MessageList, { messages: messages, showThinking: showThinking, verbosity: verbosity, isRunning: isRunning })), isRunning && !approvalRequest && !askRequest && (_jsx(Box, { marginY: 1, children: _jsx(Spinner, { label: currentStep || '正在思考与执行...' }) })), runError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u9519\u8BEF: ", runError] }) })), conversationError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u4F1A\u8BDD\u6D88\u606F\u52A0\u8F7D\u5931\u8D25: ", conversationError] }) })), _jsx(StatusHud, { cwd: cwd, model: modelName, provider: providerName, approvalMode: hostOptions.approvalMode ?? 'ask', currentStep: currentStep, lanes: lanes }), approvalRequest ? (_jsx(ApprovalPrompt, { request: approvalRequest, inputMode: approvalInputMode, isFocused: approvalChoiceFocused && !approvalSubmitting, onApprove: () => {
488
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: terminalRows, children: [_jsx(Header, { title: title, cwd: cwd, model: modelName, approvalMode: hostOptions.approvalMode ?? 'ask' }), messages.length === 0 && !isRunning && (_jsx(Welcome, { cwd: cwd, model: modelName, version: version })), messages.length > 0 && (_jsx(MessageList, { mouseEnabled: mouseEnabled, messages: messages, showThinking: showThinking, verbosity: verbosity, isRunning: isRunning })), runError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u9519\u8BEF: ", runError] }) })), conversationError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u4F1A\u8BDD\u6D88\u606F\u52A0\u8F7D\u5931\u8D25: ", conversationError] }) })), _jsx(StatusHud, { isRunning: isRunning, cwd: cwd, model: modelName, provider: providerName, approvalMode: hostOptions.approvalMode ?? 'ask', currentStep: currentStep, lanes: lanes }), approvalRequest ? (_jsx(ApprovalPrompt, { request: approvalRequest, inputMode: approvalInputMode, isFocused: approvalChoiceFocused && !approvalSubmitting, onApprove: () => {
484
489
  void approveAction(approvalRequest.effectId, true);
485
490
  }, onDeny: (reason) => {
486
491
  void approveAction(approvalRequest.effectId, false, reason);
@@ -1,6 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
- import TextInput from 'ink-text-input';
3
+ import TextInput from './TextInput.js';
4
4
  import { useState } from 'react';
5
5
  import { theme } from '../theme.js';
6
6
  export function AskPrompt({ request, disabled = false, onReply }) {
@@ -4,5 +4,5 @@ interface Props {
4
4
  model: string;
5
5
  approvalMode?: 'read-only' | 'ask' | 'auto' | undefined;
6
6
  }
7
- export declare function Header({ title, cwd, model, approvalMode }: Props): import("react").JSX.Element;
7
+ export declare function Header({ title, cwd, model }: Props): import("react").JSX.Element;
8
8
  export {};
@@ -2,8 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { theme } from '../theme.js';
4
4
  import path from 'node:path';
5
- export function Header({ title, cwd, model, approvalMode = 'ask' }) {
5
+ export function Header({ title, cwd, model }) {
6
6
  const cwdBasename = path.basename(cwd);
7
- const approvalLabel = approvalMode === 'auto' ? 'AUTO' : approvalMode === 'read-only' ? 'READ-ONLY' : 'ASK';
8
- return (_jsxs(Box, { borderBottom: true, borderStyle: "single", borderColor: theme.border, borderTop: false, borderLeft: false, borderRight: false, width: "100%", justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.primary, bold: true, children: " PULSE " }), _jsx(Text, { color: "white", bold: true, children: title })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.dim, children: cwdBasename }), _jsx(Text, { color: theme.dim, children: "\u00B7" }), _jsx(Text, { color: theme.dim, children: model }), _jsx(Text, { color: approvalMode === 'auto' ? theme.warning : theme.dim, children: approvalLabel })] })] }));
7
+ return (_jsxs(Box, { borderBottom: true, borderStyle: "single", borderColor: theme.border, borderTop: false, borderLeft: false, borderRight: false, width: "100%", flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Text, { color: theme.primary, bold: true, children: " PULSE " }), _jsx(Text, { color: "white", wrap: "truncate-end", children: title.length > 36 ? `${title.slice(0, 36)}…` : title })] }), _jsxs(Box, { gap: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Text, { color: theme.dim, wrap: "truncate-end", children: cwdBasename }), _jsx(Text, { color: theme.dim, children: "\u00B7" }), _jsx(Text, { color: theme.dim, wrap: "truncate-end", children: model })] })] }));
9
8
  }
@@ -5,6 +5,7 @@ const COMMANDS = [
5
5
  {
6
6
  category: 'General',
7
7
  commands: [
8
+ { name: '/mouse on|off', desc: '开关滚轮/触控板滚动;关闭后可原生选择文本' },
8
9
  { name: '/help', desc: '显示帮助信息' },
9
10
  { name: '/status', desc: '查看会话状态' },
10
11
  { name: '/tools', desc: '列出可用工具' },
@@ -41,5 +42,5 @@ const COMMANDS = [
41
42
  },
42
43
  ];
43
44
  export function HelpView() {
44
- return (_jsx(Box, { flexDirection: "column", marginY: 1, children: COMMANDS.map((section, idx) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.primary, bold: true, children: section.category }), section.commands.map((cmd, cIdx) => (_jsxs(Box, { children: [_jsx(Box, { width: 28, children: _jsx(Text, { color: theme.accent, children: cmd.name }) }), _jsx(Text, { children: cmd.desc })] }, cIdx)))] }, idx))) }));
45
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { children: "\u6EDA\u8F6E / \u89E6\u63A7\u677F\u53CC\u6307\u6EDA\u52A8 \u00B7 Ctrl+P / Ctrl+N \u7FFB\u9875 \u00B7 Ctrl+G \u56DE\u5230\u5E95\u90E8\uFF08\u4E5F\u652F\u6301 Fn+\u2191 / Fn+\u2193\uFF09" }), COMMANDS.map((section, idx) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.primary, bold: true, children: section.category }), section.commands.map((cmd, cIdx) => (_jsxs(Box, { children: [_jsx(Box, { width: 28, children: _jsx(Text, { color: theme.accent, children: cmd.name }) }), _jsx(Text, { children: cmd.desc })] }, cIdx)))] }, idx)))] }));
45
46
  }
@@ -1,6 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
- import TextInput from 'ink-text-input';
3
+ import TextInput from './TextInput.js';
4
4
  import { useState } from 'react';
5
5
  import { theme } from '../theme.js';
6
6
  export function InputArea({ onSubmit, disabled, focus = true, placeholder }) {
@@ -11,6 +11,8 @@ export function InputArea({ onSubmit, disabled, focus = true, placeholder }) {
11
11
  useInput((input, key) => {
12
12
  if (disabled || !focus)
13
13
  return;
14
+ if (key.ctrl || key.meta || key.pageUp || key.pageDown)
15
+ return;
14
16
  if (key.return) {
15
17
  if (key.shift) {
16
18
  setAccumulatedLines((current) => [...current, value]);
@@ -1,9 +1,10 @@
1
1
  import type { DisplayMessage, Verbosity } from '../types.js';
2
2
  interface Props {
3
+ mouseEnabled?: boolean;
3
4
  messages: DisplayMessage[];
4
5
  showThinking?: boolean | undefined;
5
6
  verbosity?: Verbosity | undefined;
6
7
  isRunning?: boolean | undefined;
7
8
  }
8
- export declare function MessageList({ messages, showThinking, verbosity, isRunning }: Props): import("react").JSX.Element;
9
+ export declare function MessageList({ mouseEnabled, messages, showThinking, verbosity, isRunning }: Props): import("react").JSX.Element;
9
10
  export {};
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { MessageViewport } from './MessageViewport.js';
3
- export function MessageList({ messages, showThinking, verbosity = 'normal', isRunning = false }) {
4
- return _jsx(MessageViewport, { messages: messages, ...(showThinking === undefined ? {} : { showThinking }), verbosity: verbosity, isRunning: isRunning });
3
+ export function MessageList({ mouseEnabled = true, messages, showThinking, verbosity = 'normal', isRunning = false }) {
4
+ return _jsx(MessageViewport, { mouseEnabled: mouseEnabled, messages: messages, ...(showThinking === undefined ? {} : { showThinking }), verbosity: verbosity, isRunning: isRunning });
5
5
  }
@@ -1,14 +1,10 @@
1
1
  import type { DisplayMessage, Verbosity } from '../types.js';
2
2
  interface Props {
3
+ mouseEnabled?: boolean;
3
4
  messages: DisplayMessage[];
4
5
  showThinking?: boolean;
5
6
  verbosity?: Verbosity;
6
7
  isRunning?: boolean;
7
8
  }
8
- /**
9
- * A bounded message window. Ink has no built-in scroll viewport, so only the
10
- * visible message window is reconciled. This keeps streaming output from
11
- * re-rendering the whole transcript and leaves the header/HUD/input stable.
12
- */
13
- export declare function MessageViewport({ messages, showThinking, verbosity, isRunning }: Props): import("react").JSX.Element;
9
+ export declare function MessageViewport({ messages, verbosity, mouseEnabled }: Props): import("react").JSX.Element;
14
10
  export {};
@@ -1,77 +1,50 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useInput, useStdout } from 'ink';
3
- import { useEffect, useMemo, useState } from 'react';
2
+ import { Box, Text, useInput, useStdout, measureElement } from 'ink';
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
+ import { mouseEvent, enableMouse, disableMouse } from '../utils/mouse.js';
4
5
  import { theme } from '../theme.js';
5
- import { AssistantMessage } from './AssistantMessage.js';
6
- import { UserMessage } from './UserMessage.js';
7
- import { stripTerminalControls } from '../utils/ansi.js';
8
- const minimumViewportRows = 5;
9
- function messageWeight(message, width) {
10
- const contentWidth = Math.max(24, width - 8);
11
- const text = stripTerminalControls(message.text || message.thinking || '');
12
- const textRows = text.split('\n').reduce((total, line) => total + Math.max(1, Math.ceil(line.length / contentWidth)), 0);
13
- const toolRows = message.toolCalls?.length ? message.toolCalls.length + 1 : 0;
14
- return Math.max(2, textRows + toolRows + (message.role === 'system' ? 1 : 2));
15
- }
16
- function renderMessage(message, showThinking, verbosity, isLast) {
17
- return (_jsxs(Box, { flexDirection: "column", children: [message.role === 'user' && _jsx(UserMessage, { text: message.text, timestamp: message.createdAt }), message.role === 'assistant' && (_jsx(AssistantMessage, { text: message.text, thinking: message.thinking, showThinking: showThinking, verbosity: verbosity, toolCalls: message.toolCalls, tokenStats: message.tokenStats })), message.role === 'system' && (_jsx(Box, { paddingX: 1, marginY: 1, children: _jsx(Text, { color: theme.dim, italic: true, children: stripTerminalControls(message.text) }) })), !isLast && _jsx(Box, { height: 1 })] }, message.id));
18
- }
19
- /**
20
- * A bounded message window. Ink has no built-in scroll viewport, so only the
21
- * visible message window is reconciled. This keeps streaming output from
22
- * re-rendering the whole transcript and leaves the header/HUD/input stable.
23
- */
24
- export function MessageViewport({ messages, showThinking = false, verbosity = 'normal', isRunning = false }) {
6
+ import { transcriptLines, scrollAction } from '../utils/transcript.js';
7
+ export function MessageViewport({ messages, verbosity = 'normal', mouseEnabled = true }) {
25
8
  const { stdout } = useStdout();
26
- const [rows, setRows] = useState(stdout.rows || 24);
27
- const [scrollOffset, setScrollOffset] = useState(0);
28
- useEffect(() => {
29
- const onResize = () => setRows(stdout.rows || 24);
30
- stdout.on('resize', onResize);
31
- return () => {
32
- stdout.off('resize', onResize);
33
- };
34
- }, [stdout]);
35
- const viewportRows = Math.max(minimumViewportRows, rows - (isRunning ? 9 : 7));
36
- const width = stdout.columns || 100;
37
- const weights = useMemo(() => messages.map((message) => messageWeight(message, width)), [messages, width]);
38
- const totalRows = weights.reduce((sum, weight) => sum + weight, 0);
39
- const maxScroll = Math.max(0, totalRows - viewportRows);
40
9
  useEffect(() => {
41
- if (isRunning)
42
- setScrollOffset(0);
43
- else
44
- setScrollOffset((current) => Math.min(current, maxScroll));
45
- }, [isRunning, maxScroll, messages.length]);
46
- useInput((_input, key) => {
47
- if (key.pageUp) {
48
- setScrollOffset((current) => Math.min(maxScroll, current + Math.max(1, viewportRows - 2)));
49
- }
50
- else if (key.pageDown) {
51
- setScrollOffset((current) => Math.max(0, current - Math.max(1, viewportRows - 2)));
52
- }
53
- else if (key.home) {
54
- setScrollOffset(maxScroll);
10
+ if (!mouseEnabled || !stdout.isTTY)
11
+ return;
12
+ stdout.write(enableMouse);
13
+ return () => { stdout.write(disableMouse); };
14
+ }, [mouseEnabled, stdout]);
15
+ const box = useRef(null);
16
+ const [size, setSize] = useState({ width: stdout.columns || 80, height: 12 });
17
+ const [start, setStart] = useState(null);
18
+ useLayoutEffect(() => {
19
+ if (!box.current)
20
+ return;
21
+ const measured = measureElement(box.current);
22
+ setSize((previous) => previous.width === measured.width && previous.height === measured.height ? previous : measured);
23
+ });
24
+ useEffect(() => { const resize = () => setSize((s) => ({ ...s, width: stdout.columns || 80 })); stdout.on('resize', resize); return () => { stdout.off('resize', resize); }; }, [stdout]);
25
+ const lines = useMemo(() => transcriptLines(messages, size.width, verbosity), [messages, size.width, verbosity]);
26
+ const page = Math.max(1, size.height - 1);
27
+ const bottom = Math.max(0, lines.length - page);
28
+ const first = start === null ? bottom : Math.min(start, bottom);
29
+ useInput((input, key) => {
30
+ const mouse = mouseEvent(input);
31
+ if (mouse) {
32
+ if (mouseEnabled && mouse.wheel)
33
+ setStart((previous) => {
34
+ const next = Math.max(0, Math.min(bottom, (previous === null ? bottom : Math.min(previous, bottom)) + mouse.wheel));
35
+ return next === bottom ? null : next;
36
+ });
37
+ return;
55
38
  }
56
- else if (key.end) {
57
- setScrollOffset(0);
39
+ const action = scrollAction(input, key);
40
+ if (action === 'up')
41
+ setStart(Math.max(0, first - Math.max(1, page - 2)));
42
+ if (action === 'down') {
43
+ const next = Math.min(bottom, first + Math.max(1, page - 2));
44
+ setStart(next === bottom ? null : next);
58
45
  }
46
+ if (action === 'bottom')
47
+ setStart(null);
59
48
  });
60
- // scrollOffset is measured from the newest message: 0 means follow the
61
- // bottom, maxScroll means show the oldest visible window.
62
- let remaining = Math.max(0, totalRows - viewportRows - scrollOffset);
63
- let startIndex = 0;
64
- while (startIndex < weights.length && remaining >= weights[startIndex]) {
65
- remaining -= weights[startIndex];
66
- startIndex++;
67
- }
68
- let visibleRows = 0;
69
- let endIndex = startIndex;
70
- while (endIndex < messages.length && visibleRows < viewportRows + 1) {
71
- visibleRows += weights[endIndex];
72
- endIndex++;
73
- }
74
- const visibleMessages = messages.slice(startIndex, endIndex);
75
- const isAtBottom = scrollOffset === 0;
76
- return (_jsxs(Box, { flexDirection: "column", height: viewportRows, overflow: "hidden", children: [!isAtBottom && _jsx(Text, { color: theme.dim, children: "\u2191 PageUp \u67E5\u770B\u66F4\u65E9\u6D88\u606F \u00B7 PageDown \u8FD4\u56DE\u6700\u65B0" }), visibleMessages.length === 0 ? (_jsx(Box, { flexGrow: 1 })) : (visibleMessages.map((message, index) => renderMessage(message, showThinking, verbosity, startIndex + index === messages.length - 1))), maxScroll > 0 && isAtBottom && _jsx(Text, { color: theme.dim, children: "\u2193 \u5DF2\u5728\u6700\u65B0\u6D88\u606F \u00B7 PageUp \u6EDA\u52A8\u5386\u53F2" })] }));
49
+ return (_jsxs(Box, { ref: box, flexDirection: "column", flexGrow: 1, flexShrink: 1, flexBasis: 0, minHeight: 3, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", height: page, overflow: "hidden", children: _jsx(Text, { children: lines.slice(first, first + page).join('\n') }) }), _jsx(Text, { color: theme.dim, children: start === null ? (mouseEnabled ? '滚轮 / 双指滚动 · Ctrl+G 回到底部' : 'Ctrl+P 上翻 · Ctrl+N 下翻') : `历史 ${first + 1}–${Math.min(lines.length, first + page)}/${lines.length} · 滚动浏览 · Ctrl+G 回到底部` })] }));
77
50
  }
@@ -1,5 +1,6 @@
1
1
  import type { LaneDisplay } from '../types.js';
2
2
  interface Props {
3
+ isRunning?: boolean;
3
4
  cwd: string;
4
5
  model: string;
5
6
  provider: string;
@@ -7,5 +8,5 @@ interface Props {
7
8
  currentStep?: string | null;
8
9
  lanes: LaneDisplay[];
9
10
  }
10
- export declare function StatusHud({ cwd, model, provider, approvalMode, currentStep, lanes }: Props): import("react").JSX.Element;
11
+ export declare function StatusHud({ isRunning, approvalMode, currentStep, lanes }: Props): import("react").JSX.Element;
11
12
  export {};
@@ -1,12 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { Spinner } from './Spinner.js';
3
4
  import { theme } from '../theme.js';
4
- function laneLabel(lane) {
5
- const goal = lane.goal.length > 34 ? `${lane.goal.slice(0, 34)}…` : lane.goal;
6
- return `${lane.id.slice(0, 8)} ${lane.status}${lane.activity ? ` · ${lane.activity}` : ''}${goal ? ` · ${goal}` : ''}`;
7
- }
8
- export function StatusHud({ cwd, model, provider, approvalMode, currentStep, lanes }) {
9
- const activeLanes = lanes.filter((lane) => !['succeeded', 'failed', 'cancelled'].includes(lane.status));
10
- const approvalLabel = approvalMode === 'auto' ? 'auto' : approvalMode === 'read-only' ? '只读' : 'ask';
11
- return (_jsxs(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: theme.border, flexDirection: "column", paddingTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.primary, bold: true, children: "STATUS" }), _jsxs(Text, { color: theme.dim, children: ["model:", model] }), _jsxs(Text, { color: theme.dim, children: ["provider:", provider] }), _jsxs(Text, { color: approvalMode === 'auto' ? theme.warning : theme.dim, children: ["approval:", approvalLabel] }), _jsxs(Text, { color: activeLanes.length ? theme.accent : theme.dim, children: ["lanes:", activeLanes.length, "/", lanes.length] }), currentStep && _jsxs(Text, { color: theme.dim, children: ["\u00B7 ", currentStep] })] }), _jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: theme.dim, children: ["workspace:", cwd] }) }), lanes.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: lanes.slice(-4).map((lane) => (_jsx(Text, { color: lane.status === 'failed' ? theme.error : lane.status === 'succeeded' ? theme.success : theme.dim, children: laneLabel(lane) }, lane.id))) }))] }));
5
+ export function StatusHud({ isRunning, approvalMode, currentStep, lanes }) {
6
+ const active = lanes.filter((lane) => !['succeeded', 'failed', 'cancelled'].includes(lane.status));
7
+ return _jsxs(Box, { flexShrink: 0, gap: 1, children: [isRunning && _jsx(Spinner, {}), _jsxs(Text, { color: theme.dim, wrap: "truncate-end", children: [isRunning ? currentStep || '正在执行' : '就绪', " \u00B7 ", approvalMode === 'auto' ? '自动审批' : approvalMode === 'read-only' ? '只读' : '操作前询问', active.length > 1 ? ` · ${active.length} 个子任务` : '', " \u00B7 /help \u5E2E\u52A9 \u00B7 /verbose \u8BE6\u60C5"] })] });
12
8
  }
@@ -0,0 +1,9 @@
1
+ interface Props {
2
+ value: string;
3
+ onChange: (value: string) => void;
4
+ focus?: boolean;
5
+ placeholder?: string;
6
+ }
7
+ /** Keep global control shortcuts out of editable text, including on Mac terminals. */
8
+ export default function TextInput({ value, onChange, focus, placeholder }: Props): import("react").JSX.Element;
9
+ export {};
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, useInput } from 'ink';
3
+ import { useRef, useState } from 'react';
4
+ import { mouseEvent } from '../utils/mouse.js';
5
+ import { stripTerminalControls } from '../utils/ansi.js';
6
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
7
+ /** Keep global control shortcuts out of editable text, including on Mac terminals. */
8
+ export default function TextInput({ value, onChange, focus = true, placeholder = '' }) {
9
+ const [position, setPosition] = useState(null);
10
+ const editing = useRef({ value, position });
11
+ editing.current = { value, position };
12
+ const characters = Array.from(segmenter.segment(value), (part) => part.segment);
13
+ const cursor = position === null ? characters.length : Math.min(position, characters.length);
14
+ useInput((input, key) => {
15
+ const characters = Array.from(segmenter.segment(editing.current.value), (part) => part.segment);
16
+ const cursor = editing.current.position === null ? characters.length : Math.min(editing.current.position, characters.length);
17
+ const move = (next) => { editing.current.position = next; setPosition(next); };
18
+ if (mouseEvent(input))
19
+ return;
20
+ if (key.ctrl || key.meta || key.pageUp || key.pageDown || key.upArrow || key.downArrow || key.tab || key.escape || key.return)
21
+ return;
22
+ if (key.leftArrow || key.rightArrow || key.home || key.end) {
23
+ move(key.home ? 0 : key.end ? null : Math.max(0, Math.min(characters.length, cursor + (key.leftArrow ? -1 : 1))));
24
+ return;
25
+ }
26
+ const next = [...characters];
27
+ if (key.backspace || key.delete) {
28
+ if (!cursor)
29
+ return;
30
+ next.splice(cursor - 1, 1);
31
+ move(cursor - 1 === next.length ? null : cursor - 1);
32
+ }
33
+ else {
34
+ const inserted = Array.from(segmenter.segment(stripTerminalControls(input)), (part) => part.segment);
35
+ if (!inserted.length)
36
+ return;
37
+ next.splice(cursor, 0, ...inserted);
38
+ move(cursor === characters.length ? null : cursor + inserted.length);
39
+ }
40
+ editing.current.value = next.join('');
41
+ onChange(editing.current.value);
42
+ }, { isActive: focus });
43
+ if (!value)
44
+ return _jsxs(Text, { dimColor: true, children: [focus ? _jsx(Text, { inverse: true, children: placeholder.slice(0, 1) || ' ' }) : null, focus ? placeholder.slice(1) : placeholder] });
45
+ return _jsxs(Text, { children: [characters.slice(0, cursor).join(''), focus ? _jsx(Text, { inverse: true, children: characters[cursor] || ' ' }) : characters[cursor], characters.slice(cursor + 1).join('')] });
46
+ }
package/dist/config.d.ts CHANGED
@@ -24,6 +24,35 @@ export interface PulseCliModel {
24
24
  maxContextTokens?: number;
25
25
  maxOutputTokens?: number;
26
26
  reasoningEffort?: 'low' | 'medium' | 'high';
27
+ pricing?: {
28
+ currency: string;
29
+ inputPerMillion: number;
30
+ outputPerMillion: number;
31
+ version: string;
32
+ };
33
+ }
34
+ export type PulseModelTask = 'reason' | 'plan' | 'merge' | 'verify';
35
+ export interface PulseCliMcpServer {
36
+ command: string;
37
+ args?: string[];
38
+ cwd?: string;
39
+ /** Explicit environment overrides; secret values should be sourced through provider-specific env mechanisms where possible. */
40
+ env?: Record<string, string>;
41
+ /** Map child environment names to names of variables in Pulse's launch environment. */
42
+ envFrom?: Record<string, string>;
43
+ timeoutMs?: number;
44
+ /** Explicit trust decision for each remote tool; all unspecified tools remain external. */
45
+ toolPolicies?: Record<string, 'read' | 'write' | 'external'>;
46
+ }
47
+ export interface PulseCliCapabilities {
48
+ /** IDs must be explicitly enabled; workspace config cannot enable host extensions. */
49
+ enabled?: string[];
50
+ /** Names of host-installed skills to load as untrusted instructions. */
51
+ skills?: string[];
52
+ /** Additional absolute roots explicitly trusted by the user for installed skills. */
53
+ trustedSkillRoots?: string[];
54
+ /** Host-level MCP processes; these are executable trusted configuration. */
55
+ mcpServers?: Record<string, PulseCliMcpServer>;
27
56
  }
28
57
  export interface PulseCliConfig {
29
58
  cwd?: string;
@@ -36,7 +65,12 @@ export interface PulseCliConfig {
36
65
  models?: Record<string, PulseCliModel>;
37
66
  /** Active Pulse model display name. */
38
67
  activeModel?: string;
68
+ /** Ordered model display names per task; later entries act as fallbacks. */
69
+ taskRouting?: Partial<Record<PulseModelTask, string[]>>;
70
+ capabilities?: PulseCliCapabilities;
39
71
  approvalMode?: 'read-only' | 'ask' | 'auto';
72
+ /** Parallelism is opt-in and only exposes tools explicitly classified read-only. */
73
+ executionMode?: 'serial' | 'parallel-read';
40
74
  maxTurns?: number;
41
75
  /** Percent of maxContextTokens that triggers automatic history compaction. Clamped to 1–90. */
42
76
  autoCompactPercent?: number;
package/dist/config.js CHANGED
@@ -24,6 +24,7 @@ export const defaultPulseConfig = {
24
24
  },
25
25
  activeModel: 'mock',
26
26
  approvalMode: 'ask',
27
+ executionMode: 'serial',
27
28
  maxTurns: 32,
28
29
  autoCompactPercent: 90,
29
30
  allowNetwork: false,
@@ -81,8 +82,11 @@ export function sanitizeWorkspaceConfig(value) {
81
82
  return {
82
83
  ...(value.cwd === undefined ? {} : { cwd: value.cwd }),
83
84
  ...(value.dataDir === undefined ? {} : { dataDir: value.dataDir }),
85
+ // Workspace configs cannot launch processes, select host skills, or enable installed capabilities.
84
86
  ...(providers === undefined || Object.keys(providers).length === 0 ? {} : { providers }),
85
87
  ...(value.activeModel === undefined ? {} : { activeModel: value.activeModel }),
88
+ // Workspace configs may select a model, but routing affects provider and
89
+ // data-sharing choices and therefore remains user-config-only.
86
90
  ...(value.models === undefined ? {} : {
87
91
  models: Object.fromEntries(Object.entries(value.models).flatMap(([name, model]) => {
88
92
  if (!model.provider || !model.modelCode)
@@ -184,7 +184,9 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
184
184
  break;
185
185
  }
186
186
  }
187
- setCurrentStep(describeFactStatus(event.data));
187
+ // Runtime facts are diagnostic data, not user-facing prose.
188
+ if (typeof event.data === 'string' && event.data.startsWith('human.input.'))
189
+ setCurrentStep(describeFactStatus(event.data));
188
190
  break;
189
191
  case 'error':
190
192
  setError(String(event.data ?? '发生未知错误'));
@@ -201,6 +203,36 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
201
203
  setAskRequest(null);
202
204
  if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
203
205
  const completion = event.data;
206
+ const usage = completion.usage && typeof completion.usage === 'object' && !Array.isArray(completion.usage)
207
+ ? completion.usage
208
+ : undefined;
209
+ if (usage) {
210
+ const input = typeof usage.inputTokens === 'number' ? usage.inputTokens.toLocaleString() : '未知';
211
+ const output = typeof usage.outputTokens === 'number' ? usage.outputTokens.toLocaleString() : '未知';
212
+ const costs = Array.isArray(usage.providerCosts) ? usage.providerCosts.filter((item) => Boolean(item && typeof item === 'object' && typeof item.currency === 'string' && typeof item.amount === 'number')) : [];
213
+ const estimate = usage.estimatedCost && typeof usage.estimatedCost === 'object' ? usage.estimatedCost : undefined;
214
+ const costLabel = costs.length ? costs.map((item) => `${item.amount} ${item.currency}`).join(', ') : estimate && typeof estimate.amount === 'number' ? `估算 ${estimate.amount} ${String(estimate.currency)} (价格 ${String(estimate.pricingVersion)})` : '费用未知';
215
+ addAssistantMessage({ id: `run-usage-${run.id}`, role: 'system', text: `用量:输入 ${input} · 输出 ${output} · ${costLabel}${usage.completeness === 'partial' ? ' · 数据不完整' : ''}`, createdAt: new Date().toISOString(), runId: run.id });
216
+ }
217
+ const taskOutcome = completion.taskOutcome && typeof completion.taskOutcome === 'object' && !Array.isArray(completion.taskOutcome)
218
+ ? completion.taskOutcome
219
+ : undefined;
220
+ if (taskOutcome && typeof taskOutcome.status === 'string') {
221
+ const labels = {
222
+ accepted: '任务验收通过',
223
+ incomplete: '任务未完成',
224
+ unverifiable: '任务结果无法核验',
225
+ failed: '任务执行失败',
226
+ cancelled: '任务已取消',
227
+ };
228
+ addAssistantMessage({
229
+ id: `task-outcome-${run.id}`,
230
+ role: 'system',
231
+ text: labels[taskOutcome.status] ?? `任务验收状态:${taskOutcome.status}`,
232
+ createdAt: new Date().toISOString(),
233
+ runId: run.id,
234
+ });
235
+ }
204
236
  const completionError = completion.error;
205
237
  if (completion.status === 'failed' && completionError && typeof completionError === 'object' && !Array.isArray(completionError)) {
206
238
  const error = completionError;
@@ -1,5 +1,6 @@
1
1
  import type { SlashCommand } from '../types.js';
2
2
  export interface SlashCommandDependencies {
3
+ onMouse?: (args: string) => void | Promise<void>;
3
4
  onHelp?: () => void | Promise<void>;
4
5
  onStatus?: () => void | Promise<void>;
5
6
  onTools?: () => void | Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useCallback } from 'react';
2
2
  export function useSlashCommands(deps) {
3
3
  const commands = useMemo(() => [
4
+ { name: '/mouse', description: '滚轮和触控板滚动 [on|off]', execute: async (args) => deps.onMouse?.(args) },
4
5
  { name: '/help', aliases: ['/h'], description: '显示帮助信息', execute: async () => deps.onHelp?.() },
5
6
  { name: '/status', description: '查看状态', execute: async () => deps.onStatus?.() },
6
7
  { name: '/tools', description: '列出可用工具', execute: async () => deps.onTools?.() },
@@ -57,7 +57,7 @@ function renderToken(token) {
57
57
  }
58
58
  case 'codespan': {
59
59
  const t = token;
60
- return chalk.bgGray.white(` ${t.text} `);
60
+ return chalk.hex(theme.accent)(t.text);
61
61
  }
62
62
  case 'strong': {
63
63
  const t = token;
@@ -0,0 +1,6 @@
1
+ /** SGR mouse reporting. Ink removes the first ESC before delivering unknown keys. */
2
+ export declare function mouseEvent(input: string): {
3
+ wheel: number;
4
+ } | undefined;
5
+ export declare const enableMouse = "\u001B[?1000h\u001B[?1006h";
6
+ export declare const disableMouse = "\u001B[?1006l\u001B[?1000l";
@@ -0,0 +1,11 @@
1
+ /** SGR mouse reporting. Ink removes the first ESC before delivering unknown keys. */
2
+ export function mouseEvent(input) {
3
+ const match = /^(?:\x1b)?\[<(\d+);\d+;\d+([Mm])$/.exec(input);
4
+ if (!match)
5
+ return undefined;
6
+ const button = Number(match[1]);
7
+ const base = button & ~28; // Shift, Alt and Ctrl modifiers do not change direction.
8
+ return { wheel: match[2] === 'M' && base === 64 ? -3 : match[2] === 'M' && base === 65 ? 3 : 0 };
9
+ }
10
+ export const enableMouse = '\x1b[?1000h\x1b[?1006h';
11
+ export const disableMouse = '\x1b[?1006l\x1b[?1000l';
@@ -0,0 +1,11 @@
1
+ import type { DisplayMessage, Verbosity } from '../types.js';
2
+ /** One shared rendering and wrapping path; scroll offsets are actual terminal rows. */
3
+ export declare function transcriptLines(messages: DisplayMessage[], width: number, verbosity?: Verbosity): string[];
4
+ export declare function scrollAction(input: string, key: {
5
+ ctrl?: boolean;
6
+ pageUp?: boolean;
7
+ pageDown?: boolean;
8
+ meta?: boolean;
9
+ upArrow?: boolean;
10
+ downArrow?: boolean;
11
+ }): 'up' | 'down' | 'bottom' | undefined;
@@ -0,0 +1,37 @@
1
+ import wrapAnsi from 'wrap-ansi';
2
+ import chalk from 'chalk';
3
+ import { stripTerminalControls } from './ansi.js';
4
+ import { renderMarkdownToAnsi } from './markdown.js';
5
+ /** One shared rendering and wrapping path; scroll offsets are actual terminal rows. */
6
+ export function transcriptLines(messages, width, verbosity = 'normal') {
7
+ const blocks = messages.map((message) => {
8
+ const text = stripTerminalControls(message.text);
9
+ if (message.role === 'user')
10
+ return chalk.cyan(`你 › ${text}`);
11
+ if (message.role === 'system')
12
+ return chalk.dim(text);
13
+ const parts = [];
14
+ const tools = message.toolCalls ?? [];
15
+ if (verbosity !== 'quiet' && tools.length) {
16
+ const failed = tools.filter((tool) => tool.status === 'failed' || tool.status === 'cancelled');
17
+ parts.push(chalk.dim(`工具 · ${tools.filter((tool) => tool.status === 'succeeded').length}/${tools.length} 已完成${failed.length ? ` · ${failed.length} 未成功` : ''}`));
18
+ for (const tool of verbosity === 'verbose' ? tools : failed) {
19
+ const detail = `${tool.name} · ${tool.status}${verbosity === 'verbose' ? `\n${JSON.stringify(tool.arguments ?? {}, null, 2)}` : ''}${tool.result === undefined ? '' : `\n${typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}`}`;
20
+ parts.push(chalk.dim(stripTerminalControls(detail)));
21
+ }
22
+ }
23
+ if (text)
24
+ parts.push(chalk.cyan.bold('Pulse'), renderMarkdownToAnsi(text));
25
+ return parts.join('\n');
26
+ }).filter(Boolean);
27
+ return wrapAnsi(blocks.join('\n\n'), Math.max(8, width), { hard: true, trim: false }).split('\n');
28
+ }
29
+ export function scrollAction(input, key) {
30
+ if (key.pageUp || (key.ctrl && input === 'p'))
31
+ return 'up';
32
+ if (key.pageDown || (key.ctrl && input === 'n'))
33
+ return 'down';
34
+ if (key.ctrl && input === 'g')
35
+ return 'bottom';
36
+ return undefined;
37
+ }
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
+ "description": "A local-first AI task assistant for coding, research, and file workflows.",
5
+ "keywords": [
6
+ "ai",
7
+ "agent",
8
+ "assistant",
9
+ "cli",
10
+ "coding",
11
+ "research",
12
+ "task-runner"
13
+ ],
14
+ "homepage": "https://github.com/zhuhengtan/Pulse#readme",
4
15
  "repository": {
5
16
  "type": "git",
6
17
  "url": "https://github.com/zhuhengtan/Pulse"
@@ -24,15 +35,16 @@
24
35
  "registry": "https://registry.npmjs.org"
25
36
  },
26
37
  "dependencies": {
27
- "@hunterzhu/pulse-server": "0.1.11",
38
+ "@hunterzhu/pulse-server": "0.2.0",
39
+ "chalk": "^5.4.1",
40
+ "highlight.js": "^11.10.0",
28
41
  "ink": "^7.1.1",
29
- "react": "^19.0.0",
30
- "ink-spinner": "^5.0.0",
31
42
  "ink-select-input": "^6.2.0",
43
+ "ink-spinner": "^5.0.0",
32
44
  "ink-text-input": "^6.0.0",
33
45
  "marked": "^14.0.0",
34
- "highlight.js": "^11.10.0",
35
- "chalk": "^5.4.1"
46
+ "react": "^19.0.0",
47
+ "wrap-ansi": "10.0.2"
36
48
  },
37
49
  "devDependencies": {
38
50
  "@types/react": "^19.0.0"