minimal-agent 0.5.4 → 0.5.6
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/package.json +1 -1
- package/src/context/archive.js +117 -0
- package/src/context/persistContext.js +33 -1
- package/src/context/sessionPath.js +59 -4
- package/src/context/sessionRegistry.js +104 -0
- package/src/llm/client.js +16 -2
- package/src/loop.js +191 -35
- package/src/prompts/system.js +16 -16
- package/src/tools/read/read.js +5 -1
- package/src/tools/shared/fileState.js +23 -0
- package/src/ui/App.js +155 -11
- package/src/ui/InputBox.js +122 -11
- package/src/ui/LiveArea.js +132 -0
- package/src/ui/MessageList.js +213 -12
- package/src/ui/ProgressPanel.js +54 -0
- package/src/ui/StatusLine.js +21 -2
- package/src/ui/StreamingBlock.js +11 -0
- package/src/ui/ToolStatus.js +2 -2
- package/src/ui/hooks/useChat.js +407 -105
- package/src/ui/hooks/useInputHistory.js +186 -0
- package/src/ui/hooks/useTerminalSize.js +31 -0
- package/src/ui/hooks/useTokenUsage.js +0 -1
- package/src/ui/liveViewport.js +176 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/ui/hooks/useInputHistory.ts —— 输入历史 ring buffer + 持久化
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* - ring buffer 上限 50 条
|
|
6
|
+
* - 持久化 ~/.minimal-agent/input-history.txt(一行一条 UTF-8)
|
|
7
|
+
* 转义 `\\` → `\\\\`,`\n` → `\\n`,避免多行输入被换行符撕碎
|
|
8
|
+
* - API:push / prev / next / search / resetCursor
|
|
9
|
+
* - 核心逻辑抽到 createInputHistoryStore,方便测试 DI 注入 fs
|
|
10
|
+
* hook 自身只是 useRef + useEffect 包一下,渲染零依赖
|
|
11
|
+
* ============================================================
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
14
|
+
import { mkdir as fsMkdir, readFile as fsReadFile, writeFile as fsWriteFile } from 'node:fs/promises';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
const MAX_ITEMS = 50;
|
|
18
|
+
const WRITE_DEBOUNCE_MS = 500;
|
|
19
|
+
/* ------------------------------------------------------------ *
|
|
20
|
+
* 转义:换行 + 反斜杠
|
|
21
|
+
* ------------------------------------------------------------ */
|
|
22
|
+
export function encodeHistoryLine(text) {
|
|
23
|
+
return text.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
|
|
24
|
+
}
|
|
25
|
+
export function decodeHistoryLine(line) {
|
|
26
|
+
// 状态机:遇到 \ 看下一个字符决定 \\ → \ 或 \n → 真换行
|
|
27
|
+
let out = '';
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (i < line.length) {
|
|
30
|
+
const ch = line[i];
|
|
31
|
+
if (ch === '\\' && i + 1 < line.length) {
|
|
32
|
+
const nxt = line[i + 1];
|
|
33
|
+
if (nxt === '\\') {
|
|
34
|
+
out += '\\';
|
|
35
|
+
i += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (nxt === 'n') {
|
|
39
|
+
out += '\n';
|
|
40
|
+
i += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
out += ch;
|
|
45
|
+
i += 1;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
export function createInputHistoryStore(opts = {}) {
|
|
50
|
+
const fs = opts.fs ?? {
|
|
51
|
+
readFile: (p) => fsReadFile(p, 'utf8'),
|
|
52
|
+
writeFile: (p, d) => fsWriteFile(p, d, 'utf8'),
|
|
53
|
+
mkdir: (p, o) => fsMkdir(p, o).then(() => undefined),
|
|
54
|
+
};
|
|
55
|
+
const historyPath = opts.historyPath ?? join(homedir(), '.minimal-agent', 'input-history.txt');
|
|
56
|
+
const debounceMs = opts.writeDebounceMs ?? WRITE_DEBOUNCE_MS;
|
|
57
|
+
let buffer = [];
|
|
58
|
+
let cursor = 0;
|
|
59
|
+
let writeTimer = null;
|
|
60
|
+
let pendingWrite = null;
|
|
61
|
+
async function doWrite() {
|
|
62
|
+
try {
|
|
63
|
+
await fs.mkdir(dirname(historyPath), { recursive: true });
|
|
64
|
+
const data = buffer.map(encodeHistoryLine).join('\n');
|
|
65
|
+
await fs.writeFile(historyPath, data);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// 持久化失败不影响 UI;下次 push 还会再试
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function scheduleWrite() {
|
|
72
|
+
if (debounceMs <= 0) {
|
|
73
|
+
pendingWrite = doWrite();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (writeTimer)
|
|
77
|
+
clearTimeout(writeTimer);
|
|
78
|
+
writeTimer = setTimeout(() => {
|
|
79
|
+
writeTimer = null;
|
|
80
|
+
pendingWrite = doWrite();
|
|
81
|
+
}, debounceMs);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
async load() {
|
|
85
|
+
try {
|
|
86
|
+
const raw = await fs.readFile(historyPath);
|
|
87
|
+
if (!raw)
|
|
88
|
+
return;
|
|
89
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
90
|
+
buffer = lines.slice(-MAX_ITEMS).map(decodeHistoryLine);
|
|
91
|
+
cursor = buffer.length;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// 文件不存在 / 读不出 → 用空 buffer 起步
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
push(text) {
|
|
98
|
+
if (!text)
|
|
99
|
+
return;
|
|
100
|
+
// 连续重复去重:跟最后一条相同就忽略
|
|
101
|
+
if (buffer.length > 0 && buffer[buffer.length - 1] === text) {
|
|
102
|
+
cursor = buffer.length;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
buffer.push(text);
|
|
106
|
+
while (buffer.length > MAX_ITEMS)
|
|
107
|
+
buffer.shift();
|
|
108
|
+
cursor = buffer.length;
|
|
109
|
+
scheduleWrite();
|
|
110
|
+
},
|
|
111
|
+
prev() {
|
|
112
|
+
if (buffer.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
if (cursor <= 0)
|
|
115
|
+
return null;
|
|
116
|
+
cursor -= 1;
|
|
117
|
+
return buffer[cursor] ?? null;
|
|
118
|
+
},
|
|
119
|
+
next() {
|
|
120
|
+
if (buffer.length === 0)
|
|
121
|
+
return null;
|
|
122
|
+
if (cursor >= buffer.length)
|
|
123
|
+
return null;
|
|
124
|
+
cursor += 1;
|
|
125
|
+
if (cursor >= buffer.length) {
|
|
126
|
+
// 已走到 "新输入" 位(buffer 之后),返 null 让 UI 清空
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return buffer[cursor] ?? null;
|
|
130
|
+
},
|
|
131
|
+
search(query) {
|
|
132
|
+
if (!query)
|
|
133
|
+
return [];
|
|
134
|
+
const hits = [];
|
|
135
|
+
// 反向遍历,最近优先;同字符串只收一次
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (let i = buffer.length - 1; i >= 0; i -= 1) {
|
|
138
|
+
const item = buffer[i];
|
|
139
|
+
if (item.includes(query) && !seen.has(item)) {
|
|
140
|
+
hits.push(item);
|
|
141
|
+
seen.add(item);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return hits;
|
|
145
|
+
},
|
|
146
|
+
resetCursor() {
|
|
147
|
+
cursor = buffer.length;
|
|
148
|
+
},
|
|
149
|
+
async flush() {
|
|
150
|
+
if (writeTimer) {
|
|
151
|
+
clearTimeout(writeTimer);
|
|
152
|
+
writeTimer = null;
|
|
153
|
+
pendingWrite = doWrite();
|
|
154
|
+
}
|
|
155
|
+
if (pendingWrite)
|
|
156
|
+
await pendingWrite;
|
|
157
|
+
},
|
|
158
|
+
_snapshot() {
|
|
159
|
+
return { buffer: [...buffer], cursor };
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/* ------------------------------------------------------------ *
|
|
164
|
+
* React hook 门面:store 用 useRef 持有,启动时 useEffect 拉盘
|
|
165
|
+
* ------------------------------------------------------------ */
|
|
166
|
+
export function useInputHistory(opts) {
|
|
167
|
+
// 同一组件实例共享同一 store;options 变化不重建(输入历史 hook 不需要重建语义)
|
|
168
|
+
const storeRef = useRef(null);
|
|
169
|
+
if (storeRef.current === null) {
|
|
170
|
+
storeRef.current = createInputHistoryStore(opts);
|
|
171
|
+
}
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
// 异步拉盘,不 await;UI 在拉盘前按 ↑ 只是空操作
|
|
174
|
+
storeRef.current?.load();
|
|
175
|
+
}, []);
|
|
176
|
+
// 暴露稳定门面,绑定到内部 store
|
|
177
|
+
return useMemo(() => {
|
|
178
|
+
return {
|
|
179
|
+
push: (t) => storeRef.current.push(t),
|
|
180
|
+
prev: () => storeRef.current.prev(),
|
|
181
|
+
next: () => storeRef.current.next(),
|
|
182
|
+
search: (q) => storeRef.current.search(q),
|
|
183
|
+
resetCursor: () => storeRef.current.resetCursor(),
|
|
184
|
+
};
|
|
185
|
+
}, []);
|
|
186
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/ui/hooks/useTerminalSize.ts —— 终端尺寸感知
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 返回当前终端的 rows / cols;监听 process.stdout 'resize' 事件,
|
|
6
|
+
* resize 时刷新 state,触发依赖此 hook 的组件 re-render(如 MessageList
|
|
7
|
+
* 根据 rows < 28 决定是否折叠 reasoning)。
|
|
8
|
+
*
|
|
9
|
+
* fallback 值:rows=24, cols=80(POSIX 终端历史默认)。
|
|
10
|
+
* 非 TTY 场景(pipe / 测试环境)下 process.stdout.rows 可能 undefined,
|
|
11
|
+
* fallback 兜底保证 UI 不崩。
|
|
12
|
+
* ============================================================
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useState } from 'react';
|
|
15
|
+
function readSize() {
|
|
16
|
+
return {
|
|
17
|
+
rows: process.stdout.rows ?? 24,
|
|
18
|
+
cols: process.stdout.columns ?? 80,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function useTerminalSize() {
|
|
22
|
+
const [size, setSize] = useState(readSize);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const update = () => setSize(readSize());
|
|
25
|
+
process.stdout.on('resize', update);
|
|
26
|
+
return () => {
|
|
27
|
+
process.stdout.off('resize', update);
|
|
28
|
+
};
|
|
29
|
+
}, []);
|
|
30
|
+
return size;
|
|
31
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/ui/liveViewport.ts —— LiveArea 有界多行 live 区的宽度安全工具(纯函数)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* ★ 两条硬不变量(根治 Windows 终端"思维链刷屏",缺一不可):
|
|
6
|
+
*
|
|
7
|
+
* Ink 默认 render 在主屏 scrollback 上用 log-update 维护**底部 live 区**:
|
|
8
|
+
* 每帧 `eraseLines(上一帧 \n 数)`(光标上移逐行清)再重写。两个坑都会让上移清除"漏擦":
|
|
9
|
+
*
|
|
10
|
+
* ┌─ INV-1 每行物理宽度 < 终端宽 ──────────────────────────────┐
|
|
11
|
+
* │ stock ink `log-update.js` 用 `output.split('\n').length` 记账, │
|
|
12
|
+
* │ **完全不补偿满宽行被终端自动折行**。若某行物理宽触到终端最后一列, │
|
|
13
|
+
* │ 终端自己折一行 → 实际物理行 > ink 记的 \n 数 → eraseLines 擦少 → │
|
|
14
|
+
* │ 它上面的行每帧 strand 进 scrollback = 刷屏。 │
|
|
15
|
+
* │ ∴ live 区每一行都按**我们自己的** displayWidth(CJK/全角/emoji=2, │
|
|
16
|
+
* │ 宁宽不窄)预钳到 ≤ cols - LIVE_RIGHT_MARGIN 再交给 ink。不依赖 │
|
|
17
|
+
* │ ink 的字宽测量 → 终端永不折行 → \n 记账 == 物理行 → 精确擦除。 │
|
|
18
|
+
* └────────────────────────────────────────────────────────┘
|
|
19
|
+
* ┌─ INV-2 live 区总高度 < 终端行数 ──────────────────────────┐
|
|
20
|
+
* │ `ink.js` onRender:一旦 outputHeight >= stdout.rows 会**全屏 │
|
|
21
|
+
* │ clearTerminal 重写**(含整个 static),灾难性。∴ 流式内容预览必须 │
|
|
22
|
+
* │ 有界(取尾部最近 N 行,N 自适应终端高度,见 LiveArea.contentMaxLines)。│
|
|
23
|
+
* └────────────────────────────────────────────────────────┘
|
|
24
|
+
*
|
|
25
|
+
* 完整思维链 / 完整答案 / 完整工具结果一律走 `<Static>`(write-once,永不
|
|
26
|
+
* 参与 log-update → 天然 strand-proof、高度不受限)。live 区只放**有界**的过程预览。
|
|
27
|
+
*
|
|
28
|
+
* ★ 中文/emoji 宽度(关键,别退化成按码点切):
|
|
29
|
+
* CJK / 全角 / emoji 1 码点 = 2 列。宽度必须按 charWidth 累加,不能按 length,
|
|
30
|
+
* 否则这些字符实际占 2× 列宽被算窄、钳不住 → 终端折行 → INV-1 失守、刷屏复发。
|
|
31
|
+
* ============================================================
|
|
32
|
+
*/
|
|
33
|
+
/** live 行右侧安全余量(列):钳到 cols - 此值,吃掉光标在"恰好满宽"处的自动折行边界。 */
|
|
34
|
+
export const LIVE_RIGHT_MARGIN = 2;
|
|
35
|
+
/** 剥离 ANSI/CSI 控制序列(含 ESC 引导符)。流式文本理论上没有,做防御性清理。 */
|
|
36
|
+
const ANSI_RE = new RegExp('\\u001b\\[[0-9;?]*[ -/]*[@-~]', 'g');
|
|
37
|
+
/**
|
|
38
|
+
* 单个 Unicode 码点的终端显示宽度:CJK / 全角 / emoji = 2,其余 = 1。
|
|
39
|
+
*
|
|
40
|
+
* 设计目标是做终端实际宽度的**上界估计**(宁可算宽、不可算窄):算宽只会让行
|
|
41
|
+
* 提前一两列截断(无害),算窄则会让钳制失效、终端折行(= 刷屏复发,INV-1 失守)。
|
|
42
|
+
*
|
|
43
|
+
* 覆盖:
|
|
44
|
+
* - 常见 East Asian Ambiguous 标点(…—–·“”‘’ 等):CJK 终端按全角(2)渲染,
|
|
45
|
+
* 这些是中文文本里最容易把行顶宽的"隐形 2 列"字符,统一按 2 计。
|
|
46
|
+
* - Emoji / 符号 / dingbat(💭✶ 等):终端普遍按全角(2)渲染。注意 charWidth 是
|
|
47
|
+
* 逐码点求和,ZWJ/变体序列会被算成各组件之和(偏大 = 安全方向)。
|
|
48
|
+
* - Hangul Jamo / CJK 部首笔画符号标点 / 假名 / CJK 统一 / 谚文音节 /
|
|
49
|
+
* 兼容表意 / 竖排标点 / 全角 ASCII 与符号 / CJK 扩展 B+(星平面)。
|
|
50
|
+
*/
|
|
51
|
+
export function charWidth(cp) {
|
|
52
|
+
// 常见 ambiguous-width 标点:CJK 终端按 2 列渲染,统一上界为 2 防折行
|
|
53
|
+
if (cp === 0x00b7 || // ·
|
|
54
|
+
cp === 0x2013 || // –
|
|
55
|
+
cp === 0x2014 || // —
|
|
56
|
+
cp === 0x2018 || // ‘
|
|
57
|
+
cp === 0x2019 || // ’
|
|
58
|
+
cp === 0x201c || // “
|
|
59
|
+
cp === 0x201d || // ”
|
|
60
|
+
cp === 0x2026 || // …
|
|
61
|
+
cp === 0x203b // ※
|
|
62
|
+
) {
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
// Emoji / 符号 / dingbat:终端普遍全角渲染(含 💭 与 spinner ✶✷✸✹✺)。上界=2,算宽无害。
|
|
66
|
+
if ((cp >= 0x2600 && cp <= 0x27bf) || // Misc Symbols + Dingbats(含 ✶✷✸✹✺)
|
|
67
|
+
(cp >= 0x2b00 && cp <= 0x2bff) || // Misc Symbols and Arrows
|
|
68
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors(VS16 强制全角)
|
|
69
|
+
(cp >= 0x1f000 && cp <= 0x1faff) // Emoji 主区(含 💭)
|
|
70
|
+
) {
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
if (cp >= 0x1100 &&
|
|
74
|
+
(cp <= 0x115f ||
|
|
75
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
76
|
+
(cp >= 0x3041 && cp <= 0xa4cf) ||
|
|
77
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
78
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
79
|
+
(cp >= 0xfe10 && cp <= 0xfe6f) ||
|
|
80
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
81
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
82
|
+
(cp >= 0x20000 && cp <= 0x3fffd))) {
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
/** 一段文本的显示宽度(按 charWidth 累加;不破坏 surrogate pair)。 */
|
|
88
|
+
export function displayWidth(text) {
|
|
89
|
+
let w = 0;
|
|
90
|
+
for (const ch of text) {
|
|
91
|
+
w += charWidth(ch.codePointAt(0) ?? 0);
|
|
92
|
+
}
|
|
93
|
+
return w;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 标准化流式文本:CRLF/CR → LF、剥离 ANSI、收敛尾部多余空行。
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeLiveText(text) {
|
|
99
|
+
return text
|
|
100
|
+
.replace(/\r\n?/g, '\n')
|
|
101
|
+
.replace(ANSI_RE, '')
|
|
102
|
+
.replace(/\n+$/g, '');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 取**前缀**:保留从头开始、显示宽度 ≤ maxWidth 的最长前缀(不破坏 surrogate pair)。
|
|
106
|
+
* 用于状态行的单行宽度钳制(保住最重要的行首 glyph/阶段,多余的尾部丢弃)。
|
|
107
|
+
*/
|
|
108
|
+
export function clampWidthStart(text, maxWidth) {
|
|
109
|
+
if (maxWidth <= 0)
|
|
110
|
+
return '';
|
|
111
|
+
let w = 0;
|
|
112
|
+
let out = '';
|
|
113
|
+
for (const ch of text) {
|
|
114
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
115
|
+
if (w + cw > maxWidth)
|
|
116
|
+
break;
|
|
117
|
+
out += ch;
|
|
118
|
+
w += cw;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 把(可能多行的)流式文本硬换行成**显示行数组**,每行显示宽度 ≤ maxWidth(满足 INV-1)。
|
|
124
|
+
*
|
|
125
|
+
* 规则:
|
|
126
|
+
* - 先按原始 `\n` 切段落(保留空行 → 输出空字符串行)。
|
|
127
|
+
* - 段落内贪心累加:超过 maxWidth 时,优先在**最近一个空格**处断行(word-aware,
|
|
128
|
+
* 英文不切断单词);没有空格(如中文)则按字符硬断。
|
|
129
|
+
* - 全程按 charWidth(CJK/全角/emoji=2)计宽,绝不按 length。
|
|
130
|
+
*
|
|
131
|
+
* 这是"打字机多行"的核心:reasoning / 答案的长文本在这里被切成等宽窄行,
|
|
132
|
+
* 再由 LiveArea 取尾部最近 N 行(满足 INV-2)。
|
|
133
|
+
*
|
|
134
|
+
* 导出供单测断言。
|
|
135
|
+
*/
|
|
136
|
+
export function wrapToDisplayLines(text, maxWidth) {
|
|
137
|
+
if (maxWidth <= 0)
|
|
138
|
+
return [];
|
|
139
|
+
const normalized = normalizeLiveText(text);
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const para of normalized.split('\n')) {
|
|
142
|
+
if (para.length === 0) {
|
|
143
|
+
out.push('');
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let line = '';
|
|
147
|
+
let lineWidth = 0;
|
|
148
|
+
let lastSpace = -1; // line 内最近一个空格的下标(断行机会)
|
|
149
|
+
for (const ch of para) {
|
|
150
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
151
|
+
// 超宽且当前行非空 → 先断行再放入 ch
|
|
152
|
+
if (lineWidth + cw > maxWidth && line.length > 0) {
|
|
153
|
+
if (lastSpace >= 0 && lastSpace < line.length - 1) {
|
|
154
|
+
// 在最近空格处断:head 不含该空格,余下部分(不含空格)顺延到下一行
|
|
155
|
+
const head = line.slice(0, lastSpace);
|
|
156
|
+
const rest = line.slice(lastSpace + 1);
|
|
157
|
+
out.push(head);
|
|
158
|
+
line = rest;
|
|
159
|
+
lineWidth = displayWidth(rest);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
out.push(line);
|
|
163
|
+
line = '';
|
|
164
|
+
lineWidth = 0;
|
|
165
|
+
}
|
|
166
|
+
lastSpace = -1;
|
|
167
|
+
}
|
|
168
|
+
line += ch;
|
|
169
|
+
lineWidth += cw;
|
|
170
|
+
if (ch === ' ')
|
|
171
|
+
lastSpace = line.length - 1;
|
|
172
|
+
}
|
|
173
|
+
out.push(line);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|