@hunterzhu/pulse-cli 0.1.6 → 0.1.8
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 -0
- package/dist/bin.js +128 -30
- package/dist/commands/interactive.d.ts +2 -1
- package/dist/commands/interactive.js +21 -2
- package/dist/commands/resume.js +4 -0
- package/dist/commands/run.js +4 -0
- package/dist/components/App.d.ts +2 -1
- package/dist/components/App.js +165 -37
- package/dist/components/ApprovalPrompt.d.ts +4 -1
- package/dist/components/ApprovalPrompt.js +20 -15
- package/dist/components/AskPrompt.d.ts +8 -0
- package/dist/components/AskPrompt.js +57 -0
- package/dist/components/AssistantMessage.js +2 -1
- package/dist/components/Header.js +2 -1
- package/dist/components/HelpView.js +2 -1
- package/dist/components/InputArea.d.ts +2 -1
- package/dist/components/InputArea.js +13 -3
- package/dist/components/MessageList.d.ts +2 -1
- package/dist/components/MessageList.js +4 -11
- package/dist/components/MessageViewport.d.ts +14 -0
- package/dist/components/MessageViewport.js +77 -0
- package/dist/components/SessionList.js +43 -13
- package/dist/components/StatusHud.d.ts +11 -0
- package/dist/components/StatusHud.js +12 -0
- package/dist/components/ToolCallCard.js +27 -2
- package/dist/components/UserMessage.js +1 -1
- package/dist/config.d.ts +39 -7
- package/dist/config.js +49 -8
- package/dist/hooks/useConversation.d.ts +2 -1
- package/dist/hooks/useConversation.js +12 -5
- package/dist/hooks/useRun.d.ts +7 -3
- package/dist/hooks/useRun.js +133 -13
- package/dist/hooks/useSlashCommands.d.ts +1 -0
- package/dist/hooks/useSlashCommands.js +1 -0
- package/dist/types.d.ts +23 -1
- package/package.json +10 -5
- package/scripts/postinstall.mjs +11 -0
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Box, Text
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
3
4
|
import { theme } from '../theme.js';
|
|
4
5
|
import { describeApprovalTool } from '../utils/approval.js';
|
|
5
|
-
export function ApprovalPrompt({ request, onApprove, onDeny }) {
|
|
6
|
-
useInput((input) => {
|
|
7
|
-
if (input.length !== 1)
|
|
8
|
-
return;
|
|
9
|
-
const char = input.toLowerCase();
|
|
10
|
-
if (char === 'y') {
|
|
11
|
-
onApprove();
|
|
12
|
-
}
|
|
13
|
-
else if (char === 'n') {
|
|
14
|
-
onDeny('用户拒绝');
|
|
15
|
-
}
|
|
16
|
-
});
|
|
6
|
+
export function ApprovalPrompt({ request, isFocused = false, inputMode = false, onApprove, onDeny, onInput }) {
|
|
17
7
|
const tools = request.tools ?? [{ name: request.toolName, input: request.toolArgs }];
|
|
18
8
|
const previews = tools.map((tool) => describeApprovalTool(tool));
|
|
19
9
|
const truncated = previews.some((preview) => preview.truncated);
|
|
20
|
-
return (_jsxs(Box, { borderStyle: "
|
|
10
|
+
return (_jsxs(Box, { borderStyle: "double", borderColor: theme.warning, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 \u5BA1\u6279\u8BF7\u6C42" }), _jsx(Text, { color: theme.tool, children: request.toolName })] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: previews.map((preview, index) => (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.tool, bold: true, children: preview.name }), _jsx(Text, { color: theme.dim, children: preview.body })] }, `${preview.name}-${index}`))) }), truncated && (_jsx(Text, { color: theme.warning, children: "\u9884\u89C8\u5DF2\u622A\u65AD\u3002\u9009\u62E9\u201C\u540C\u610F\u201D\u4F1A\u6267\u884C\u4E0A\u9762\u5217\u51FA\u7684\u5B8C\u6574\u64CD\u4F5C\uFF0C\u800C\u4E0D\u53EA\u662F\u53EF\u89C1\u7247\u6BB5\u3002" })), request.prompt && (_jsx(Box, { children: _jsx(Text, { children: request.prompt }) })), request.digest && _jsxs(Text, { color: theme.dim, children: ["\u6458\u8981\u6821\u9A8C\uFF1A", request.digest] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: isFocused
|
|
11
|
+
? '审批选择已聚焦(↑↓ 选择,Enter 确认;Tab 切换到输入;默认拒绝)'
|
|
12
|
+
: inputMode
|
|
13
|
+
? '输入模式:可发送补充信息;按 Tab 返回审批选择'
|
|
14
|
+
: '审批选择处理中...' }) }), _jsx(SelectInput, { isFocused: isFocused, initialIndex: 1, items: [
|
|
15
|
+
{ label: '同意', value: 'approve' },
|
|
16
|
+
{ label: '拒绝', value: 'deny' },
|
|
17
|
+
{ label: '输入', value: 'input' },
|
|
18
|
+
], onSelect: (item) => {
|
|
19
|
+
if (item.value === 'approve')
|
|
20
|
+
onApprove();
|
|
21
|
+
else if (item.value === 'deny')
|
|
22
|
+
onDeny('用户拒绝');
|
|
23
|
+
else
|
|
24
|
+
onInput();
|
|
25
|
+
} }, request.effectId)] }));
|
|
21
26
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AskRequest } from '../types.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
request: AskRequest;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
onReply: (value: Record<string, unknown>) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function AskPrompt({ request, disabled, onReply }: Props): import("react").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { theme } from '../theme.js';
|
|
6
|
+
export function AskPrompt({ request, disabled = false, onReply }) {
|
|
7
|
+
const [value, setValue] = useState(request.defaultValue ?? '');
|
|
8
|
+
const [inputLines, setInputLines] = useState([]);
|
|
9
|
+
const [index, setIndex] = useState(0);
|
|
10
|
+
const [selected, setSelected] = useState([]);
|
|
11
|
+
const options = request.options ?? [];
|
|
12
|
+
const clampedIndex = options.length === 0 ? 0 : Math.min(Math.max(0, index), options.length - 1);
|
|
13
|
+
useInput((input, key) => {
|
|
14
|
+
if (disabled)
|
|
15
|
+
return;
|
|
16
|
+
if (request.type === 'input' && key.return) {
|
|
17
|
+
if (key.shift) {
|
|
18
|
+
setInputLines((current) => [...current, value]);
|
|
19
|
+
setValue('');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
onReply({ text: [...inputLines, value].join('\n') });
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if ((request.type !== 'choice' && request.type !== 'multi') || options.length === 0)
|
|
27
|
+
return;
|
|
28
|
+
if (key.upArrow) {
|
|
29
|
+
setIndex((current) => Math.max(0, current - 1));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (key.downArrow) {
|
|
33
|
+
setIndex((current) => Math.min(options.length - 1, current + 1));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (request.type === 'choice' && key.return) {
|
|
37
|
+
const option = options[Math.min(Math.max(0, index), options.length - 1)];
|
|
38
|
+
if (option)
|
|
39
|
+
onReply({ value: option.value });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (request.type !== 'multi')
|
|
43
|
+
return;
|
|
44
|
+
if (input === ' ') {
|
|
45
|
+
const option = options[Math.min(Math.max(0, index), options.length - 1)];
|
|
46
|
+
if (!option)
|
|
47
|
+
return;
|
|
48
|
+
setSelected((current) => current.includes(option.value) ? current.filter((item) => item !== option.value) : [...current, option.value]);
|
|
49
|
+
}
|
|
50
|
+
else if (key.return) {
|
|
51
|
+
if (selected.length < (request.min ?? 0) || (request.max !== undefined && selected.length > request.max))
|
|
52
|
+
return;
|
|
53
|
+
onReply({ values: selected });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return (_jsxs(Box, { borderStyle: "double", borderColor: theme.primary, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: theme.primary, bold: true, children: ["\u2726 \u9700\u8981\u4F60\u7684\u56DE\u7B54 \u00B7 ", request.toolName] }), _jsx(Text, { color: "white", children: request.prompt }), request.type === 'choice' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', option.label] }, `${optionIndex}:${option.value}`))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 \u79FB\u52A8\uFF0CEnter \u786E\u8BA4" })] })), request.type === 'multi' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', selected.includes(option.value) ? '[x] ' : '[ ] ', option.label] }, `${optionIndex}:${option.value}`))), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 \u79FB\u52A8\uFF0C\u7A7A\u683C\u9009\u62E9\uFF0CEnter \u786E\u8BA4", request.min ? `(至少 ${request.min} 项)` : ''] })] })), request.type === 'input' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [inputLines.map((line, lineIndex) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(Text, { children: line })] }, `${lineIndex}:${line}`))), _jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(TextInput, { focus: !disabled, value: value, onChange: setValue, placeholder: request.placeholder ?? '' })] })] }))] }));
|
|
57
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
3
4
|
import { ThinkingBlock } from './ThinkingBlock.js';
|
|
4
5
|
import { ToolCallCard } from './ToolCallCard.js';
|
|
5
6
|
import { TokenStats } from './TokenStats.js';
|
|
6
7
|
import { renderMarkdownToAnsi } from '../utils/markdown.js';
|
|
7
8
|
export function AssistantMessage({ text, thinking, showThinking, toolCalls, tokenStats, verbosity = 'normal', }) {
|
|
8
9
|
const renderedText = text ? renderMarkdownToAnsi(text) : '';
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [showThinking && thinking && (_jsx(ThinkingBlock, { content: thinking, visible: true })), verbosity !== 'quiet' && toolCalls && toolCalls.length > 0 && (_jsx(Box, { flexDirection: "column", gap: 1, marginY: 1, children: toolCalls.map((tc, idx) => (_jsx(ToolCallCard, { call: tc }, idx))) })), renderedText && (_jsx(Box, { children: _jsx(Text, { children: renderedText }) })), verbosity === 'verbose' && tokenStats && (_jsx(TokenStats, { inputTokens: tokenStats.inputTokens, outputTokens: tokenStats.outputTokens, durationMs: tokenStats.durationMs, estimatedCost: tokenStats.estimatedCost }))] }));
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingLeft: 1, borderStyle: "single", borderLeft: true, borderTop: false, borderRight: false, borderBottom: false, borderColor: theme.primary, children: [_jsx(Text, { color: theme.primary, bold: true, children: "Pulse" }), showThinking && thinking && (_jsx(ThinkingBlock, { content: thinking, visible: true })), verbosity !== 'quiet' && toolCalls && toolCalls.length > 0 && (_jsx(Box, { flexDirection: "column", gap: 1, marginY: 1, children: toolCalls.map((tc, idx) => (_jsx(ToolCallCard, { call: tc }, idx))) })), renderedText && (_jsx(Box, { children: _jsx(Text, { children: renderedText }) })), verbosity === 'verbose' && tokenStats && (_jsx(TokenStats, { inputTokens: tokenStats.inputTokens, outputTokens: tokenStats.outputTokens, durationMs: tokenStats.durationMs, estimatedCost: tokenStats.estimatedCost }))] }));
|
|
10
11
|
}
|
|
@@ -4,5 +4,6 @@ import { theme } from '../theme.js';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
export function Header({ title, cwd, model, approvalMode = 'ask' }) {
|
|
6
6
|
const cwdBasename = path.basename(cwd);
|
|
7
|
-
|
|
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 })] })] }));
|
|
8
9
|
}
|
|
@@ -17,6 +17,7 @@ const COMMANDS = [
|
|
|
17
17
|
category: 'Session',
|
|
18
18
|
commands: [
|
|
19
19
|
{ name: '/new', desc: '新建会话' },
|
|
20
|
+
{ name: '/resume', desc: '恢复上一次会话或未完成运行' },
|
|
20
21
|
{ name: '/sessions', desc: '交互式历史会话管理' },
|
|
21
22
|
{ name: '/delete [id]', desc: '删除指定历史会话' },
|
|
22
23
|
{ name: '/export [format]', desc: '导出会话 (markdown / json)' },
|
|
@@ -34,7 +35,7 @@ const COMMANDS = [
|
|
|
34
35
|
commands: [
|
|
35
36
|
{ name: '/model [name]', desc: '切换模型' },
|
|
36
37
|
{ name: '/thinking [level]', desc: '设置模型思考深度 (low/medium/high/off)' },
|
|
37
|
-
{ name: '/compact', desc: '
|
|
38
|
+
{ name: '/compact', desc: '手动压缩历史;默认在上下文 90% 时自动压缩' },
|
|
38
39
|
{ name: '/config', desc: '查看当前运行时配置' },
|
|
39
40
|
],
|
|
40
41
|
},
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
interface Props {
|
|
2
2
|
onSubmit: (text: string) => void;
|
|
3
3
|
disabled?: boolean;
|
|
4
|
+
focus?: boolean;
|
|
4
5
|
placeholder?: string;
|
|
5
6
|
}
|
|
6
|
-
export declare function InputArea({ onSubmit, disabled, placeholder }: Props): import("react").JSX.Element;
|
|
7
|
+
export declare function InputArea({ onSubmit, disabled, focus, placeholder }: Props): import("react").JSX.Element;
|
|
7
8
|
export {};
|
|
@@ -3,14 +3,24 @@ import { Box, Text, useInput } from 'ink';
|
|
|
3
3
|
import TextInput from 'ink-text-input';
|
|
4
4
|
import { useState } from 'react';
|
|
5
5
|
import { theme } from '../theme.js';
|
|
6
|
-
export function InputArea({ onSubmit, disabled, placeholder }) {
|
|
6
|
+
export function InputArea({ onSubmit, disabled, focus = true, placeholder }) {
|
|
7
7
|
const [value, setValue] = useState('');
|
|
8
8
|
const [accumulatedLines, setAccumulatedLines] = useState([]);
|
|
9
9
|
const [history, setHistory] = useState([]);
|
|
10
10
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
11
11
|
useInput((input, key) => {
|
|
12
|
-
if (disabled)
|
|
12
|
+
if (disabled || !focus)
|
|
13
13
|
return;
|
|
14
|
+
if (key.return) {
|
|
15
|
+
if (key.shift) {
|
|
16
|
+
setAccumulatedLines((current) => [...current, value]);
|
|
17
|
+
setValue('');
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
handleSubmit(value);
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
14
24
|
if (key.upArrow) {
|
|
15
25
|
if (historyIndex < history.length - 1) {
|
|
16
26
|
const nextIndex = historyIndex + 1;
|
|
@@ -51,5 +61,5 @@ export function InputArea({ onSubmit, disabled, placeholder }) {
|
|
|
51
61
|
};
|
|
52
62
|
const promptChar = accumulatedLines.length > 0 ? '…' : '›';
|
|
53
63
|
const promptColor = disabled ? theme.dim : theme.primary;
|
|
54
|
-
return (_jsxs(Box, { flexDirection: "column", children: [accumulatedLines.map((line, idx) => (_jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), _jsx(Text, { children: line })] }, idx))), _jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), disabled ? (_jsx(Text, { color: theme.dim, children: placeholder || '处理中...' })) : (_jsx(TextInput, { value: value, onChange: setValue,
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", children: [accumulatedLines.map((line, idx) => (_jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), _jsx(Text, { children: line })] }, idx))), _jsxs(Box, { children: [_jsxs(Text, { color: promptColor, children: [promptChar, " "] }), disabled ? (_jsx(Text, { color: theme.dim, children: placeholder || '处理中...' })) : (_jsx(TextInput, { focus: focus, value: value, onChange: setValue, placeholder: placeholder || '' }))] })] }));
|
|
55
65
|
}
|
|
@@ -3,6 +3,7 @@ interface Props {
|
|
|
3
3
|
messages: DisplayMessage[];
|
|
4
4
|
showThinking?: boolean | undefined;
|
|
5
5
|
verbosity?: Verbosity | undefined;
|
|
6
|
+
isRunning?: boolean | undefined;
|
|
6
7
|
}
|
|
7
|
-
export declare function MessageList({ messages, showThinking, verbosity }: Props): import("react").JSX.Element;
|
|
8
|
+
export declare function MessageList({ messages, showThinking, verbosity, isRunning }: Props): import("react").JSX.Element;
|
|
8
9
|
export {};
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { AssistantMessage } from './AssistantMessage.js';
|
|
6
|
-
import { stripTerminalControls } from '../utils/ansi.js';
|
|
7
|
-
export function MessageList({ messages, showThinking, verbosity = 'normal' }) {
|
|
8
|
-
return (_jsx(Box, { flexDirection: "column", children: messages.map((msg, index) => {
|
|
9
|
-
const isLast = index === messages.length - 1;
|
|
10
|
-
return (_jsxs(Box, { flexDirection: "column", children: [msg.role === 'user' && (_jsx(UserMessage, { text: msg.text, timestamp: msg.createdAt })), msg.role === 'assistant' && (_jsx(AssistantMessage, { text: msg.text, thinking: msg.thinking, showThinking: showThinking, verbosity: verbosity, toolCalls: msg.toolCalls, tokenStats: msg.tokenStats })), msg.role === 'system' && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.dim, italic: true, children: stripTerminalControls(msg.text) }) })), !isLast && _jsx(Box, { height: 1 })] }, msg.id || index));
|
|
11
|
-
}) }));
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
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 });
|
|
12
5
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DisplayMessage, Verbosity } from '../types.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
messages: DisplayMessage[];
|
|
4
|
+
showThinking?: boolean;
|
|
5
|
+
verbosity?: Verbosity;
|
|
6
|
+
isRunning?: boolean;
|
|
7
|
+
}
|
|
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;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
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';
|
|
4
|
+
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 }) {
|
|
25
|
+
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
|
+
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);
|
|
55
|
+
}
|
|
56
|
+
else if (key.end) {
|
|
57
|
+
setScrollOffset(0);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
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" })] }));
|
|
77
|
+
}
|
|
@@ -1,33 +1,63 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
|
-
import SelectInput from 'ink-select-input';
|
|
4
3
|
import { useState } from 'react';
|
|
5
4
|
import { theme } from '../theme.js';
|
|
6
5
|
import path from 'node:path';
|
|
6
|
+
const PAGE_SIZE = 10;
|
|
7
7
|
export function SessionList({ sessions, notice, onSelect, onDelete, onBack }) {
|
|
8
|
-
const [
|
|
8
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
9
9
|
const [pendingDelete, setPendingDelete] = useState(null);
|
|
10
|
-
|
|
10
|
+
const clampedIndex = sessions.length === 0 ? 0 : Math.min(Math.max(0, selectedIndex), sessions.length - 1);
|
|
11
|
+
const highlightedSession = sessions[clampedIndex];
|
|
12
|
+
useInput((input, key) => {
|
|
11
13
|
if (pendingDelete) {
|
|
12
|
-
if (input === 'd' &&
|
|
14
|
+
if (input === 'd' && onDelete) {
|
|
13
15
|
void Promise.resolve(onDelete(pendingDelete));
|
|
14
16
|
setPendingDelete(null);
|
|
15
17
|
}
|
|
16
|
-
else if (input === 'n' || input === 'q' || input === 'd') {
|
|
18
|
+
else if (input === 'n' || input === 'q' || input === 'd' || key.escape) {
|
|
17
19
|
setPendingDelete(null);
|
|
18
20
|
}
|
|
19
21
|
return;
|
|
20
22
|
}
|
|
21
|
-
if (input === 'q') {
|
|
23
|
+
if (input === 'q' || key.escape) {
|
|
22
24
|
onBack();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (input === 'd' && highlightedSession && onDelete) {
|
|
28
|
+
setPendingDelete(highlightedSession.id);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (key.upArrow || input === 'k') {
|
|
32
|
+
// Strictly clamp to top - NEVER wrap around on trackpad bounce
|
|
33
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
34
|
+
return;
|
|
23
35
|
}
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
if (key.downArrow || input === 'j') {
|
|
37
|
+
// Strictly clamp to bottom - NEVER wrap around on trackpad bounce
|
|
38
|
+
setSelectedIndex((prev) => Math.min(Math.max(0, sessions.length - 1), prev + 1));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (key.return) {
|
|
42
|
+
if (highlightedSession) {
|
|
43
|
+
onSelect(highlightedSession.id);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
26
46
|
}
|
|
27
47
|
});
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
const total = sessions.length;
|
|
49
|
+
// Windowing logic so the list never overflows terminal height
|
|
50
|
+
const halfPage = Math.floor(PAGE_SIZE / 2);
|
|
51
|
+
let startIndex = Math.max(0, clampedIndex - halfPage);
|
|
52
|
+
let endIndex = Math.min(total, startIndex + PAGE_SIZE);
|
|
53
|
+
if (endIndex - startIndex < PAGE_SIZE) {
|
|
54
|
+
startIndex = Math.max(0, endIndex - PAGE_SIZE);
|
|
55
|
+
}
|
|
56
|
+
const visibleSessions = sessions.slice(startIndex, endIndex);
|
|
57
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.primary, bold: true, children: "\u4F1A\u8BDD\u5217\u8868 (\u2191\u2193 \u9009\u62E9, Enter \u7EE7\u7EED, d \u5220\u9664, q \u8FD4\u56DE)" }) }), notice && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.error, children: notice }) })), pendingDelete && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.warning, children: "\u518D\u6309 d \u786E\u8BA4\u5220\u9664\uFF0C\u6309 n \u53D6\u6D88\u3002" }) })), total === 0 ? (_jsx(Text, { color: theme.dim, children: "\u6682\u65E0\u5386\u53F2\u4F1A\u8BDD\u3002\u6309 q \u8FD4\u56DE\u3002" })) : (_jsxs(Box, { flexDirection: "column", children: [startIndex > 0 && (_jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: theme.dim, children: ["\u25B2 \u4E0A\u65B9\u8FD8\u6709 ", startIndex, " \u4E2A\u4F1A\u8BDD..."] }) })), visibleSessions.map((s, idx) => {
|
|
58
|
+
const actualIndex = startIndex + idx;
|
|
59
|
+
const isSelected = actualIndex === clampedIndex;
|
|
60
|
+
const label = `${s.title.length > 30 ? s.title.slice(0, 30) + '...' : s.title} (${s.updatedAt}) - ${path.basename(s.cwd)}`;
|
|
61
|
+
return (_jsxs(Box, { children: [_jsx(Text, { ...(isSelected ? { color: theme.primary } : {}), children: isSelected ? '❯ ' : ' ' }), _jsx(Text, { ...(isSelected ? { color: theme.primary, bold: true } : {}), children: label })] }, s.id));
|
|
62
|
+
}), endIndex < total && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsxs(Text, { color: theme.dim, children: ["\u25BC \u4E0B\u65B9\u8FD8\u6709 ", total - endIndex, " \u4E2A\u4F1A\u8BDD..."] }) }))] }))] }));
|
|
33
63
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LaneDisplay } from '../types.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
cwd: string;
|
|
4
|
+
model: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
approvalMode: 'read-only' | 'ask' | 'auto';
|
|
7
|
+
currentStep?: string | null;
|
|
8
|
+
lanes: LaneDisplay[];
|
|
9
|
+
}
|
|
10
|
+
export declare function StatusHud({ cwd, model, provider, approvalMode, currentStep, lanes }: Props): import("react").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
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))) }))] }));
|
|
12
|
+
}
|
|
@@ -2,6 +2,25 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text, useFocus, useInput } from 'ink';
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { theme } from '../theme.js';
|
|
5
|
+
function resultText(value) {
|
|
6
|
+
if (typeof value === 'string')
|
|
7
|
+
return value;
|
|
8
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
9
|
+
const record = value;
|
|
10
|
+
const code = typeof record.code === 'string' ? record.code : undefined;
|
|
11
|
+
const message = typeof record.message === 'string' ? record.message : undefined;
|
|
12
|
+
if (code && message)
|
|
13
|
+
return `${code}: ${message}`;
|
|
14
|
+
if (message)
|
|
15
|
+
return message;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
5
24
|
export function ToolCallCard({ call, expanded: defaultExpanded = false, onToggle }) {
|
|
6
25
|
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
|
7
26
|
const { isFocused } = useFocus({ autoFocus: false });
|
|
@@ -21,11 +40,17 @@ export function ToolCallCard({ call, expanded: defaultExpanded = false, onToggle
|
|
|
21
40
|
case 'running': return _jsx(Text, { color: theme.warning, children: "\u23F3" });
|
|
22
41
|
case 'succeeded': return _jsx(Text, { color: theme.success, children: "\u2713" });
|
|
23
42
|
case 'failed': return _jsx(Text, { color: theme.error, children: "\u2717" });
|
|
43
|
+
case 'cancelled': return _jsx(Text, { color: theme.warning, children: "\u2298" });
|
|
24
44
|
default: return _jsx(Text, { children: "?" });
|
|
25
45
|
}
|
|
26
46
|
};
|
|
27
47
|
const argsJson = call.arguments ? JSON.stringify(call.arguments, null, 2) : '{}';
|
|
28
|
-
const resultJson = call.result ? JSON.stringify(call.result)
|
|
48
|
+
const resultJson = call.result === undefined ? '' : JSON.stringify(call.result);
|
|
29
49
|
const resultSummary = resultJson.length > 500 ? resultJson.slice(0, 500) + '...' : resultJson;
|
|
30
|
-
|
|
50
|
+
const failureSummary = call.status === 'failed'
|
|
51
|
+
? resultText(call.result ?? '工具执行失败')
|
|
52
|
+
: call.status === 'cancelled'
|
|
53
|
+
? resultText(call.result ?? '工具执行已取消')
|
|
54
|
+
: '';
|
|
55
|
+
return (_jsxs(Box, { borderStyle: "single", borderLeft: true, borderTop: false, borderRight: false, borderBottom: false, borderColor: isFocused ? theme.tool : theme.border, flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.tool, children: "\u25C6" }), _jsx(Text, { color: theme.tool, bold: true, children: call.name }), getStatusIcon(), failureSummary && _jsxs(Text, { color: call.status === 'failed' ? theme.error : theme.warning, children: [" ", failureSummary] }), call.durationMs !== undefined && _jsxs(Text, { color: theme.dim, children: [call.durationMs, "ms"] }), !isExpanded && call.result !== undefined && _jsx(Text, { color: theme.dim, children: " \u00B7 \u5DF2\u8FD4\u56DE\u7ED3\u679C" })] }), isExpanded && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.dim, children: "\u53C2\u6570" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: theme.codeLang, children: argsJson }) }), call.result !== undefined && call.result !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.dim, children: call.status === 'failed' ? '错误:' : call.status === 'cancelled' ? '取消原因:' : '结果:' }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: theme.codeLang, children: resultSummary }) })] })) : null] })), _jsx(Text, { color: theme.dim, children: isExpanded ? 'Enter/Space 收起' : 'Enter/Space 展开详情' })] }));
|
|
31
56
|
}
|
|
@@ -3,5 +3,5 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { theme } from '../theme.js';
|
|
4
4
|
import { stripTerminalControls } from '../utils/ansi.js';
|
|
5
5
|
export function UserMessage({ text, timestamp }) {
|
|
6
|
-
return (_jsxs(Box, { marginY: 1,
|
|
6
|
+
return (_jsxs(Box, { marginY: 1, paddingLeft: 1, borderStyle: "single", borderLeft: true, borderTop: false, borderRight: false, borderBottom: false, borderColor: theme.user, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.user, bold: true, children: "YOU" }), timestamp && _jsx(Text, { color: theme.dim, dimColor: true, children: timestamp })] }), _jsx(Text, { color: "white", children: stripTerminalControls(text) })] }));
|
|
7
7
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,13 +1,45 @@
|
|
|
1
|
+
export interface PulseCliProviderProfile {
|
|
2
|
+
/** Adapter/protocol id. The map key is the user-facing provider code. */
|
|
3
|
+
provider: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
baseURL?: string;
|
|
6
|
+
apiKeyEnv?: string;
|
|
7
|
+
maxContextTokens?: number;
|
|
8
|
+
maxOutputTokens?: number;
|
|
9
|
+
reasoningEffort?: 'low' | 'medium' | 'high';
|
|
10
|
+
toolChoice?: 'auto' | 'required' | 'none' | {
|
|
11
|
+
type: 'function';
|
|
12
|
+
function: {
|
|
13
|
+
name: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface PulseCliModel {
|
|
18
|
+
/** Provider map key, not the adapter id. */
|
|
19
|
+
provider: string;
|
|
20
|
+
/** Exact model identifier sent to the provider. */
|
|
21
|
+
modelCode: string;
|
|
22
|
+
/** Globally unique name shown by the CLI and accepted by `/model`. */
|
|
23
|
+
displayName: string;
|
|
24
|
+
maxContextTokens?: number;
|
|
25
|
+
maxOutputTokens?: number;
|
|
26
|
+
reasoningEffort?: 'low' | 'medium' | 'high';
|
|
27
|
+
}
|
|
1
28
|
export interface PulseCliConfig {
|
|
2
29
|
cwd?: string;
|
|
3
30
|
dataDir?: string;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
31
|
+
systemPrompt?: string;
|
|
32
|
+
systemPromptFile?: string;
|
|
33
|
+
/** Named provider profiles. The map key is a stable provider code. */
|
|
34
|
+
providers?: Record<string, PulseCliProviderProfile>;
|
|
35
|
+
/** Globally unique Pulse model names mapped to provider/model codes. */
|
|
36
|
+
models?: Record<string, PulseCliModel>;
|
|
37
|
+
/** Active Pulse model display name. */
|
|
38
|
+
activeModel?: string;
|
|
10
39
|
approvalMode?: 'read-only' | 'ask' | 'auto';
|
|
40
|
+
maxTurns?: number;
|
|
41
|
+
/** Percent of maxContextTokens that triggers automatic history compaction. Clamped to 1–90. */
|
|
42
|
+
autoCompactPercent?: number;
|
|
11
43
|
allowNetwork?: boolean;
|
|
12
44
|
}
|
|
13
45
|
export declare const defaultPulseConfig: PulseCliConfig;
|
|
@@ -16,7 +48,7 @@ export declare function defaultPulseHomePath(): string;
|
|
|
16
48
|
/** Create the user config on first run without replacing an existing file. */
|
|
17
49
|
export declare function ensurePulseUserConfig(path?: string): Promise<void>;
|
|
18
50
|
export declare function expandHome(path: string | undefined): string | undefined;
|
|
19
|
-
/** Workspace files must not escalate approval, network, or
|
|
51
|
+
/** Workspace files must not escalate approval, network, provider credentials, or the system prompt. */
|
|
20
52
|
export declare function sanitizeWorkspaceConfig(value: PulseCliConfig): PulseCliConfig;
|
|
21
53
|
export declare function mergePulseConfigs(layers: Array<{
|
|
22
54
|
value: PulseCliConfig;
|