@ravshansbox/pi-statusline 1.9.0 → 1.11.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/README.md +9 -1
- package/history.ts +73 -0
- package/index.ts +233 -3
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-statusline
|
|
2
2
|
|
|
3
|
-
Statusline extension for pi.
|
|
3
|
+
Statusline and prompt history extension for pi.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -18,6 +18,14 @@ Pi loads the extension from `./index.ts`. It replaces the built-in footer with a
|
|
|
18
18
|
- token usage, cache hit rate and context usage
|
|
19
19
|
- statuses set by other extensions
|
|
20
20
|
|
|
21
|
+
It also replaces the editor. The editor has no borders and shows `❯ ` before the prompt. It adds shell-style prompt history:
|
|
22
|
+
|
|
23
|
+
- **Up / Down** recall the previous / next prompt from earlier sessions in the current folder.
|
|
24
|
+
- **Ctrl+R** opens reverse search. Type to filter, Up/Down to move, Tab to switch between current-folder and all-folder history, Enter to load the prompt, Esc to cancel. Text already in the editor becomes the initial query.
|
|
25
|
+
- Empty prompts, slash commands and inline bash (`!`) are skipped. Repeated prompts keep only their newest position. Each search scope holds up to 500 prompts.
|
|
26
|
+
|
|
27
|
+
Do not use this with `@ravshansbox/pi-prompt-history`; only one extension can replace the editor.
|
|
28
|
+
|
|
21
29
|
## Development
|
|
22
30
|
|
|
23
31
|
```bash
|
package/history.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { SessionManager } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
|
|
4
|
+
export type HistoryScope = 'current' | 'all';
|
|
5
|
+
|
|
6
|
+
const HISTORY_LIMIT = 500;
|
|
7
|
+
|
|
8
|
+
type RawEntry = {
|
|
9
|
+
type?: string;
|
|
10
|
+
message?: { role?: string; content?: unknown };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function extractText(content: unknown): string {
|
|
14
|
+
if (typeof content === 'string') return content;
|
|
15
|
+
if (!Array.isArray(content)) return '';
|
|
16
|
+
return content
|
|
17
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
18
|
+
.map((block) => block.text as string)
|
|
19
|
+
.join('\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isRecallablePrompt(text: string): boolean {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
return trimmed.length > 0 && !trimmed.startsWith('/') && !trimmed.startsWith('!');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readPrompts(filePath: string): Promise<string[]> {
|
|
28
|
+
let raw: string;
|
|
29
|
+
try {
|
|
30
|
+
raw = await readFile(filePath, 'utf-8');
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const prompts: string[] = [];
|
|
35
|
+
for (const line of raw.split('\n')) {
|
|
36
|
+
if (!line.trim()) continue;
|
|
37
|
+
let entry: RawEntry;
|
|
38
|
+
try {
|
|
39
|
+
entry = JSON.parse(line) as RawEntry;
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (entry.type !== 'message' || entry.message?.role !== 'user') continue;
|
|
44
|
+
const text = extractText(entry.message.content);
|
|
45
|
+
if (isRecallablePrompt(text)) prompts.push(text.trim());
|
|
46
|
+
}
|
|
47
|
+
return prompts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function uniqueNewest(prompts: string[], limit = Infinity): string[] {
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
const newestFirst: string[] = [];
|
|
53
|
+
for (const prompt of [...prompts].reverse()) {
|
|
54
|
+
if (seen.has(prompt)) continue;
|
|
55
|
+
seen.add(prompt);
|
|
56
|
+
newestFirst.push(prompt);
|
|
57
|
+
if (newestFirst.length >= limit) break;
|
|
58
|
+
}
|
|
59
|
+
return newestFirst.reverse();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function loadPromptHistory(cwd: string, scope: HistoryScope): Promise<string[]> {
|
|
63
|
+
let sessions: Awaited<ReturnType<typeof SessionManager.list>>;
|
|
64
|
+
try {
|
|
65
|
+
sessions = scope === 'all' ? await SessionManager.listAll() : await SessionManager.list(cwd);
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const ordered = [...sessions].sort((a, b) => a.modified.getTime() - b.modified.getTime());
|
|
70
|
+
const all: string[] = [];
|
|
71
|
+
for (const session of ordered) all.push(...(await readPrompts(session.path)));
|
|
72
|
+
return uniqueNewest(all, HISTORY_LIMIT);
|
|
73
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
CustomEditor,
|
|
4
|
+
getSelectListTheme,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type Theme,
|
|
8
|
+
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import {
|
|
10
|
+
type Component,
|
|
11
|
+
fuzzyFilter,
|
|
12
|
+
matchesKey,
|
|
13
|
+
SelectList,
|
|
14
|
+
type SelectListTheme,
|
|
15
|
+
Text,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
type TUI,
|
|
18
|
+
type TuiMouseEvent,
|
|
19
|
+
type TuiMouseEventResult,
|
|
20
|
+
} from '@earendil-works/pi-tui';
|
|
21
|
+
import {
|
|
22
|
+
type HistoryScope,
|
|
23
|
+
isRecallablePrompt,
|
|
24
|
+
loadPromptHistory,
|
|
25
|
+
uniqueNewest,
|
|
26
|
+
} from './history.ts';
|
|
4
27
|
|
|
5
28
|
type Usage = {
|
|
6
29
|
input: number;
|
|
@@ -52,8 +75,207 @@ function getUsage(ctx: ExtensionContext) {
|
|
|
52
75
|
return { totals, latestCacheHitRate };
|
|
53
76
|
}
|
|
54
77
|
|
|
78
|
+
const PROMPT = '\u276f ';
|
|
79
|
+
|
|
80
|
+
class HistoryEditor extends CustomEditor {
|
|
81
|
+
private prompts: string[] = [];
|
|
82
|
+
onReverseSearch?: () => void;
|
|
83
|
+
|
|
84
|
+
setHistory(prompts: string[]): void {
|
|
85
|
+
this.prompts = [];
|
|
86
|
+
for (const prompt of prompts) this.addToHistory(prompt);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getHistory(): string[] {
|
|
90
|
+
return this.prompts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override addToHistory(text: string): void {
|
|
94
|
+
const trimmed = text.trim();
|
|
95
|
+
if (isRecallablePrompt(trimmed)) {
|
|
96
|
+
this.prompts = [...this.prompts.filter((prompt) => prompt !== trimmed), trimmed];
|
|
97
|
+
}
|
|
98
|
+
super.addToHistory(text);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private contentRows = 0;
|
|
102
|
+
|
|
103
|
+
protected override renderTopBorder(): string {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
protected override renderBottomBorder(): string {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override render(width: number): string[] {
|
|
112
|
+
const lines = super.render(Math.max(1, width - PROMPT.length));
|
|
113
|
+
const bottom = lines.indexOf('', 1);
|
|
114
|
+
this.contentRows = bottom - 1;
|
|
115
|
+
return lines
|
|
116
|
+
.filter((_, i) => i !== 0 && i !== bottom)
|
|
117
|
+
.map((line, i) => (i === 0 ? PROMPT : ' '.repeat(PROMPT.length)) + line);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
121
|
+
return super.handleMouse({
|
|
122
|
+
...event,
|
|
123
|
+
x: event.x - PROMPT.length,
|
|
124
|
+
y: event.y < this.contentRows ? event.y + 1 : event.y + 2,
|
|
125
|
+
width: event.width - PROMPT.length,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
override handleInput(data: string): void {
|
|
130
|
+
if (matchesKey(data, 'ctrl+r')) {
|
|
131
|
+
this.onReverseSearch?.();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
super.handleInput(data);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class ReverseSearch implements Component {
|
|
139
|
+
private query: string;
|
|
140
|
+
private scope: HistoryScope = 'current';
|
|
141
|
+
private readonly prompts: Record<HistoryScope, string[] | undefined>;
|
|
142
|
+
private loadingAll = false;
|
|
143
|
+
private list: SelectList;
|
|
144
|
+
private readonly header = new Text('', 1, 0);
|
|
145
|
+
private readonly loadAllPrompts: () => Promise<string[]>;
|
|
146
|
+
private readonly tui: TUI;
|
|
147
|
+
private readonly theme: Theme;
|
|
148
|
+
private readonly listTheme: SelectListTheme;
|
|
149
|
+
private readonly done: (value: string | null) => void;
|
|
150
|
+
|
|
151
|
+
constructor(
|
|
152
|
+
currentPrompts: string[],
|
|
153
|
+
initialQuery: string,
|
|
154
|
+
loadAllPrompts: () => Promise<string[]>,
|
|
155
|
+
tui: TUI,
|
|
156
|
+
theme: Theme,
|
|
157
|
+
listTheme: SelectListTheme,
|
|
158
|
+
done: (value: string | null) => void,
|
|
159
|
+
) {
|
|
160
|
+
this.loadAllPrompts = loadAllPrompts;
|
|
161
|
+
this.tui = tui;
|
|
162
|
+
this.theme = theme;
|
|
163
|
+
this.listTheme = listTheme;
|
|
164
|
+
this.done = done;
|
|
165
|
+
this.query = initialQuery.trim();
|
|
166
|
+
this.prompts = { current: currentPrompts, all: undefined };
|
|
167
|
+
this.list = this.buildList();
|
|
168
|
+
this.refreshHeader();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private buildList(): SelectList {
|
|
172
|
+
const ordered = [...(this.prompts[this.scope] ?? [])].reverse();
|
|
173
|
+
const prompts = this.query ? fuzzyFilter(ordered, this.query, (p) => p) : ordered;
|
|
174
|
+
const items = prompts.map((prompt) => ({
|
|
175
|
+
value: prompt,
|
|
176
|
+
label: prompt.replace(/\s+/g, ' ').trim(),
|
|
177
|
+
}));
|
|
178
|
+
const list = new SelectList(items, 10, this.listTheme);
|
|
179
|
+
list.onSelect = (item) => this.done(item.value);
|
|
180
|
+
list.onCancel = () => this.done(null);
|
|
181
|
+
return list;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private refreshHeader(): void {
|
|
185
|
+
const tabs = (['current', 'all'] as const)
|
|
186
|
+
.map((scope) => {
|
|
187
|
+
const label = scope === 'current' ? 'Current folder' : 'All folders';
|
|
188
|
+
return this.scope === scope
|
|
189
|
+
? this.theme.fg('accent', `[${label}]`)
|
|
190
|
+
: this.theme.fg('dim', label);
|
|
191
|
+
})
|
|
192
|
+
.join(this.theme.fg('dim', ' '));
|
|
193
|
+
const label = this.theme.fg('accent', 'reverse-search');
|
|
194
|
+
const query = this.query
|
|
195
|
+
? this.theme.fg('toolOutput', this.query)
|
|
196
|
+
: this.theme.fg('dim', '(type to filter)');
|
|
197
|
+
const loading =
|
|
198
|
+
this.scope === 'all' && this.loadingAll ? this.theme.fg('dim', ' loading…') : '';
|
|
199
|
+
const hint = this.theme.fg('dim', ' Tab scope · ↑↓ move · Enter load · Esc cancel');
|
|
200
|
+
this.header.setText(`${tabs}\n${label}: ${query}${loading}${hint}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private refresh(): void {
|
|
204
|
+
this.list = this.buildList();
|
|
205
|
+
this.refreshHeader();
|
|
206
|
+
this.tui.requestRender();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private switchScope(): void {
|
|
210
|
+
this.scope = this.scope === 'current' ? 'all' : 'current';
|
|
211
|
+
if (this.scope === 'all' && !this.prompts.all && !this.loadingAll) {
|
|
212
|
+
this.loadingAll = true;
|
|
213
|
+
void this.loadAllPrompts()
|
|
214
|
+
.catch(() => [])
|
|
215
|
+
.then((prompts) => {
|
|
216
|
+
this.prompts.all = prompts;
|
|
217
|
+
this.loadingAll = false;
|
|
218
|
+
this.refresh();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
this.refresh();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
handleInput(data: string): void {
|
|
225
|
+
if (matchesKey(data, 'escape')) {
|
|
226
|
+
this.done(null);
|
|
227
|
+
} else if (matchesKey(data, 'return') || matchesKey(data, 'enter')) {
|
|
228
|
+
this.done(this.list.getSelectedItem()?.value ?? null);
|
|
229
|
+
} else if (
|
|
230
|
+
matchesKey(data, 'tab') ||
|
|
231
|
+
matchesKey(data, 'right') ||
|
|
232
|
+
matchesKey(data, 'shift+tab') ||
|
|
233
|
+
matchesKey(data, 'left')
|
|
234
|
+
) {
|
|
235
|
+
this.switchScope();
|
|
236
|
+
} else if (matchesKey(data, 'up') || matchesKey(data, 'down')) {
|
|
237
|
+
this.list.handleInput(data);
|
|
238
|
+
this.tui.requestRender();
|
|
239
|
+
} else if (matchesKey(data, 'backspace')) {
|
|
240
|
+
if (!this.query) return;
|
|
241
|
+
this.query = this.query.slice(0, -1);
|
|
242
|
+
this.refresh();
|
|
243
|
+
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
244
|
+
this.query += data;
|
|
245
|
+
this.refresh();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
render(width: number): string[] {
|
|
250
|
+
return [...this.header.render(width), ...this.list.render(width)];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
invalidate(): void {
|
|
254
|
+
this.header.invalidate();
|
|
255
|
+
this.list.invalidate();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function openReverseSearch(ctx: ExtensionContext, editor: HistoryEditor) {
|
|
260
|
+
const listTheme = getSelectListTheme();
|
|
261
|
+
const result = await ctx.ui.custom<string | null>(
|
|
262
|
+
(tui, theme, _kb, done) =>
|
|
263
|
+
new ReverseSearch(
|
|
264
|
+
editor.getHistory(),
|
|
265
|
+
ctx.ui.getEditorText(),
|
|
266
|
+
async () =>
|
|
267
|
+
uniqueNewest([...(await loadPromptHistory(ctx.cwd, 'all')), ...editor.getHistory()]),
|
|
268
|
+
tui,
|
|
269
|
+
theme,
|
|
270
|
+
listTheme,
|
|
271
|
+
done,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
if (result) ctx.ui.setEditorText(result);
|
|
275
|
+
}
|
|
276
|
+
|
|
55
277
|
export default function (pi: ExtensionAPI) {
|
|
56
|
-
pi.on('session_start', (_event, ctx) => {
|
|
278
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
57
279
|
if (ctx.mode !== 'tui') return;
|
|
58
280
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
59
281
|
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
@@ -115,5 +337,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
337
|
},
|
|
116
338
|
};
|
|
117
339
|
});
|
|
340
|
+
|
|
341
|
+
const prompts = await loadPromptHistory(ctx.cwd, 'current');
|
|
342
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
343
|
+
const editor = new HistoryEditor(tui, theme, keybindings);
|
|
344
|
+
editor.setHistory(prompts);
|
|
345
|
+
editor.onReverseSearch = () => void openReverseSearch(ctx, editor);
|
|
346
|
+
return editor;
|
|
347
|
+
});
|
|
118
348
|
});
|
|
119
349
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ravshansbox/pi-statusline",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Statusline extension for pi",
|
|
3
|
+
"version": "1.11.0",
|
|
4
|
+
"description": "Statusline and prompt history extension for pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"keywords": [
|
|
16
16
|
"pi-package",
|
|
17
17
|
"pi-extension",
|
|
18
|
-
"statusline"
|
|
18
|
+
"statusline",
|
|
19
|
+
"prompt-history"
|
|
19
20
|
],
|
|
20
21
|
"pi": {
|
|
21
22
|
"extensions": [
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
"typescript": "^7.0.2"
|
|
43
44
|
},
|
|
44
45
|
"files": [
|
|
45
|
-
"index.ts"
|
|
46
|
+
"index.ts",
|
|
47
|
+
"history.ts"
|
|
46
48
|
],
|
|
47
49
|
"publishConfig": {
|
|
48
50
|
"access": "public"
|