@hunterzhu/pulse-cli 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +55 -0
  2. package/dist/bin.d.ts +1 -1
  3. package/dist/bin.js +104 -4
  4. package/dist/commands/mcp.d.ts +2 -0
  5. package/dist/commands/mcp.js +29 -0
  6. package/dist/commands/resume.js +5 -1
  7. package/dist/commands/run.js +7 -1
  8. package/dist/commands/scheduled.d.ts +2 -0
  9. package/dist/commands/scheduled.js +131 -0
  10. package/dist/commands/service.d.ts +2 -0
  11. package/dist/commands/service.js +81 -0
  12. package/dist/commands/template.d.ts +2 -0
  13. package/dist/commands/template.js +31 -0
  14. package/dist/components/App.js +8 -2
  15. package/dist/components/AskPrompt.js +1 -1
  16. package/dist/components/Header.d.ts +1 -1
  17. package/dist/components/Header.js +2 -3
  18. package/dist/components/HelpView.js +2 -1
  19. package/dist/components/InputArea.js +3 -1
  20. package/dist/components/MessageList.d.ts +2 -1
  21. package/dist/components/MessageList.js +2 -2
  22. package/dist/components/MessageViewport.d.ts +2 -6
  23. package/dist/components/MessageViewport.js +42 -69
  24. package/dist/components/StatusHud.d.ts +2 -1
  25. package/dist/components/StatusHud.js +4 -8
  26. package/dist/components/TextInput.d.ts +9 -0
  27. package/dist/components/TextInput.js +46 -0
  28. package/dist/config.d.ts +36 -0
  29. package/dist/config.js +4 -0
  30. package/dist/hooks/useRun.js +33 -1
  31. package/dist/hooks/useSlashCommands.d.ts +1 -0
  32. package/dist/hooks/useSlashCommands.js +1 -0
  33. package/dist/utils/markdown.js +1 -1
  34. package/dist/utils/mouse.d.ts +6 -0
  35. package/dist/utils/mouse.js +11 -0
  36. package/dist/utils/transcript.d.ts +11 -0
  37. package/dist/utils/transcript.js +37 -0
  38. package/package.json +18 -6
@@ -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();
@@ -178,6 +183,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
178
183
  maxTurns: hostOptions.maxTurns ?? 32,
179
184
  autoCompactPercent: hostOptions.autoCompactPercent ?? 90,
180
185
  allowNetwork: hostOptions.allowNetwork ?? false,
186
+ networkHosts: hostOptions.networkHosts ?? [],
181
187
  }, null, 2),
182
188
  createdAt: new Date().toISOString(),
183
189
  });
@@ -480,7 +486,7 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
480
486
  const title = conversation?.summary.title ?? 'New conversation';
481
487
  const modelName = host?.getModel() || hostOptions.provider?.defaultModel || hostOptions.provider?.provider || 'default';
482
488
  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: () => {
489
+ 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
490
  void approveAction(approvalRequest.effectId, true);
485
491
  }, onDeny: (reason) => {
486
492
  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,10 +65,17 @@ 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;
77
+ /** Explicit network destinations; workspace configuration cannot grant these. */
78
+ networkHosts?: string[];
43
79
  allowNetwork?: boolean;
44
80
  }
45
81
  export declare const defaultPulseConfig: PulseCliConfig;
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
+ }