@zzs-fun/current-question 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/README.zh-CN.md +57 -0
- package/index.ts +793 -0
- package/package.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wszzs110
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# current-question
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
> A [pi coding agent](https://pi.dev) extension that pins your recent questions above the editor — navigate, expand, copy, and reply with context.
|
|
6
|
+
|
|
7
|
+
`current-question` is a pi extension that pins the last N user questions above the editor, so you can quickly find "which of my questions produced the answer below" in long sessions.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Shows the last N user questions (default 5; set with `/current-question <number>`)
|
|
12
|
+
- One line per question; long lines are truncated with `...` (correctly handles double-width CJK characters)
|
|
13
|
+
- Title carries the round counter: `❯ 当前问题(N)` where N is the current question round
|
|
14
|
+
- Collapsed by default; `Ctrl+Alt+L` expands the selected entry to show the full question + its answer, `Ctrl+Alt+H` collapses
|
|
15
|
+
- Collapsed: `Ctrl+Alt+H` hides the widget; hidden: `Ctrl+Alt+L` shows it again
|
|
16
|
+
- Expanded: `Ctrl+Alt+J/K` scrolls the answer; collapsed: `Ctrl+Alt+J/K` switches entries
|
|
17
|
+
(vim-style j=down/k=up; in expanded mode j scrolls the answer forward, in collapsed mode j goes to an earlier question)
|
|
18
|
+
- Selection can exceed the visible window: the list holds all history, the window shows a fixed `maxLines` rows and follows the selection; stops silently at top/bottom
|
|
19
|
+
- Expanded answer is shown in a fixed-height scrollable window; stops silently at top/bottom
|
|
20
|
+
- Expanded height adapts to the terminal: long questions are truncated, the answer window is dynamically compressed by terminal height to avoid hitting the top and causing streaming jitter
|
|
21
|
+
- `Ctrl+Alt+B` copies the selected entry's full question + answer
|
|
22
|
+
- `Ctrl+Alt+G` opens an external editor to compose a reply with the selected Q&A as context (vi-family editors auto-lock the history region read-only)
|
|
23
|
+
- After a new message is sent, the selection snaps back to the newest entry and collapses
|
|
24
|
+
- Uses `sessionManager` as the single source of truth; stays consistent after `/undo`, `/redo`, `/tree`, and compaction
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:@zzs-fun/current-question
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Shortcuts
|
|
33
|
+
|
|
34
|
+
| Shortcut | Collapsed | Expanded |
|
|
35
|
+
|----------|-----------|----------|
|
|
36
|
+
| `Ctrl+Alt+J` | Select an earlier question | Scroll answer down |
|
|
37
|
+
| `Ctrl+Alt+K` | Select a newer question | Scroll answer up |
|
|
38
|
+
| `Ctrl+Alt+L` | Expand the selected entry (full question + answer) | — |
|
|
39
|
+
| `Ctrl+Alt+H` | Hide the widget | Collapse the selected entry |
|
|
40
|
+
| `Ctrl+Alt+B` | Copy the full question + answer | Copy the full question + answer |
|
|
41
|
+
| `Ctrl+Alt+G` | Open external editor with Q&A context | Same |
|
|
42
|
+
|
|
43
|
+
> Note: overrides the editor's default `Ctrl+Alt+J` / `Ctrl+Alt+K` cursor movement.
|
|
44
|
+
|
|
45
|
+
## Command
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/current-question [on|off|toggle|<number>]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `on` / `off` / `toggle`: toggle the widget
|
|
52
|
+
- number: set how many entries to show (e.g. `/current-question 8`), and enables it
|
|
53
|
+
- no argument: equivalent to `toggle`
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# current-question
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
> pi 扩展:在编辑器上方置顶显示最近 N 条用户问题,支持切换/展开/复制/带上下文的外部编辑器回复。
|
|
6
|
+
|
|
7
|
+
pi coding agent 扩展,在编辑器上方固定显示「最近 n 条用户问题」,方便在长会话中快速定位「我哪条问题对应下面的回答」。
|
|
8
|
+
|
|
9
|
+
## 功能
|
|
10
|
+
|
|
11
|
+
- 显示最近 N 条用户问题(默认 5 条,`/current-question <数字>` 设置)
|
|
12
|
+
- 每条只展示一行,超长自动截断并加 `...`(正确处理中文等双宽字符)
|
|
13
|
+
- 标题带「第 N 轮」计数:`❯ 当前问题(N)`,N 为当前提问轮数
|
|
14
|
+
- 默认收起;选中条可 `Ctrl+Alt+L` 展开看问题全文 + 该轮回答,`Ctrl+Alt+H` 收起
|
|
15
|
+
- 收起态 `Ctrl+Alt+H` 隐藏当前问题;隐藏态 `Ctrl+Alt+L` 重新显示
|
|
16
|
+
- 展开态 `Ctrl+Alt+J/K` 滚动回答内容;收起态 `Ctrl+Alt+J/K` 切换问题条目
|
|
17
|
+
(vim 风格 j=下/k=上;展开时 j=往后看回答,收起时 j=更早的问题)
|
|
18
|
+
- 选择可超出显示窗口:列表含全部历史问题,窗口固定显示 maxLines 条,选中移出窗口时窗口随之滚动;到顶/到底静默停止
|
|
19
|
+
- 展开后回答以固定行数窗口显示,可滚动,到顶/到底静默停止
|
|
20
|
+
- 展开态高度自适应终端:问题全文超长截断,回答窗口按终端高度动态压缩,避免触碰顶部引发流式抖动
|
|
21
|
+
- `Ctrl+Alt+B` 同时复制选中条完整问题与回答
|
|
22
|
+
- `Ctrl+Alt+G` 带选中问答上下文打开外部编辑器编辑回复(vi 系列自动锁定历史区只读)
|
|
23
|
+
- 发新消息后选中自动归位到最新一条、收起
|
|
24
|
+
- 以 sessionManager 为唯一真相源,`/undo`、`/redo`、`/tree`、压缩后保持一致
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:@zzs-fun/current-question
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 快捷键
|
|
33
|
+
|
|
34
|
+
| 快捷键 | 收起态 | 展开态 |
|
|
35
|
+
|--------|--------|--------|
|
|
36
|
+
| `Ctrl+Alt+J` | 选更早问题 | 回答下滚 |
|
|
37
|
+
| `Ctrl+Alt+K` | 选更新问题 | 回答上滚 |
|
|
38
|
+
| `Ctrl+Alt+L` | 展开选中条(显示问题全文 + 回答) | — |
|
|
39
|
+
| `Ctrl+Alt+H` | 隐藏当前问题 | 收起选中条 |
|
|
40
|
+
| `Ctrl+Alt+B` | 复制选中条完整问题与回答 | 复制选中条完整问题与回答 |
|
|
41
|
+
| `Ctrl+Alt+G` | 带选中问答上下文打开外部编辑器回复 | 同左 |
|
|
42
|
+
|
|
43
|
+
> 注:会覆盖编辑器原 `Ctrl+Alt+J` / `Ctrl+Alt+K` 光标移动。
|
|
44
|
+
|
|
45
|
+
## 命令
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/current-question [on|off|toggle|<数字>]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `on` / `off` / `toggle`:切换开关
|
|
52
|
+
- 数字:设置显示条数(如 `/current-question 8`),并开启
|
|
53
|
+
- 不带参数:等价于 `toggle`
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「当前问题」置顶扩展 (current-question)
|
|
3
|
+
*
|
|
4
|
+
* 作者: jenson
|
|
5
|
+
*
|
|
6
|
+
* 功能:在编辑器上方固定显示「最近 n 条用户问题」,并支持快捷键选择/滚动。
|
|
7
|
+
*
|
|
8
|
+
* - 显示最近 n 条用户问题(默认 5 条,可用 /current-question <数字> 设置)
|
|
9
|
+
* - 每条只展示一行,超长自动截断并加 "..."
|
|
10
|
+
* - 标题带「第 N 轮」计数:❯ 当前问题(N),N 为当前提问轮数
|
|
11
|
+
* - 默认收起;选中条可用 Ctrl+Alt+L 展开看问题全文 + 该轮回答,Ctrl+Alt+H 收起
|
|
12
|
+
* - 收起态下 Ctrl+Alt+H 隐藏当前问题;隐藏态下 Ctrl+Alt+L 重新显示
|
|
13
|
+
* - 展开态下 Ctrl+Alt+J/K 滚动回答内容;收起态下 Ctrl+Alt+J/K 切换问题条目
|
|
14
|
+
* (vim 风格 j=下/k=上;展开时 j=往后看回答,收起时 j=更早的问题)
|
|
15
|
+
* 注:会覆盖编辑器原 ctrl+alt+j/ctrl+alt+k 光标移动
|
|
16
|
+
* - 选择可超出显示窗口:列表含全部历史问题,窗口固定显示 maxLines 条,
|
|
17
|
+
* 选中移出窗口时窗口随之滚动;到顶/到底静默停止
|
|
18
|
+
* - 展开后回答以固定行数窗口显示(ANSWER_VIEW_LINES),可滚动,到顶/到底静默停止
|
|
19
|
+
* - 展开态高度自适应终端:问题全文超 MAX_QUESTION_LINES 截断,回答窗口按终端高度
|
|
20
|
+
* 动态压缩,整体不超过 termRows - WIDGET_HEIGHT_RESERVED,避免触碰顶部引发流式抖动
|
|
21
|
+
* - 选中条可用 Ctrl+Alt+B 同时复制完整问题与回答
|
|
22
|
+
* - 发新消息后选中自动归位到最新一条、收起
|
|
23
|
+
* - 以 sessionManager 为唯一真相源,监听 message_end / session_start /
|
|
24
|
+
* session_tree / session_compact / agent_end 事件重建,确保 /undo、/redo、/tree、压缩后一致。
|
|
25
|
+
* 其中 agent_end 在 AI 回答完全结束后刷新一次(回答过程中不刷新,避免展开态高频抖动)
|
|
26
|
+
* - /current-question [on|off|toggle|<数字>] 切换开关或设置条数
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { copyToClipboard, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
31
|
+
import { spawn } from "node:child_process";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
/** widget 标识 */
|
|
37
|
+
const WIDGET_ID = "current-question";
|
|
38
|
+
|
|
39
|
+
/** 展开时回答区固定显示的行数,超出靠 Ctrl+Alt+J/K 滚动 */
|
|
40
|
+
const ANSWER_VIEW_LINES = 8;
|
|
41
|
+
/** 展开时问题全文最多显示的行数,超出截断(避免长问题撑爆 widget 高度触碰终端顶部) */
|
|
42
|
+
const MAX_QUESTION_LINES = 6;
|
|
43
|
+
/** 终端底部预留给 editor/footer/status/chat 的行数,widget 高度不超过 termRows - 此值。
|
|
44
|
+
* 根因:流式回答时 chat 每帧重建 Markdown,行数波动;若 widget 过高挤压 chat,使正在输出的
|
|
45
|
+
* assistant 消息起始行滚出视口,pi 的 firstChanged<viewportTop 判定会触发 fullRender,与
|
|
46
|
+
* differential 渲染交替造成抖动。留出 chat 缓冲让消息起始留在视口内即可避免。 */
|
|
47
|
+
const WIDGET_HEIGHT_RESERVED = 10;
|
|
48
|
+
|
|
49
|
+
/** 外部编辑器分节标记(行首严格匹配) */
|
|
50
|
+
const MARKER_QUESTION = ">> 问题";
|
|
51
|
+
const MARKER_ANSWER = ">> 回答";
|
|
52
|
+
const MARKER_REPLY = ">> 回复";
|
|
53
|
+
|
|
54
|
+
/** 缓存的 TUI 实例句柄,供外部编辑器流程暂停/恢复终端 */
|
|
55
|
+
interface TuiHandle {
|
|
56
|
+
stop(): void;
|
|
57
|
+
start(): void;
|
|
58
|
+
requestRender(full?: boolean): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* nvim/vim 专属锁定脚本(通过 nvim -S 加载)。
|
|
63
|
+
* 作用:① 光标定位到最后一行行尾 ② 把最后一个 “>> 回复” 标记行及以上区域设为只读,
|
|
64
|
+
* 防止误改历史问答;解析时仍以最后一个 “>> 回复” 标记为准,双重保险。
|
|
65
|
+
* 实现:InsertEnter 阻止进入插入 + TextChanged 检测改动并 undo 恢复。
|
|
66
|
+
*/
|
|
67
|
+
const NVIM_LOCK_SCRIPT = [
|
|
68
|
+
'" pi 外部编辑器上下文锁定脚本(nvim/vim 专属)',
|
|
69
|
+
'" 作用:① 光标定位到最后一行行尾 ② 把 “>> 回复” 标记行及以上区域设为只读,',
|
|
70
|
+
'" 防止误改历史问答;解析时仍以最后一个 “>> 回复” 标记为准,双重保险。',
|
|
71
|
+
'',
|
|
72
|
+
'" —— 1. 定位光标到文件末尾 ——',
|
|
73
|
+
'normal! G$',
|
|
74
|
+
'',
|
|
75
|
+
'" —— 2. 找到最后一个 “>> 回复” 标记行 ——',
|
|
76
|
+
'let g:pi_ctx_locked_end = 0',
|
|
77
|
+
'for s:i in range(1, line(\'$\'))',
|
|
78
|
+
' if getline(s:i) =~# \'^>>\\s*回复\\s*$\'',
|
|
79
|
+
' let g:pi_ctx_locked_end = s:i',
|
|
80
|
+
' endif',
|
|
81
|
+
'endfor',
|
|
82
|
+
'if g:pi_ctx_locked_end == 0',
|
|
83
|
+
' finish',
|
|
84
|
+
'endif',
|
|
85
|
+
'',
|
|
86
|
+
'" —— 3. 快照 + 阻止改动 ——',
|
|
87
|
+
'let g:pi_ctx_snapshot = copy(getline(1, g:pi_ctx_locked_end))',
|
|
88
|
+
'',
|
|
89
|
+
'function! PiCtxGuardInsert() abort',
|
|
90
|
+
' if line(\'.\') <= g:pi_ctx_locked_end',
|
|
91
|
+
' call feedkeys("\\<Esc>", \'n\')',
|
|
92
|
+
' echohl WarningMsg | echo "历史问答区只读,请在 \'>> 回复\' 下方编辑" | echohl None',
|
|
93
|
+
' endif',
|
|
94
|
+
'endfunction',
|
|
95
|
+
'autocmd InsertEnter <buffer> call PiCtxGuardInsert()',
|
|
96
|
+
'',
|
|
97
|
+
'function! PiCtxCheckLocked() abort',
|
|
98
|
+
' if exists(\'g:pi_ctx_restoring\') | return | endif',
|
|
99
|
+
' if getline(1, g:pi_ctx_locked_end) != g:pi_ctx_snapshot',
|
|
100
|
+
' let g:pi_ctx_restoring = 1',
|
|
101
|
+
' undo',
|
|
102
|
+
' unlet g:pi_ctx_restoring',
|
|
103
|
+
' echohl WarningMsg | echo "已恢复被改动的历史问答区" | echohl None',
|
|
104
|
+
' endif',
|
|
105
|
+
'endfunction',
|
|
106
|
+
'autocmd TextChanged,TextChangedI <buffer> call PiCtxCheckLocked()',
|
|
107
|
+
].join("\n") + "\n";
|
|
108
|
+
|
|
109
|
+
interface Question {
|
|
110
|
+
text: string;
|
|
111
|
+
round: number;
|
|
112
|
+
answer: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 从消息 content 中提取可显示文本。
|
|
117
|
+
* content 可能是 string 或 ContentBlock 数组(text / image 等),
|
|
118
|
+
* 纯图片消息返回 "[图片]" 占位,空消息返回空串。
|
|
119
|
+
* 对 assistant 消息,thinking / tool_use 等非 text 块会被忽略,只取 text。
|
|
120
|
+
*/
|
|
121
|
+
function extractText(content: unknown): string {
|
|
122
|
+
if (typeof content === "string") return content;
|
|
123
|
+
if (!Array.isArray(content)) return "";
|
|
124
|
+
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
let hasImage = false;
|
|
127
|
+
for (const block of content) {
|
|
128
|
+
if (!block || typeof block !== "object") continue;
|
|
129
|
+
const b = block as { type?: string; text?: string };
|
|
130
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
131
|
+
parts.push(b.text);
|
|
132
|
+
} else if (b.type === "image") {
|
|
133
|
+
hasImage = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const text = parts.join("").trim();
|
|
137
|
+
if (text) return text;
|
|
138
|
+
return hasImage ? "[图片]" : "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 单行截断:只取第一行,超长加 "...",保证可见宽度 <= maxWidth。
|
|
143
|
+
* 正确处理中文等双宽字符。
|
|
144
|
+
*/
|
|
145
|
+
function truncateLine(text: string, maxWidth: number): string {
|
|
146
|
+
if (maxWidth <= 0) return "";
|
|
147
|
+
const firstLine = text.split("\n", 1)[0] ?? "";
|
|
148
|
+
const ellipsis = "...";
|
|
149
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
150
|
+
const firstLineWidth = visibleWidth(firstLine);
|
|
151
|
+
const hasMoreLines = text.includes("\n");
|
|
152
|
+
|
|
153
|
+
if (firstLineWidth <= maxWidth && !hasMoreLines) {
|
|
154
|
+
return firstLine;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const contentMax = Math.max(0, maxWidth - ellipsisWidth);
|
|
158
|
+
let w = 0;
|
|
159
|
+
let out = "";
|
|
160
|
+
for (const ch of firstLine) {
|
|
161
|
+
const cw = visibleWidth(ch);
|
|
162
|
+
if (w + cw > contentMax) break;
|
|
163
|
+
out += ch;
|
|
164
|
+
w += cw;
|
|
165
|
+
}
|
|
166
|
+
return out + ellipsis;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 多行文本处理:保留原始换行,每行按 maxWidth 截断(超长加 "...")。
|
|
171
|
+
* 不限制总行数(由调用方 slice 滚动窗口)。
|
|
172
|
+
*/
|
|
173
|
+
function wrapText(text: string, maxWidth: number): string[] {
|
|
174
|
+
if (!text) return [];
|
|
175
|
+
const rawLines = text.split("\n");
|
|
176
|
+
const result: string[] = [];
|
|
177
|
+
for (const raw of rawLines) {
|
|
178
|
+
result.push(truncateLine(raw, maxWidth));
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 把编辑器命令字符串拆成 [command, ...args],简单处理双引号包裹的 token。
|
|
185
|
+
* 例:"code --wait" -> ["code","--wait"];'"C:\\Program Files\\nvim.exe"' -> ["C:\\Program Files\\nvim.exe"]
|
|
186
|
+
*/
|
|
187
|
+
function parseEditorCommand(cmd: string): { command: string; args: string[] } {
|
|
188
|
+
const tokens = cmd.trim().match(/"[^"]+"|\S+/g) ?? [];
|
|
189
|
+
if (tokens.length === 0) return { command: cmd, args: [] };
|
|
190
|
+
const strip = (s: string) => s.replace(/^"|"$/g, "");
|
|
191
|
+
return { command: strip(tokens[0]!), args: tokens.slice(1).map(strip) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 解析外部编辑器命令。优先级与 pi 内置 openExternalEditor 一致:
|
|
196
|
+
* settings.json 的 externalEditor > $VISUAL > $EDITOR > 平台默认(Win: notepad, Unix: nano)。
|
|
197
|
+
*/
|
|
198
|
+
function resolveEditorCommand(): { command: string; args: string[] } {
|
|
199
|
+
try {
|
|
200
|
+
const p = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
201
|
+
if (fs.existsSync(p)) {
|
|
202
|
+
const settings = JSON.parse(fs.readFileSync(p, "utf8")) as { externalEditor?: string };
|
|
203
|
+
if (typeof settings.externalEditor === "string" && settings.externalEditor.trim()) {
|
|
204
|
+
return parseEditorCommand(settings.externalEditor);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
/* 忽略,回退到环境变量 */
|
|
209
|
+
}
|
|
210
|
+
if (process.env.VISUAL) return parseEditorCommand(process.env.VISUAL);
|
|
211
|
+
if (process.env.EDITOR) return parseEditorCommand(process.env.EDITOR);
|
|
212
|
+
return process.platform === "win32"
|
|
213
|
+
? { command: "notepad", args: [] }
|
|
214
|
+
: { command: "nano", args: [] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 构造带历史上下文的编辑器文件内容:
|
|
219
|
+
* >> 问题 / >> 回答 / >> 回复
|
|
220
|
+
* 回复区放当前编辑器文本,前两节作为只读参考。
|
|
221
|
+
*/
|
|
222
|
+
function buildContextFileContent(q: { text: string; answer: string }, replyText: string): string {
|
|
223
|
+
const answer = q.answer?.trim() || "(暂无回答)";
|
|
224
|
+
return `${MARKER_QUESTION}\n\n${q.text}\n\n${MARKER_ANSWER}\n\n${answer}\n\n${MARKER_REPLY}\n\n${replyText}\n`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 从编辑器保存的文件内容中解析"回复"部分。
|
|
229
|
+
*
|
|
230
|
+
* 规则:取【最后一个】行首严格匹配 ">> 回复" 的标记行之后的内容。
|
|
231
|
+
* 为何取最后一个:回复区在文件末尾,正常只有一处标记;即使历史问答里意外出现
|
|
232
|
+
* ">> 回复" 行,最后一个也最可能是真正的回复区分隔符,避免被历史内容误导。
|
|
233
|
+
*
|
|
234
|
+
* 容错:若找不到标记(被删除/改写),返回整个内容并标记 markerFound=false,
|
|
235
|
+
* 由调用方提示用户"已将全部内容作为回复",确保不丢内容。
|
|
236
|
+
*/
|
|
237
|
+
function parseReplyFromContent(content: string): { reply: string; markerFound: boolean } {
|
|
238
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
239
|
+
let markerIdx = -1;
|
|
240
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
241
|
+
if (/^>>\s*回复\s*$/.test(lines[i]!)) {
|
|
242
|
+
markerIdx = i;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const trimEnds = (s: string) => s.replace(/^\n+/, "").replace(/\n+$/, "");
|
|
247
|
+
if (markerIdx === -1) {
|
|
248
|
+
return { reply: trimEnds(content), markerFound: false };
|
|
249
|
+
}
|
|
250
|
+
return { reply: trimEnds(lines.slice(markerIdx + 1).join("\n")), markerFound: true };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default function (pi: ExtensionAPI) {
|
|
254
|
+
/** 最近的问题列表(按时间正序,末尾为最新) */
|
|
255
|
+
let recentQuestions: Question[] = [];
|
|
256
|
+
/** 显示最近多少条 */
|
|
257
|
+
let maxLines = 5;
|
|
258
|
+
/** 是否启用置顶显示 */
|
|
259
|
+
let enabled = true;
|
|
260
|
+
/** 当前轮数(已提问的 user 消息计数) */
|
|
261
|
+
let currentRound = 0;
|
|
262
|
+
/** 选中索引(0=最旧,length-1=最新),默认最新 */
|
|
263
|
+
let selectedIndex = 0;
|
|
264
|
+
/** 窗口起始(最旧的可见索引),窗口固定显示 maxLines 条 */
|
|
265
|
+
let windowStart = 0;
|
|
266
|
+
/** 选中条是否展开(显示问题全文 + 回答) */
|
|
267
|
+
let expanded = false;
|
|
268
|
+
/** 展开态下回答的滚动偏移(行) */
|
|
269
|
+
let answerScroll = 0;
|
|
270
|
+
/** 缓存最近一次 render 计算的选中条回答分行,供 handler 做滚动边界判断 */
|
|
271
|
+
let cachedAnswerLines: string[] = [];
|
|
272
|
+
/** 缓存最近一次 render 计算的回答窗口行数(可能因终端高度不足被压缩),供 handler 边界判断保持一致 */
|
|
273
|
+
let cachedAnswerView = ANSWER_VIEW_LINES;
|
|
274
|
+
// render 指纹缓存:输入不变则返回同一数组引用,避免 pi 流式重绘时反复重算导致抖动
|
|
275
|
+
let lastFingerprint = "";
|
|
276
|
+
let cachedOutput: string[] | null = null;
|
|
277
|
+
/** 缓存 widget 回调拿到的 TUI 实例,供 Ctrl+Alt+G 外部编辑器流程 stop/start 终端 */
|
|
278
|
+
let tuiRef: TuiHandle | undefined;
|
|
279
|
+
|
|
280
|
+
/** 选中归位到最新一条,窗口跟随,并收起 */
|
|
281
|
+
const resetSelectionToNewest = () => {
|
|
282
|
+
const n = recentQuestions.length;
|
|
283
|
+
selectedIndex = n > 0 ? n - 1 : 0;
|
|
284
|
+
windowStart = Math.max(0, n - maxLines);
|
|
285
|
+
expanded = false;
|
|
286
|
+
answerScroll = 0;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** 确保选中在合法范围内且在可见窗口内,必要时滚动窗口 */
|
|
290
|
+
const ensureVisible = () => {
|
|
291
|
+
const n = recentQuestions.length;
|
|
292
|
+
if (n === 0) {
|
|
293
|
+
selectedIndex = 0;
|
|
294
|
+
windowStart = 0;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (selectedIndex < 0) selectedIndex = 0;
|
|
298
|
+
if (selectedIndex > n - 1) selectedIndex = n - 1;
|
|
299
|
+
if (selectedIndex < windowStart) windowStart = selectedIndex;
|
|
300
|
+
if (selectedIndex > windowStart + maxLines - 1) {
|
|
301
|
+
windowStart = selectedIndex - maxLines + 1;
|
|
302
|
+
}
|
|
303
|
+
const maxWin = Math.max(0, n - maxLines);
|
|
304
|
+
if (windowStart < 0) windowStart = 0;
|
|
305
|
+
if (windowStart > maxWin) windowStart = maxWin;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** 切换选中索引,并重置回答滚动到顶部 */
|
|
309
|
+
const setSelected = (idx: number) => {
|
|
310
|
+
selectedIndex = idx;
|
|
311
|
+
answerScroll = 0;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** 获取当前选中问题;会先修正越界索引,避免历史变更后复制到错误条目 */
|
|
315
|
+
const getSelectedQuestion = (): Question | undefined => {
|
|
316
|
+
if (recentQuestions.length === 0) return undefined;
|
|
317
|
+
ensureVisible();
|
|
318
|
+
return recentQuestions[selectedIndex];
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** 同时复制当前选中条的完整问题与回答 */
|
|
322
|
+
const copyQuestionAndAnswer = async (ctx: ExtensionContext) => {
|
|
323
|
+
const q = getSelectedQuestion();
|
|
324
|
+
if (!q) {
|
|
325
|
+
ctx.ui.notify("暂无历史问题可复制", "warning");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const text = `>> 问题\n\n${q.text}\n\n>> 回答\n\n${q.answer || "(暂无回答)"}`;
|
|
330
|
+
try {
|
|
331
|
+
await copyToClipboard(text);
|
|
332
|
+
ctx.ui.notify(`已复制第 ${q.round} 轮问题与回答`, "info");
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.error("[current-question] 复制到剪贴板失败", error);
|
|
335
|
+
ctx.ui.notify(`复制失败:${error instanceof Error ? error.message : String(error)}`, "error");
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** 从指定分支重建 recentQuestions 与 currentRound(纯数据,不触发渲染) */
|
|
340
|
+
const rebuildFromBranch = (branch: unknown) => {
|
|
341
|
+
const list: Question[] = [];
|
|
342
|
+
let round = 0;
|
|
343
|
+
let currentAnswer = "";
|
|
344
|
+
for (const entry of branch as Array<
|
|
345
|
+
{ type?: string; message?: { role?: string; content?: unknown } } | undefined
|
|
346
|
+
>) {
|
|
347
|
+
if (!entry || entry.type !== "message") continue;
|
|
348
|
+
const msg = entry.message;
|
|
349
|
+
if (!msg) continue;
|
|
350
|
+
if (msg.role === "user") {
|
|
351
|
+
// 新一轮开始:先把上一轮的回答存入
|
|
352
|
+
if (list.length > 0) {
|
|
353
|
+
list[list.length - 1].answer = currentAnswer.trim();
|
|
354
|
+
}
|
|
355
|
+
currentAnswer = "";
|
|
356
|
+
const text = extractText(msg.content);
|
|
357
|
+
if (text === "") continue;
|
|
358
|
+
round++;
|
|
359
|
+
list.push({ text, round, answer: "" });
|
|
360
|
+
} else if (msg.role === "assistant") {
|
|
361
|
+
// 累积本轮回答文本(thinking / tool_use 等非 text 块会被 extractText 忽略)
|
|
362
|
+
const text = extractText(msg.content);
|
|
363
|
+
if (text) {
|
|
364
|
+
currentAnswer += (currentAnswer ? "\n" : "") + text;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// 存入最后一轮的回答
|
|
369
|
+
if (list.length > 0) {
|
|
370
|
+
list[list.length - 1].answer = currentAnswer.trim();
|
|
371
|
+
}
|
|
372
|
+
recentQuestions = list;
|
|
373
|
+
currentRound = round;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** 以 sessionManager 为真相源重建,并归位选中 */
|
|
377
|
+
const rebuild = (ctx: ExtensionContext) => {
|
|
378
|
+
rebuildFromBranch(ctx.sessionManager.getBranch());
|
|
379
|
+
resetSelectionToNewest();
|
|
380
|
+
refresh(ctx);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/** 根据当前状态刷新(或清除)widget */
|
|
384
|
+
const refresh = (ctx: ExtensionContext) => {
|
|
385
|
+
if (!ctx.hasUI) return;
|
|
386
|
+
if (!enabled || recentQuestions.length === 0) {
|
|
387
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
|
391
|
+
tuiRef = tui as unknown as TuiHandle;
|
|
392
|
+
return {
|
|
393
|
+
render(width: number) {
|
|
394
|
+
// 指纹缓存:输入不变则返回同一数组引用,避免 pi 流式重绘时反复重算导致抖动。
|
|
395
|
+
// 含 termRows:终端 resize 时缓存失效,避免高度限制按旧高度计算。
|
|
396
|
+
const termRows = tui.terminal.rows;
|
|
397
|
+
const sel = recentQuestions[selectedIndex];
|
|
398
|
+
const fp = `${width}|${termRows}|${currentRound}|${recentQuestions.length}|${windowStart}|${maxLines}|${selectedIndex}|${expanded}|${answerScroll}|${sel?.text ?? ""}|${expanded ? (sel?.answer ?? "") : ""}`;
|
|
399
|
+
if (fp === lastFingerprint && cachedOutput) {
|
|
400
|
+
return cachedOutput;
|
|
401
|
+
}
|
|
402
|
+
lastFingerprint = fp;
|
|
403
|
+
// widget 高度上限:留出底部 editor/footer/status/chat 空间,避免触碰终端顶部。
|
|
404
|
+
const maxWidgetRows = Math.max(8, termRows - WIDGET_HEIGHT_RESERVED);
|
|
405
|
+
const indent = " ";
|
|
406
|
+
const indentWidth = visibleWidth(indent);
|
|
407
|
+
const n = recentQuestions.length;
|
|
408
|
+
const out: string[] = [];
|
|
409
|
+
out.push(theme.fg("accent", theme.bold(`❯ 当前问题(${currentRound})`)));
|
|
410
|
+
|
|
411
|
+
// 顶部指示:窗口上方还有更新的问题
|
|
412
|
+
const newerCount = n - (windowStart + maxLines);
|
|
413
|
+
if (newerCount > 0) {
|
|
414
|
+
out.push(`${theme.fg("dim", indent)}${theme.fg("dim", `↑ 还有 ${newerCount} 条更新`)}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 可见窗口 [windowStart, windowStart+maxLines),反转使最新在上
|
|
418
|
+
const end = Math.min(n, windowStart + maxLines);
|
|
419
|
+
const shown = recentQuestions.slice(windowStart, end);
|
|
420
|
+
for (let i = shown.length - 1; i >= 0; i--) {
|
|
421
|
+
const q = shown[i];
|
|
422
|
+
const chronIdx = windowStart + i; // 该条在 recentQuestions 中的真实索引
|
|
423
|
+
const isSelected = chronIdx === selectedIndex;
|
|
424
|
+
if (isSelected) {
|
|
425
|
+
const marker = "▸ ";
|
|
426
|
+
if (!expanded) {
|
|
427
|
+
// 收起:单行摘要(用 text 正常色,比未选中的 dim 略亮)
|
|
428
|
+
const contentWidth = Math.max(1, width - indentWidth - visibleWidth(marker));
|
|
429
|
+
const line = truncateLine(q.text, contentWidth);
|
|
430
|
+
out.push(`${theme.fg("dim", indent)}${theme.fg("accent", marker)}${theme.fg("text", line)}`);
|
|
431
|
+
} else {
|
|
432
|
+
// 展开:问题全文 + 回答滚动窗口
|
|
433
|
+
// 问题全文用 text(正常前景色),回答用 muted,与未选中的 dim 拉开层次
|
|
434
|
+
const bodyIndent = " ";
|
|
435
|
+
const bodyWidth = Math.max(1, width - visibleWidth(bodyIndent));
|
|
436
|
+
// 问题全文限制最大行数,超出截断并提示,避免长问题撑爆 widget 高度
|
|
437
|
+
const rawQLines = wrapText(q.text, bodyWidth);
|
|
438
|
+
const qTruncated = rawQLines.length > MAX_QUESTION_LINES;
|
|
439
|
+
const qLines = qTruncated ? rawQLines.slice(0, MAX_QUESTION_LINES) : rawQLines;
|
|
440
|
+
qLines.forEach((ln, idx) => {
|
|
441
|
+
if (idx === 0) {
|
|
442
|
+
out.push(`${theme.fg("dim", indent)}${theme.fg("accent", marker)}${theme.fg("text", ln)}`);
|
|
443
|
+
} else {
|
|
444
|
+
out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("text", ln)}`);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
if (qTruncated) {
|
|
448
|
+
out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", `… 问题共 ${rawQLines.length} 行,已截断前 ${MAX_QUESTION_LINES} 行(Ctrl+Alt+B 复制全文)`)}`);
|
|
449
|
+
}
|
|
450
|
+
out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("accent", "─ 回答 ─")}`);
|
|
451
|
+
if (q.answer) {
|
|
452
|
+
// 全部分行并缓存,供滚动 handler 做边界判断
|
|
453
|
+
cachedAnswerLines = wrapText(q.answer, bodyWidth);
|
|
454
|
+
const total = cachedAnswerLines.length;
|
|
455
|
+
// 动态回答窗口行数:按终端高度限制 widget 总高,避免触碰顶部导致流式抖动。
|
|
456
|
+
// 预估除回答外的固定行数,剩余预算分配给回答窗口。
|
|
457
|
+
const topHintLines = newerCount > 0 ? 1 : 0;
|
|
458
|
+
const bottomHintLines = windowStart > 0 ? 1 : 0;
|
|
459
|
+
const otherItemLines = end - windowStart - 1; // 除选中条外其他可见条目各占 1 行
|
|
460
|
+
const questionLines = qLines.length + (qTruncated ? 1 : 0);
|
|
461
|
+
const scrollHintReserve = 2; // 上下滚提示各预留 1 行(最坏情况)
|
|
462
|
+
const nonAnswerLines =
|
|
463
|
+
1 /*标题*/ + topHintLines + questionLines + 1 /*回答分隔*/ + otherItemLines + bottomHintLines + scrollHintReserve;
|
|
464
|
+
let answerView = ANSWER_VIEW_LINES;
|
|
465
|
+
if (nonAnswerLines + answerView > maxWidgetRows) {
|
|
466
|
+
answerView = Math.max(0, maxWidgetRows - nonAnswerLines);
|
|
467
|
+
}
|
|
468
|
+
cachedAnswerView = answerView;
|
|
469
|
+
const maxScroll = Math.max(0, total - answerView);
|
|
470
|
+
if (answerScroll > maxScroll) answerScroll = maxScroll;
|
|
471
|
+
const viewEnd = Math.min(total, answerScroll + answerView);
|
|
472
|
+
// 回答上方滚动提示
|
|
473
|
+
if (answerScroll > 0) {
|
|
474
|
+
out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", `↑ 还有 ${answerScroll} 行(Ctrl+Alt+K 上滚)`)}`);
|
|
475
|
+
}
|
|
476
|
+
for (const ln of cachedAnswerLines.slice(answerScroll, viewEnd)) {
|
|
477
|
+
out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("muted", ln)}`);
|
|
478
|
+
}
|
|
479
|
+
// 回答下方滚动提示
|
|
480
|
+
const remain = total - viewEnd;
|
|
481
|
+
if (remain > 0) {
|
|
482
|
+
out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", `↓ 还有 ${remain} 行(Ctrl+Alt+J 下滚)`)}`);
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
out.push(`${theme.fg("dim", bodyIndent)}${theme.fg("dim", "(暂无回答)")}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
const marker = "· ";
|
|
490
|
+
const contentWidth = Math.max(1, width - indentWidth - visibleWidth(marker));
|
|
491
|
+
const line = truncateLine(q.text, contentWidth);
|
|
492
|
+
out.push(`${theme.fg("dim", indent)}${theme.fg("dim", marker)}${theme.fg("dim", line)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 底部指示:窗口下方还有更早的问题
|
|
497
|
+
const olderCount = windowStart;
|
|
498
|
+
if (olderCount > 0) {
|
|
499
|
+
out.push(`${theme.fg("dim", indent)}${theme.fg("dim", `↓ 还有 ${olderCount} 条更早`)}`);
|
|
500
|
+
}
|
|
501
|
+
// 兜底:极端配置或小终端下,若 widget 总行数仍超上限,从底部截断并提示,
|
|
502
|
+
// 确保不触碰终端顶部引发流式抖动。
|
|
503
|
+
if (out.length > maxWidgetRows) {
|
|
504
|
+
const overflow = out.length - maxWidgetRows;
|
|
505
|
+
out.length = maxWidgetRows;
|
|
506
|
+
out.push(`${theme.fg("dim", indent)}${theme.fg("dim", `↓ 因终端高度隐藏 ${overflow} 行`)}`);
|
|
507
|
+
}
|
|
508
|
+
cachedOutput = out;
|
|
509
|
+
return cachedOutput;
|
|
510
|
+
},
|
|
511
|
+
invalidate() {},
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// 用户发新消息:增量追加,选中归位最新并收起
|
|
517
|
+
// 注意:assistant 回答不在 message_end 里更新数据,统一延后到 agent_end 重建。
|
|
518
|
+
// 否则回答期间 last.answer 不断变化,pi 流式重绘时 render 重算会导致展开态抖动。
|
|
519
|
+
pi.on("message_end", async (event, ctx) => {
|
|
520
|
+
if (event.message.role !== "user") return;
|
|
521
|
+
const text = extractText(event.message.content);
|
|
522
|
+
if (text === "") return;
|
|
523
|
+
currentRound++;
|
|
524
|
+
recentQuestions.push({ text, round: currentRound, answer: "" });
|
|
525
|
+
resetSelectionToNewest();
|
|
526
|
+
refresh(ctx);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// 会话开始/恢复时重建
|
|
530
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
531
|
+
rebuild(ctx);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// 会话树跳转:覆盖 /undo、/redo、/tree 导航
|
|
535
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
536
|
+
rebuild(ctx);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// 压缩后重建
|
|
540
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
541
|
+
rebuild(ctx);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// AI 开始回答:自动收起展开态,避免流式输出整屏重排波及 widget 区造成抖动
|
|
545
|
+
// (回答中展开最新条本就看不到实时回答——数据冻结到 agent_end 才更新)
|
|
546
|
+
let restoreExpandedAfterStream = false;
|
|
547
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
548
|
+
if (expanded) {
|
|
549
|
+
restoreExpandedAfterStream = true;
|
|
550
|
+
expanded = false;
|
|
551
|
+
refresh(ctx);
|
|
552
|
+
} else {
|
|
553
|
+
restoreExpandedAfterStream = false;
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// AI 回答完全结束:恢复用户之前的展开态,并以 sessionManager 为准刷新数据
|
|
558
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
559
|
+
if (recentQuestions.length === 0) return;
|
|
560
|
+
rebuildFromBranch(ctx.sessionManager.getBranch());
|
|
561
|
+
if (selectedIndex > recentQuestions.length - 1) {
|
|
562
|
+
selectedIndex = Math.max(0, recentQuestions.length - 1);
|
|
563
|
+
}
|
|
564
|
+
if (restoreExpandedAfterStream) {
|
|
565
|
+
expanded = true;
|
|
566
|
+
answerScroll = 0;
|
|
567
|
+
restoreExpandedAfterStream = false;
|
|
568
|
+
}
|
|
569
|
+
refresh(ctx);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Ctrl+Alt+J:收起态=选更早问题;展开态=回答下滚
|
|
573
|
+
pi.registerShortcut("ctrl+alt+j", {
|
|
574
|
+
description: "当前问题:收起时选更早问题 / 展开时回答下滚",
|
|
575
|
+
handler: async (ctx) => {
|
|
576
|
+
if (recentQuestions.length === 0 || !enabled) return;
|
|
577
|
+
if (expanded) {
|
|
578
|
+
// 回答下滚
|
|
579
|
+
const total = cachedAnswerLines.length;
|
|
580
|
+
const maxScroll = Math.max(0, total - cachedAnswerView);
|
|
581
|
+
if (answerScroll >= maxScroll) return; // 已到底,静默停止
|
|
582
|
+
answerScroll++;
|
|
583
|
+
refresh(ctx);
|
|
584
|
+
} else {
|
|
585
|
+
if (selectedIndex <= 0) return; // 已到最早,静默停止
|
|
586
|
+
setSelected(selectedIndex - 1);
|
|
587
|
+
ensureVisible();
|
|
588
|
+
refresh(ctx);
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// Ctrl+Alt+K:收起态=选更新问题;展开态=回答上滚
|
|
594
|
+
pi.registerShortcut("ctrl+alt+k", {
|
|
595
|
+
description: "当前问题:收起时选更新问题 / 展开时回答上滚",
|
|
596
|
+
handler: async (ctx) => {
|
|
597
|
+
if (recentQuestions.length === 0 || !enabled) return;
|
|
598
|
+
if (expanded) {
|
|
599
|
+
// 回答上滚
|
|
600
|
+
if (answerScroll <= 0) return; // 已到顶,静默停止
|
|
601
|
+
answerScroll--;
|
|
602
|
+
refresh(ctx);
|
|
603
|
+
} else {
|
|
604
|
+
if (selectedIndex >= recentQuestions.length - 1) return; // 已到最新,静默停止
|
|
605
|
+
setSelected(selectedIndex + 1);
|
|
606
|
+
ensureVisible();
|
|
607
|
+
refresh(ctx);
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// Ctrl+Alt+C:同时复制当前选中条的完整问题与回答
|
|
613
|
+
pi.registerShortcut("ctrl+alt+b", {
|
|
614
|
+
description: "当前问题:复制选中条完整问题与回答",
|
|
615
|
+
handler: async (ctx) => {
|
|
616
|
+
await copyQuestionAndAnswer(ctx);
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// Ctrl+Alt+L:隐藏态=重新显示;显示态=展开当前选中条(重置回答滚动到顶部)
|
|
621
|
+
pi.registerShortcut("ctrl+alt+l", {
|
|
622
|
+
description: "当前问题:隐藏时重新显示 / 显示时展开选中条(显示问题全文 + 回答)",
|
|
623
|
+
handler: async (ctx) => {
|
|
624
|
+
if (recentQuestions.length === 0) return;
|
|
625
|
+
if (!enabled) {
|
|
626
|
+
enabled = true;
|
|
627
|
+
expanded = false;
|
|
628
|
+
ensureVisible();
|
|
629
|
+
refresh(ctx);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
expanded = true;
|
|
633
|
+
answerScroll = 0;
|
|
634
|
+
refresh(ctx);
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Ctrl+Alt+H:展开态=收起当前选中条;收起态=隐藏当前问题
|
|
639
|
+
pi.registerShortcut("ctrl+alt+h", {
|
|
640
|
+
description: "当前问题:展开时收起选中条 / 收起时隐藏当前问题",
|
|
641
|
+
handler: async (ctx) => {
|
|
642
|
+
if (expanded) {
|
|
643
|
+
expanded = false;
|
|
644
|
+
} else {
|
|
645
|
+
enabled = false;
|
|
646
|
+
}
|
|
647
|
+
refresh(ctx);
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// Ctrl+Alt+G:带选中问答上下文打开外部编辑器编辑回复
|
|
652
|
+
// 选中条的问题+回答作为参考写入文件顶部(>> 问题 / >> 回答),当前输入框内容置于 >> 回复 下;
|
|
653
|
+
// 编辑器保存退出后只把 ">> 回复" 之后的内容回填输入框。
|
|
654
|
+
// 终端管理复用 pi 内置 openExternalEditor 的方式:tui.stop() 释放终端、异步 spawn 编辑器、
|
|
655
|
+
// 退出后 tui.start() 恢复。用异步 spawn 而非 spawnSync,避免 Windows 上 console input 抢占。
|
|
656
|
+
pi.registerShortcut("ctrl+alt+g", {
|
|
657
|
+
description: "当前问题:带选中问答上下文打开外部编辑器编辑回复",
|
|
658
|
+
handler: async (ctx) => {
|
|
659
|
+
if (!enabled || recentQuestions.length === 0) {
|
|
660
|
+
ctx.ui.notify("暂无历史问题,无法带上下文编辑", "warning");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const q = getSelectedQuestion();
|
|
664
|
+
if (!q) {
|
|
665
|
+
ctx.ui.notify("未选中历史问题", "warning");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const tui = tuiRef;
|
|
669
|
+
if (!tui?.stop || !tui?.start) {
|
|
670
|
+
ctx.ui.notify("当前环境不支持外部编辑器流程", "error");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const replyText = ctx.ui.getEditorText();
|
|
675
|
+
const fileContent = buildContextFileContent(q, replyText);
|
|
676
|
+
|
|
677
|
+
// 写临时文件(.md 触发 markdown 语法高亮)
|
|
678
|
+
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
679
|
+
const tmpFile = path.join(os.tmpdir(), `pi-context-${stamp}.md`);
|
|
680
|
+
const tempFiles: string[] = [tmpFile];
|
|
681
|
+
const cleanupTemp = () => {
|
|
682
|
+
for (const f of tempFiles) {
|
|
683
|
+
try { fs.unlinkSync(f); } catch { /* ignore */ }
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
try {
|
|
687
|
+
fs.writeFileSync(tmpFile, fileContent, "utf8");
|
|
688
|
+
} catch (e) {
|
|
689
|
+
ctx.ui.notify(`写临时文件失败:${e instanceof Error ? e.message : String(e)}`, "error");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const { command, args } = resolveEditorCommand();
|
|
694
|
+
// vi 系列编辑器(nvim/vim 等)专属增强:追加锁定脚本,把 “>> 回复” 及以上区域
|
|
695
|
+
// 设为只读防止误改历史,并把光标定位到文件末尾。非 vi 编辑器保持兼容。
|
|
696
|
+
const editorBase = path.basename(command).toLowerCase();
|
|
697
|
+
const isViFamily = /^(n?vim|gvim|vi|ex)(\.exe)?$/.test(editorBase);
|
|
698
|
+
let editorArgs: string[];
|
|
699
|
+
if (isViFamily) {
|
|
700
|
+
const lockFile = path.join(os.tmpdir(), `pi-context-lock-${stamp}.vim`);
|
|
701
|
+
try {
|
|
702
|
+
fs.writeFileSync(lockFile, NVIM_LOCK_SCRIPT, "utf8");
|
|
703
|
+
tempFiles.push(lockFile);
|
|
704
|
+
editorArgs = ["-S", lockFile, tmpFile];
|
|
705
|
+
} catch {
|
|
706
|
+
// 写锁定脚本失败:退化为仅定位光标(不加锁定)
|
|
707
|
+
editorArgs = [...args, "+normal! G$", tmpFile];
|
|
708
|
+
}
|
|
709
|
+
} else {
|
|
710
|
+
editorArgs = [...args, tmpFile];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// stop 前直接写 stdout 提示(TUI 停止后 notify 不可见)
|
|
714
|
+
process.stdout.write(`打开外部编辑器编辑第 ${q.round} 轮问答(${command})…\nPi 将在编辑器退出后恢复。\n`);
|
|
715
|
+
|
|
716
|
+
let status: number | null = null;
|
|
717
|
+
let spawnErr: Error | null = null;
|
|
718
|
+
tui.stop();
|
|
719
|
+
try {
|
|
720
|
+
status = await new Promise<number | null>((resolve) => {
|
|
721
|
+
const child = spawn(command, editorArgs, {
|
|
722
|
+
stdio: "inherit",
|
|
723
|
+
shell: process.platform === "win32",
|
|
724
|
+
});
|
|
725
|
+
child.on("error", (err) => {
|
|
726
|
+
spawnErr = err;
|
|
727
|
+
resolve(null);
|
|
728
|
+
});
|
|
729
|
+
child.on("close", (code) => resolve(code));
|
|
730
|
+
});
|
|
731
|
+
} finally {
|
|
732
|
+
tui.start();
|
|
733
|
+
tui.requestRender(true);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (spawnErr) {
|
|
737
|
+
ctx.ui.notify(`启动编辑器失败:${spawnErr.message}`, "error");
|
|
738
|
+
cleanupTemp();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (status !== 0) {
|
|
742
|
+
// 非正常退出:保留原输入框内容不变
|
|
743
|
+
ctx.ui.notify(`编辑器退出码 ${status},未修改输入框`, "warning");
|
|
744
|
+
cleanupTemp();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 读取并解析回复
|
|
749
|
+
let newContent: string;
|
|
750
|
+
try {
|
|
751
|
+
newContent = fs.readFileSync(tmpFile, "utf8");
|
|
752
|
+
} catch (e) {
|
|
753
|
+
ctx.ui.notify(`读取临时文件失败:${e instanceof Error ? e.message : String(e)}`, "error");
|
|
754
|
+
cleanupTemp();
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
cleanupTemp();
|
|
758
|
+
|
|
759
|
+
const { reply, markerFound } = parseReplyFromContent(newContent);
|
|
760
|
+
ctx.ui.setEditorText(reply);
|
|
761
|
+
if (!markerFound) {
|
|
762
|
+
ctx.ui.notify("⚠️ 未找到 '>> 回复' 标记(可能被删除/改写),已将全部内容作为回复", "warning");
|
|
763
|
+
} else {
|
|
764
|
+
ctx.ui.notify(`已更新输入框(参考第 ${q.round} 轮问答)`, "info");
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// /current-question [on|off|toggle|<数字>]
|
|
770
|
+
pi.registerCommand("current-question", {
|
|
771
|
+
description: "当前问题置顶:on/off/toggle 切换开关,或传数字设置显示条数(默认 5)",
|
|
772
|
+
handler: async (args, ctx) => {
|
|
773
|
+
const arg = args.trim().toLowerCase();
|
|
774
|
+
if (arg === "on") {
|
|
775
|
+
enabled = true;
|
|
776
|
+
} else if (arg === "off") {
|
|
777
|
+
enabled = false;
|
|
778
|
+
} else if (arg === "toggle" || arg === "") {
|
|
779
|
+
enabled = !enabled;
|
|
780
|
+
} else if (/^\d+$/.test(arg)) {
|
|
781
|
+
const n = parseInt(arg, 10);
|
|
782
|
+
maxLines = Math.max(1, n);
|
|
783
|
+
enabled = true;
|
|
784
|
+
ensureVisible();
|
|
785
|
+
} else {
|
|
786
|
+
ctx.ui.notify("用法: /current-question [on|off|toggle|<数字>]", "warning");
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
refresh(ctx);
|
|
790
|
+
ctx.ui.notify(`当前问题置顶:${enabled ? "开" : "关"},显示 ${maxLines} 条`, "info");
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zzs-fun/current-question",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi 扩展:在编辑器上方置顶显示最近 N 条用户问题,支持切换/展开/复制/带上下文的外部编辑器回复",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"author": "jenson",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/wszzs110/current-question.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/wszzs110/current-question#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/wszzs110/current-question/issues"
|
|
15
|
+
},
|
|
16
|
+
"files": ["index.ts", "README.md", "README.zh-CN.md", "LICENSE"],
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": ["./index.ts"]
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-tui": "*",
|
|
23
|
+
"typebox": "*"
|
|
24
|
+
}
|
|
25
|
+
}
|