@hunterzhu/pulse-cli 0.1.10 → 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.
- package/dist/bin.d.ts +1 -1
- package/dist/bin.js +103 -4
- package/dist/commands/mcp.d.ts +2 -0
- package/dist/commands/mcp.js +29 -0
- package/dist/commands/resume.js +5 -1
- package/dist/commands/run.js +7 -1
- package/dist/commands/scheduled.d.ts +2 -0
- package/dist/commands/scheduled.js +131 -0
- package/dist/commands/service.d.ts +2 -0
- package/dist/commands/service.js +81 -0
- package/dist/commands/template.d.ts +2 -0
- package/dist/commands/template.js +31 -0
- package/dist/components/App.js +7 -2
- package/dist/components/AskPrompt.js +1 -1
- package/dist/components/Header.d.ts +1 -1
- package/dist/components/Header.js +2 -3
- package/dist/components/HelpView.js +2 -1
- package/dist/components/InputArea.js +3 -1
- package/dist/components/MessageList.d.ts +2 -1
- package/dist/components/MessageList.js +2 -2
- package/dist/components/MessageViewport.d.ts +2 -6
- package/dist/components/MessageViewport.js +42 -69
- package/dist/components/StatusHud.d.ts +2 -1
- package/dist/components/StatusHud.js +4 -8
- package/dist/components/TextInput.d.ts +9 -0
- package/dist/components/TextInput.js +46 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +4 -0
- package/dist/hooks/useRun.js +33 -1
- package/dist/hooks/useSlashCommands.d.ts +1 -0
- package/dist/hooks/useSlashCommands.js +1 -0
- package/dist/utils/markdown.js +1 -1
- package/dist/utils/mouse.d.ts +6 -0
- package/dist/utils/mouse.js +11 -0
- package/dist/utils/transcript.d.ts +11 -0
- package/dist/utils/transcript.js +37 -0
- package/package.json +18 -6
package/dist/bin.d.ts
CHANGED
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, {
|
|
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,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
|
+
}
|
package/dist/commands/resume.js
CHANGED
|
@@ -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
|
-
|
|
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`);
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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,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,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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
|
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,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
|
+
}
|
package/dist/components/App.js
CHANGED
|
@@ -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:
|
|
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 '
|
|
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
|
|
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
|
|
5
|
+
export function Header({ title, cwd, model }) {
|
|
6
6
|
const cwdBasename = path.basename(cwd);
|
|
7
|
-
|
|
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 (
|
|
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 '
|
|
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 {
|
|
6
|
-
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}, [
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
5
|
-
const
|
|
6
|
-
return
|
|
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)
|
package/dist/hooks/useRun.js
CHANGED
|
@@ -184,7 +184,9 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
184
184
|
break;
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
|
-
|
|
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,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?.() },
|
package/dist/utils/markdown.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
"
|
|
35
|
-
"
|
|
46
|
+
"react": "^19.0.0",
|
|
47
|
+
"wrap-ansi": "10.0.2"
|
|
36
48
|
},
|
|
37
49
|
"devDependencies": {
|
|
38
50
|
"@types/react": "^19.0.0"
|