pi-turns 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## 1.5.0
4
+
5
+ - Viewer shows up to 70% of the terminal height (was 45%, capped at 22 lines).
6
+ - `g` / `G` jump to the top / bottom of the viewer.
7
+
8
+ ## 1.4.1
9
+
10
+ - Leave search typing mode when opening a turn; the filter query stays.
11
+ - Serialize star file writes so fast toggles cannot clobber each other.
12
+
13
+ ## 1.4.0
14
+
15
+ - `/` incremental search over user/assistant text. Esc clears the query first, then closes.
16
+ - Search stacks with the starred filter and survives opening a turn.
17
+
18
+ ## 1.3.0
19
+
20
+ - `s` stars a turn for later review; `f` shows starred turns only.
21
+ - Stars persist per session in `~/.pi/agent/pi-turns-stars.json` and do not move the session leaf.
22
+
23
+ ## 1.2.0
24
+
25
+ - Truncate list previews by terminal display width (CJK counts as 2).
26
+ - `g` / `G` jump to the first / last turn.
27
+ - Mark the latest turn with `●`.
28
+
29
+ ## 1.1.0
30
+
31
+ - `/turns` lists the current session branch as user/assistant turns
32
+ - Consecutive assistant fragments between user messages are merged
33
+ - Markdown viewer with copy; list remembers the last opened turn
34
+ - Same-day timestamps show `HH:MM`; other days show `MM-DD HH:MM`
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FarL1
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,71 @@
1
+ # pi-turns
2
+
3
+ Browse the current [Pi](https://pi.dev) session as user and assistant turns.
4
+
5
+ ![Turn list](screenshots/turns.png)
6
+
7
+ ## Install
8
+
9
+ Requires [Pi](https://pi.dev) 0.84+ and Node 22+.
10
+
11
+ ```sh
12
+ pi install git:github.com/FarL1/pi-turns
13
+ ```
14
+
15
+ From a local clone:
16
+
17
+ ```sh
18
+ pi install .
19
+ ```
20
+
21
+ Then run `/turns`. Reload with `/reload` if Pi is already open.
22
+
23
+ ## Keys
24
+
25
+ | Key | Action |
26
+ | --- | --- |
27
+ | `/` | Search (type to filter; Esc clears) |
28
+ | `↑` `↓` / `j` `k` | Move |
29
+ | `s` | Star / unstar |
30
+ | `f` | Show starred only |
31
+ | `g` / `G` | First / last |
32
+ | Enter | Open turn |
33
+ | Esc | Back / close |
34
+
35
+ **Viewer:** `c` copies the original text (not the rendered view). Enter or Esc goes back. `↑` `↓` / PageUp / PageDown scroll. `g` / `G` top/bottom.
36
+
37
+ The list remembers the last opened turn. Search and the starred filter survive opening a turn.
38
+
39
+ ## Viewer
40
+
41
+ Enter opens the selected turn as Markdown:
42
+
43
+ ![Markdown viewer](screenshots/viewer.png)
44
+
45
+ ## Star and search
46
+
47
+ `s` stars a turn. `f` shows only starred turns:
48
+
49
+ ![Starred filter](screenshots/starred.png)
50
+
51
+ `/` filters as you type:
52
+
53
+ ![Search](screenshots/search.png)
54
+
55
+ ## Behavior
56
+
57
+ - Active branch only. Does not move the session leaf or write the session JSONL.
58
+ - Consecutive assistant fragments between two user messages are one turn.
59
+ - Tool calls, thinking, and bash results are omitted.
60
+ - Same-day timestamps: `HH:MM`. Other days: `MM-DD HH:MM`.
61
+ - Latest turn: `●`. Starred: `★`. Lists in the viewer: `•`.
62
+
63
+ Not a session-tree browser (`/tree`) and not a search across sessions.
64
+
65
+ ## Data
66
+
67
+ Stars are stored locally in `~/.pi/agent/pi-turns-stars.json`, keyed by session. Nothing is uploaded.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,197 @@
1
+ export type Turn = {
2
+ role: "user" | "assistant";
3
+ text: string;
4
+ timestamp: number;
5
+ };
6
+
7
+ export function turnId(turn: Turn): string {
8
+ const head = turn.text.replace(/\s+/g, " ").trim().slice(0, 80);
9
+ return `${turn.role}:${turn.timestamp}:${head}`;
10
+ }
11
+
12
+ export function matchesQuery(text: string, query: string): boolean {
13
+ const needle = query.trim().toLowerCase();
14
+ if (!needle) return true;
15
+ return text.toLowerCase().includes(needle);
16
+ }
17
+
18
+ export function toggleStar(ids: Iterable<string>, id: string): string[] {
19
+ const next = new Set(ids);
20
+ if (next.has(id)) next.delete(id);
21
+ else next.add(id);
22
+ return [...next];
23
+ }
24
+
25
+ export function extractText(content: unknown): string {
26
+ if (typeof content === "string") return content.trim();
27
+ if (!Array.isArray(content)) return "";
28
+ const parts: string[] = [];
29
+ for (const block of content) {
30
+ if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
31
+ const text = (block as { text?: unknown }).text;
32
+ if (typeof text === "string" && text.trim()) parts.push(text.trim());
33
+ }
34
+ }
35
+ return parts.join("\n\n");
36
+ }
37
+
38
+ function messageFromEntry(entry: unknown): { role?: string; content?: unknown; timestamp?: unknown } | undefined {
39
+ if (!entry || typeof entry !== "object") return undefined;
40
+ const record = entry as { type?: string; message?: unknown; timestamp?: unknown };
41
+ if (record.type && record.type !== "message") return undefined;
42
+ const message = record.type === "message" ? record.message : entry;
43
+ if (!message || typeof message !== "object") return undefined;
44
+ const body = message as { role?: string; content?: unknown; timestamp?: unknown };
45
+ return { role: body.role, content: body.content, timestamp: body.timestamp ?? record.timestamp };
46
+ }
47
+
48
+ function timestampOf(value: unknown): number {
49
+ if (typeof value === "number" && Number.isFinite(value)) return value;
50
+ if (typeof value === "string") {
51
+ const parsed = Date.parse(value);
52
+ if (Number.isFinite(parsed)) return parsed;
53
+ }
54
+ return 0;
55
+ }
56
+
57
+ /** One user prompt, then one combined assistant reply (all fragments until the next user). */
58
+ export function turnsFromEntries(entries: unknown[]): Turn[] {
59
+ const turns: Turn[] = [];
60
+ let pending: Turn | undefined;
61
+ const flush = () => {
62
+ if (pending?.text) turns.push(pending);
63
+ pending = undefined;
64
+ };
65
+ for (const entry of entries) {
66
+ const message = messageFromEntry(entry);
67
+ if (!message) continue;
68
+ if (message.role === "user") {
69
+ flush();
70
+ const text = extractText(message.content);
71
+ if (text) turns.push({ role: "user", text, timestamp: timestampOf(message.timestamp) });
72
+ continue;
73
+ }
74
+ if (message.role !== "assistant") continue;
75
+ const text = extractText(message.content);
76
+ if (!pending) pending = { role: "assistant", text: "", timestamp: timestampOf(message.timestamp) };
77
+ if (text) pending.text = pending.text ? `${pending.text}\n\n${text}` : text;
78
+ }
79
+ flush();
80
+ return turns;
81
+ }
82
+
83
+ export function charWidth(ch: string): number {
84
+ const code = ch.codePointAt(0) ?? 0;
85
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return 0;
86
+ if (
87
+ code >= 0x1100 && (
88
+ code <= 0x115f
89
+ || code === 0x2329 || code === 0x232a
90
+ || (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f)
91
+ || (code >= 0xac00 && code <= 0xd7a3)
92
+ || (code >= 0xf900 && code <= 0xfaff)
93
+ || (code >= 0xfe10 && code <= 0xfe19)
94
+ || (code >= 0xfe30 && code <= 0xfe6f)
95
+ || (code >= 0xff00 && code <= 0xff60)
96
+ || (code >= 0xffe0 && code <= 0xffe6)
97
+ || code >= 0x1f300
98
+ )
99
+ ) return 2;
100
+ return 1;
101
+ }
102
+
103
+ export function displayWidth(text: string): number {
104
+ let width = 0;
105
+ for (const ch of text) width += charWidth(ch);
106
+ return width;
107
+ }
108
+
109
+ export function previewLine(text: string, max = 72): string {
110
+ const one = text.replace(/\s+/g, " ").trim();
111
+ if (displayWidth(one) <= max) return one;
112
+ let out = "";
113
+ let width = 0;
114
+ for (const ch of one) {
115
+ const next = charWidth(ch);
116
+ if (width + next + 1 > max) break;
117
+ out += ch;
118
+ width += next;
119
+ }
120
+ return `${out || one.slice(0, 1)}…`;
121
+ }
122
+
123
+ export function formatWhen(timestamp: number, now = Date.now()): string {
124
+ if (!timestamp) return "--:--";
125
+ const date = new Date(timestamp);
126
+ const current = new Date(now);
127
+ const clock = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
128
+ const sameDay = date.getFullYear() === current.getFullYear()
129
+ && date.getMonth() === current.getMonth()
130
+ && date.getDate() === current.getDate();
131
+ if (sameDay) return clock;
132
+ return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${clock}`;
133
+ }
134
+
135
+ export function roleBadge(role: Turn["role"]): string {
136
+ return role === "user" ? "YOU" : "PI";
137
+ }
138
+
139
+ export function listLabel(turn: Turn, index: number, total: number, previewMax = 56): string {
140
+ return `${index + 1}/${total} ${roleBadge(turn.role)} ${formatWhen(turn.timestamp)} ${previewLine(turn.text, previewMax)}`;
141
+ }
142
+
143
+ export function fitGap(left: string, right: string, width: number): string {
144
+ return " ".repeat(Math.max(1, width - left.length - right.length));
145
+ }
146
+
147
+ export function visibleLen(text: string): number {
148
+ return displayWidth(text.replace(/\u001b\[[0-9;]*m/g, ""));
149
+ }
150
+
151
+ export function padLine(text: string, width: number): string {
152
+ const extra = width - visibleLen(text);
153
+ return extra > 0 ? `${text}${" ".repeat(extra)}` : text;
154
+ }
155
+
156
+ export function moveSelection(current: number, total: number, delta: number): number {
157
+ if (total <= 0) return 0;
158
+ return Math.max(0, Math.min(total - 1, current + delta));
159
+ }
160
+
161
+ export function pickerWindow(selected: number, total: number, maxVisible: number): { start: number; end: number } {
162
+ if (total <= maxVisible) return { start: 0, end: total };
163
+ const half = Math.floor(maxVisible / 2);
164
+ let start = selected - half;
165
+ if (start < 0) start = 0;
166
+ if (start + maxVisible > total) start = total - maxVisible;
167
+ return { start, end: start + maxVisible };
168
+ }
169
+
170
+ export function visibleSlice(lines: string[], scroll: number, height: number): { lines: string[]; scroll: number; maxScroll: number } {
171
+ const view = Math.max(1, height);
172
+ const maxScroll = Math.max(0, lines.length - view);
173
+ const offset = Math.max(0, Math.min(scroll, maxScroll));
174
+ return { lines: lines.slice(offset, offset + view), scroll: offset, maxScroll };
175
+ }
176
+
177
+ export function roleLabel(role: Turn["role"]): string {
178
+ return roleBadge(role);
179
+ }
180
+
181
+ export type Theme = {
182
+ bold: (text: string) => string;
183
+ fg: (name: string, text: string) => string;
184
+ bg?: (name: string, text: string) => string;
185
+ italic?: (text: string) => string;
186
+ underline?: (text: string) => string;
187
+ };
188
+
189
+ export type Keybindings = {
190
+ matches: (data: string, id: string) => boolean;
191
+ };
192
+
193
+ export type TuiHandle = {
194
+ requestRender: () => void;
195
+ rows?: number;
196
+ terminal?: { rows?: number };
197
+ };
@@ -0,0 +1,331 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ fitGap,
7
+ formatWhen,
8
+ padLine,
9
+ listLabel,
10
+ moveSelection,
11
+ pickerWindow,
12
+ previewLine,
13
+ roleBadge,
14
+ matchesQuery,
15
+ toggleStar,
16
+ turnId,
17
+ turnsFromEntries,
18
+ visibleSlice,
19
+ type Keybindings,
20
+ type Theme,
21
+ type TuiHandle,
22
+ type Turn,
23
+ } from "./core.ts";
24
+
25
+ function markdownThemeFrom(theme: Theme) {
26
+ const fg = (color: string, value: string) => theme.fg(color, value);
27
+ return {
28
+ heading: (value: string) => theme.bold(fg("mdHeading", value)),
29
+ link: (value: string) => fg("mdLink", value),
30
+ linkUrl: (value: string) => fg("mdLinkUrl", value),
31
+ code: (value: string) => fg("mdCode", value),
32
+ codeBlock: (value: string) => fg("mdCodeBlock", value),
33
+ codeBlockBorder: (value: string) => fg("mdCodeBlockBorder", value),
34
+ quote: (value: string) => fg("mdQuote", value),
35
+ quoteBorder: (value: string) => fg("mdQuoteBorder", value),
36
+ hr: (value: string) => fg("mdHr", value),
37
+ listBullet: (value: string) => fg("mdListBullet", value.replace(/^[-*+]/u, "•")),
38
+ bold: (value: string) => theme.bold(value),
39
+ italic: (value: string) => theme.italic?.(value) ?? value,
40
+ underline: (value: string) => theme.underline?.(value) ?? value,
41
+ strikethrough: (value: string) => value,
42
+ };
43
+ }
44
+
45
+ function roleColor(theme: Theme, role: Turn["role"], text: string): string {
46
+ return theme.fg(role === "user" ? "userMessageText" : "accent", text);
47
+ }
48
+
49
+ function rule(theme: Theme, width: number): string {
50
+ return theme.fg("borderMuted", "─".repeat(Math.max(8, width)));
51
+ }
52
+
53
+ function starsFile(): string {
54
+ const root = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
55
+ return join(root, "pi-turns-stars.json");
56
+ }
57
+
58
+ async function loadStars(sessionKey: string): Promise<Set<string>> {
59
+ try {
60
+ const data = JSON.parse(await readFile(starsFile(), "utf8")) as Record<string, string[]>;
61
+ return new Set(data[sessionKey] ?? []);
62
+ } catch {
63
+ return new Set();
64
+ }
65
+ }
66
+
67
+ let starWrite: Promise<void> = Promise.resolve();
68
+
69
+ async function saveStars(sessionKey: string, ids: Set<string>): Promise<void> {
70
+ const snapshot = [...ids];
71
+ starWrite = starWrite.then(async () => {
72
+ const file = starsFile();
73
+ let data: Record<string, string[]> = {};
74
+ try {
75
+ data = JSON.parse(await readFile(file, "utf8")) as Record<string, string[]>;
76
+ } catch { /* first write */ }
77
+ if (snapshot.length === 0) delete data[sessionKey];
78
+ else data[sessionKey] = snapshot;
79
+ await mkdir(dirname(file), { recursive: true });
80
+ await writeFile(file, `${JSON.stringify(data)}\n`, "utf8");
81
+ }).catch(() => {});
82
+ return starWrite;
83
+ }
84
+
85
+ async function copyText(text: string): Promise<boolean> {
86
+ try {
87
+ const { copyToClipboard } = await import("@earendil-works/pi-coding-agent");
88
+ await copyToClipboard(text);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ type StarState = { ids: Set<string>; sessionKey: string };
96
+ type ViewState = { starredOnly: boolean; query: string; searching: boolean };
97
+
98
+ function pickIndex(
99
+ ctx: {
100
+ ui: {
101
+ custom?: (factory: (tui: TuiHandle, theme: Theme, keybindings: Keybindings, done: (value: number | undefined) => void) => unknown) => Promise<unknown>;
102
+ select: (title: string, options: string[]) => Promise<string | undefined>;
103
+ };
104
+ },
105
+ title: string,
106
+ turns: Turn[],
107
+ selectedIndex: number,
108
+ stars: StarState,
109
+ view: ViewState,
110
+ ): Promise<number | undefined> {
111
+ if (typeof ctx.ui.custom !== "function") {
112
+ const items = turns.map((turn, index) => `${stars.ids.has(turnId(turn)) ? "★ " : ""}${listLabel(turn, index, turns.length)}`);
113
+ return ctx.ui.select(title, items).then((chosen) => chosen === undefined ? undefined : items.indexOf(chosen));
114
+ }
115
+ return ctx.ui.custom((tui, theme, keybindings, done) => {
116
+ const rows = tui.rows ?? tui.terminal?.rows ?? 24;
117
+ const maxVisible = Math.max(6, Math.min(14, Math.floor(rows * 0.4)));
118
+ const visible = () => turns.filter((turn) => {
119
+ if (view.starredOnly && !stars.ids.has(turnId(turn))) return false;
120
+ return matchesQuery(turn.text, view.query);
121
+ });
122
+ const selectTurn = (turn: Turn | undefined) => {
123
+ if (!turn) return 0;
124
+ const id = turnId(turn);
125
+ const index = visible().findIndex((item) => turnId(item) === id);
126
+ return index >= 0 ? index : 0;
127
+ };
128
+ let selected = selectTurn(turns[selectedIndex]);
129
+ return {
130
+ render: (width: number) => {
131
+ const inner = Math.max(16, width);
132
+ const previewMax = Math.max(8, inner - 24);
133
+ const list = visible();
134
+ selected = moveSelection(selected, list.length, 0);
135
+ const latestId = turns.length ? turnId(turns[turns.length - 1]) : "";
136
+ const heading = `${view.starredOnly ? `${title} ★` : title}${view.searching || view.query ? ` /${view.query}` : ""}`;
137
+ const count = list.length ? `${selected + 1} / ${list.length}` : `0 / 0`;
138
+ const lines = [
139
+ `${theme.bold(theme.fg("accent", heading))}${fitGap(heading, count, inner)}${theme.fg("dim", count)}`,
140
+ rule(theme, inner),
141
+ ];
142
+ if (list.length === 0) {
143
+ lines.push(theme.fg("dim", view.query.trim() ? " no matches" : view.starredOnly ? " no starred turns" : " no turns"));
144
+ } else {
145
+ const { start, end } = pickerWindow(selected, list.length, maxVisible);
146
+ for (let index = start; index < end; index++) {
147
+ const turn = list[index];
148
+ const active = index === selected;
149
+ const mark = active ? theme.fg("accent", "▎") : " ";
150
+ const badge = roleColor(theme, turn.role, roleBadge(turn.role).padEnd(3));
151
+ const star = stars.ids.has(turnId(turn)) ? theme.fg("warning", "★") : " ";
152
+ const time = theme.fg("dim", formatWhen(turn.timestamp).padEnd(11));
153
+ const latestMark = turnId(turn) === latestId ? theme.fg("accent", "●") : " ";
154
+ const preview = previewLine(turn.text, previewMax);
155
+ lines.push(padLine(`${mark} ${active ? theme.bold(badge) : badge} ${star} ${time} ${latestMark} ${active ? theme.fg("text", preview) : theme.fg("muted", preview)}`, inner));
156
+ }
157
+ }
158
+ lines.push(rule(theme, inner));
159
+ lines.push(padLine(theme.fg("dim", view.searching
160
+ ? "type to filter backspace esc clear enter open"
161
+ : "↑↓ move / search s star f starred g/G first/last enter esc"), inner));
162
+ return lines;
163
+ },
164
+ invalidate: () => {},
165
+ handleInput: (data: string) => {
166
+ const list = visible();
167
+ if (keybindings.matches(data, "tui.select.cancel")) {
168
+ if (view.query || view.searching) {
169
+ view.query = "";
170
+ view.searching = false;
171
+ selected = selectTurn(list[selected]);
172
+ tui.requestRender();
173
+ return;
174
+ }
175
+ done(undefined);
176
+ return;
177
+ }
178
+ if (keybindings.matches(data, "tui.select.confirm") || data === "\n") {
179
+ const turn = list[selected];
180
+ if (!turn) return;
181
+ view.searching = false;
182
+ done(turns.findIndex((item) => turnId(item) === turnId(turn)));
183
+ return;
184
+ }
185
+ if (view.searching) {
186
+ if (data === "\x7f" || data === "\b") {
187
+ const current = list[selected];
188
+ view.query = view.query.slice(0, -1);
189
+ selected = selectTurn(current);
190
+ tui.requestRender();
191
+ return;
192
+ }
193
+ if (data.length === 1 && data >= " ") {
194
+ const current = list[selected];
195
+ view.query += data;
196
+ selected = selectTurn(current);
197
+ tui.requestRender();
198
+ return;
199
+ }
200
+ } else if (data === "/") {
201
+ view.searching = true;
202
+ tui.requestRender();
203
+ return;
204
+ } else if (data === "s" || data === "S") {
205
+ const turn = list[selected];
206
+ if (!turn) return;
207
+ stars.ids = new Set(toggleStar(stars.ids, turnId(turn)));
208
+ void saveStars(stars.sessionKey, stars.ids);
209
+ tui.requestRender();
210
+ return;
211
+ } else if (data === "f" || data === "F") {
212
+ const current = list[selected];
213
+ view.starredOnly = !view.starredOnly;
214
+ selected = selectTurn(current);
215
+ tui.requestRender();
216
+ return;
217
+ }
218
+ if (data === "g" && !view.searching) {
219
+ selected = 0;
220
+ } else if (data === "G" && !view.searching) {
221
+ selected = Math.max(0, list.length - 1);
222
+ } else if (keybindings.matches(data, "tui.select.up") || (!view.searching && data === "k")) {
223
+ selected = moveSelection(selected, list.length, -1);
224
+ } else if (keybindings.matches(data, "tui.select.down") || (!view.searching && data === "j")) {
225
+ selected = moveSelection(selected, list.length, 1);
226
+ } else if (keybindings.matches(data, "tui.select.pageUp")) {
227
+ selected = moveSelection(selected, list.length, -maxVisible);
228
+ } else if (keybindings.matches(data, "tui.select.pageDown")) {
229
+ selected = moveSelection(selected, list.length, maxVisible);
230
+ } else {
231
+ return;
232
+ }
233
+ tui.requestRender();
234
+ },
235
+ };
236
+ }) as Promise<number | undefined>;
237
+
238
+ }
239
+
240
+ async function showMessage(ctx: {
241
+ ui: {
242
+ custom?: (factory: (tui: TuiHandle, theme: Theme, keybindings: Keybindings, done: (value?: undefined) => void) => unknown) => Promise<unknown>;
243
+ notify: (message: string, level?: string) => void;
244
+ };
245
+ }, title: string, text: string, role: Turn["role"] = "assistant"): Promise<void> {
246
+ if (typeof ctx.ui.custom !== "function") {
247
+ ctx.ui.notify(text, "info");
248
+ return;
249
+ }
250
+ await ctx.ui.custom(async (tui, theme, keybindings, done) => {
251
+ const { Markdown } = await import("@earendil-works/pi-tui");
252
+ const { keyText, rawKeyHint } = await import("@earendil-works/pi-coding-agent");
253
+ const markdown = new Markdown(text, 0, 0, markdownThemeFrom(theme));
254
+ const rows = tui.rows ?? tui.terminal?.rows ?? 24;
255
+ const bodyHeight = Math.max(10, Math.min(40, Math.floor(rows * 0.7)));
256
+ let scroll = 0;
257
+ let status = "";
258
+ return {
259
+ render: (width: number) => {
260
+ const inner = Math.max(16, width);
261
+ const rendered = markdown.render(Math.max(8, inner - 2));
262
+ const slice = visibleSlice(rendered, scroll, bodyHeight);
263
+ scroll = slice.scroll;
264
+ const more = slice.maxScroll > 0
265
+ ? `${slice.scroll + 1}–${slice.scroll + slice.lines.length} / ${rendered.length}`
266
+ : "";
267
+ const hint = status || `${rawKeyHint("c", "copy")} g/G top/bottom ${keyText("tui.select.confirm")}/${keyText("tui.select.cancel")} back`;
268
+ const heading = `${theme.fg("accent", "▎")} ${theme.bold(roleColor(theme, role, title))}${fitGap(`▎ ${title}`, more, inner)}${theme.fg("dim", more)}`;
269
+ return [heading, rule(theme, inner), ...slice.lines.map((line) => ` ${line}`), rule(theme, inner), theme.fg("dim", hint)];
270
+ },
271
+ invalidate: () => {},
272
+ handleInput: (data: string) => {
273
+ if (keybindings.matches(data, "tui.select.cancel") || keybindings.matches(data, "tui.select.confirm") || data === "\n") {
274
+ done();
275
+ return;
276
+ }
277
+ if (data === "c" || data === "C" || keybindings.matches(data, "app.message.copy")) {
278
+ void copyText(text).then((ok) => {
279
+ status = ok ? "copied" : "copy failed";
280
+ tui.requestRender();
281
+ });
282
+ return;
283
+ }
284
+ if (data === "g") {
285
+ scroll = 0;
286
+ } else if (data === "G") {
287
+ scroll = Number.MAX_SAFE_INTEGER;
288
+ } else if (keybindings.matches(data, "tui.select.up") || data === "k") {
289
+ scroll -= 1;
290
+ } else if (keybindings.matches(data, "tui.select.down") || data === "j") {
291
+ scroll += 1;
292
+ } else if (keybindings.matches(data, "tui.select.pageUp")) {
293
+ scroll -= 8;
294
+ } else if (keybindings.matches(data, "tui.select.pageDown")) {
295
+ scroll += 8;
296
+ } else {
297
+ return;
298
+ }
299
+ status = "";
300
+ tui.requestRender();
301
+ },
302
+ };
303
+ });
304
+ }
305
+
306
+ async function openTurns(_args: string, ctx: Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1]): Promise<void> {
307
+ const turns = turnsFromEntries(ctx.sessionManager?.getBranch?.() ?? []);
308
+ if (turns.length === 0) {
309
+ ctx.ui.notify("No user/assistant messages in this session yet", "info");
310
+ return;
311
+ }
312
+ const sessionKey = ctx.sessionManager?.getSessionFile?.() ?? ctx.sessionManager?.getSessionId?.() ?? "ephemeral";
313
+ const stars = { ids: await loadStars(sessionKey), sessionKey };
314
+ const view = { starredOnly: false, query: "", searching: false };
315
+ let cursor = turns.length - 1;
316
+ while (true) {
317
+ const chosen = await pickIndex(ctx, "Turns", turns, cursor, stars, view);
318
+ if (chosen === undefined) return;
319
+ cursor = chosen;
320
+ const turn = turns[chosen];
321
+ if (!turn) return;
322
+ await showMessage(ctx, `${roleBadge(turn.role)} ${formatWhen(turn.timestamp)}`, turn.text, turn.role);
323
+ }
324
+ }
325
+
326
+ export default function turns(pi: ExtensionAPI): void {
327
+ pi.registerCommand("turns", {
328
+ description: "Browse this session's user and assistant turns",
329
+ handler: openTurns,
330
+ });
331
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "pi-turns",
3
+ "version": "1.5.1",
4
+ "description": "Browse the current Pi session as user and assistant turns.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "FarL1",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/FarL1/pi-turns.git"
11
+ },
12
+ "homepage": "https://github.com/FarL1/pi-turns#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/FarL1/pi-turns/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "pi",
20
+ "pi-coding-agent",
21
+ "turns",
22
+ "transcript",
23
+ "session"
24
+ ],
25
+ "files": [
26
+ "extensions",
27
+ "README.md",
28
+ "CHANGELOG.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "test": "node tests/turns.mjs"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*"
40
+ },
41
+ "pi": {
42
+ "extensions": [
43
+ "./extensions/index.ts"
44
+ ],
45
+ "image": "https://raw.githubusercontent.com/FarL1/pi-turns/main/screenshots/turns.png"
46
+ }
47
+ }