@saifymatteo/pi-extensions 0.0.0-stage → 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 +661 -0
- package/NOTICE.md +20 -0
- package/README.md +151 -2
- package/inventory.json +73 -0
- package/package.json +74 -4
- package/packages/pi-bg-bash/LICENSE +661 -0
- package/packages/pi-bg-bash/NOTICE.md +3 -0
- package/packages/pi-bg-bash/README.md +41 -0
- package/packages/pi-bg-bash/examples/settings.example.json +6 -0
- package/packages/pi-bg-bash/package.json +38 -0
- package/packages/pi-bg-bash/src/index.ts +969 -0
- package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
- package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
- package/packages/pi-claude-usage/LICENSE +661 -0
- package/packages/pi-claude-usage/NOTICE.md +3 -0
- package/packages/pi-claude-usage/README.md +27 -0
- package/packages/pi-claude-usage/package.json +35 -0
- package/packages/pi-claude-usage/src/index.ts +130 -0
- package/packages/pi-clipboard-image/LICENSE +661 -0
- package/packages/pi-clipboard-image/NOTICE.md +3 -0
- package/packages/pi-clipboard-image/README.md +27 -0
- package/packages/pi-clipboard-image/package.json +36 -0
- package/packages/pi-clipboard-image/src/index.ts +527 -0
- package/packages/pi-codeblock-borders/LICENSE +661 -0
- package/packages/pi-codeblock-borders/NOTICE.md +3 -0
- package/packages/pi-codeblock-borders/README.md +30 -0
- package/packages/pi-codeblock-borders/package.json +35 -0
- package/packages/pi-codeblock-borders/src/index.ts +302 -0
- package/packages/pi-codegraph/LICENSE +661 -0
- package/packages/pi-codegraph/NOTICE.md +5 -0
- package/packages/pi-codegraph/README.md +35 -0
- package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
- package/packages/pi-codegraph/package.json +37 -0
- package/packages/pi-codegraph/src/index.ts +573 -0
- package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
- package/packages/pi-compact-tools/LICENSE +661 -0
- package/packages/pi-compact-tools/NOTICE.md +3 -0
- package/packages/pi-compact-tools/README.md +27 -0
- package/packages/pi-compact-tools/package.json +36 -0
- package/packages/pi-compact-tools/src/index.ts +1161 -0
- package/packages/pi-decisions/LICENSE +661 -0
- package/packages/pi-decisions/NOTICE.md +3 -0
- package/packages/pi-decisions/README.md +37 -0
- package/packages/pi-decisions/examples/service-models.example.json +27 -0
- package/packages/pi-decisions/package.json +37 -0
- package/packages/pi-decisions/src/index.ts +364 -0
- package/packages/pi-decisions/src/lib/service-models.ts +95 -0
- package/packages/pi-decisions/test/service-models.test.mjs +71 -0
- package/packages/pi-load-pi-dotenv/LICENSE +661 -0
- package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
- package/packages/pi-load-pi-dotenv/README.md +27 -0
- package/packages/pi-load-pi-dotenv/package.json +32 -0
- package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
- package/packages/pi-model-go/LICENSE +661 -0
- package/packages/pi-model-go/NOTICE.md +5 -0
- package/packages/pi-model-go/README.md +33 -0
- package/packages/pi-model-go/package.json +36 -0
- package/packages/pi-model-go/src/index.ts +171 -0
- package/packages/pi-model-go/src/lib/dialog.ts +31 -0
- package/packages/pi-model-go/src/lib/model-go.ts +239 -0
- package/packages/pi-model-go/test/behavior.test.mjs +17 -0
- package/packages/pi-prestart-working/LICENSE +661 -0
- package/packages/pi-prestart-working/NOTICE.md +3 -0
- package/packages/pi-prestart-working/README.md +25 -0
- package/packages/pi-prestart-working/package.json +35 -0
- package/packages/pi-prestart-working/src/index.ts +86 -0
- package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
- package/packages/pi-root-guard/LICENSE +661 -0
- package/packages/pi-root-guard/NOTICE.md +3 -0
- package/packages/pi-root-guard/README.md +27 -0
- package/packages/pi-root-guard/package.json +32 -0
- package/packages/pi-root-guard/src/index.ts +202 -0
- package/packages/pi-session-finder/LICENSE +661 -0
- package/packages/pi-session-finder/NOTICE.md +3 -0
- package/packages/pi-session-finder/README.md +33 -0
- package/packages/pi-session-finder/package.json +36 -0
- package/packages/pi-session-finder/src/index.ts +306 -0
- package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
- package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
- package/packages/pi-session-id/LICENSE +661 -0
- package/packages/pi-session-id/NOTICE.md +3 -0
- package/packages/pi-session-id/README.md +31 -0
- package/packages/pi-session-id/package.json +35 -0
- package/packages/pi-session-id/src/index.ts +20 -0
- package/packages/pi-session-id/test/behavior.test.mjs +25 -0
- package/packages/pi-session-nav/LICENSE +661 -0
- package/packages/pi-session-nav/NOTICE.md +3 -0
- package/packages/pi-session-nav/README.md +32 -0
- package/packages/pi-session-nav/package.json +36 -0
- package/packages/pi-session-nav/src/index.ts +203 -0
- package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
- package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
- package/packages/pi-session-rename/LICENSE +661 -0
- package/packages/pi-session-rename/NOTICE.md +3 -0
- package/packages/pi-session-rename/README.md +31 -0
- package/packages/pi-session-rename/package.json +35 -0
- package/packages/pi-session-rename/src/index.ts +456 -0
- package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
- package/packages/pi-session-retention/LICENSE +661 -0
- package/packages/pi-session-retention/NOTICE.md +3 -0
- package/packages/pi-session-retention/README.md +62 -0
- package/packages/pi-session-retention/package.json +36 -0
- package/packages/pi-session-retention/src/README.md +42 -0
- package/packages/pi-session-retention/src/core.ts +333 -0
- package/packages/pi-session-retention/src/dialog.ts +118 -0
- package/packages/pi-session-retention/src/index.ts +311 -0
- package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
- package/packages/pi-session-retention/src/safety.ts +50 -0
- package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
- package/packages/pi-skill-usage-log/LICENSE +661 -0
- package/packages/pi-skill-usage-log/NOTICE.md +3 -0
- package/packages/pi-skill-usage-log/README.md +32 -0
- package/packages/pi-skill-usage-log/package.json +36 -0
- package/packages/pi-skill-usage-log/src/index.ts +316 -0
- package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
- package/packages/pi-tool-usage-log/LICENSE +661 -0
- package/packages/pi-tool-usage-log/NOTICE.md +5 -0
- package/packages/pi-tool-usage-log/README.md +32 -0
- package/packages/pi-tool-usage-log/package.json +36 -0
- package/packages/pi-tool-usage-log/src/index.ts +233 -0
- package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
- package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
- package/packages/pi-tts/LICENSE +661 -0
- package/packages/pi-tts/NOTICE.md +3 -0
- package/packages/pi-tts/README.md +37 -0
- package/packages/pi-tts/examples/service-models.example.json +27 -0
- package/packages/pi-tts/package.json +37 -0
- package/packages/pi-tts/src/index.ts +135 -0
- package/packages/pi-tts/src/lib/service-models.ts +95 -0
- package/packages/pi-tts/test/tts-output.test.mjs +47 -0
- package/packages/pi-tts/test/tts.test.mjs +57 -0
- package/packages/pi-user-image-previews/LICENSE +661 -0
- package/packages/pi-user-image-previews/NOTICE.md +3 -0
- package/packages/pi-user-image-previews/README.md +27 -0
- package/packages/pi-user-image-previews/package.json +36 -0
- package/packages/pi-user-image-previews/src/index.ts +168 -0
- package/packages/pi-vision/LICENSE +661 -0
- package/packages/pi-vision/NOTICE.md +5 -0
- package/packages/pi-vision/README.md +43 -0
- package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
- package/packages/pi-vision/package.json +38 -0
- package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
- package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
- package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
- package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
- package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
- package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
- package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
- package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
- package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
- package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
- package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
- package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
- package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
- package/packages/pi-vision/src/index.ts +728 -0
- package/packages/pi-vision/test/vision.test.mjs +30 -0
- package/packages/pi-wait/LICENSE +661 -0
- package/packages/pi-wait/NOTICE.md +3 -0
- package/packages/pi-wait/README.md +37 -0
- package/packages/pi-wait/package.json +37 -0
- package/packages/pi-wait/src/index.ts +88 -0
- package/packages/pi-wait/src/lib/async-work.ts +27 -0
- package/packages/pi-wait/test/wait.test.mjs +43 -0
- package/packages/pi-zvec/LICENSE +661 -0
- package/packages/pi-zvec/NOTICE.md +3 -0
- package/packages/pi-zvec/README.md +37 -0
- package/packages/pi-zvec/examples/zvec.example.json +5 -0
- package/packages/pi-zvec/package.json +37 -0
- package/packages/pi-zvec/src/index.ts +754 -0
- package/packages/pi-zvec/test/zvec.test.mjs +19 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pi-session-nav
|
|
2
|
+
|
|
3
|
+
Navigate visible user and assistant messages on the active conversation branch without adding transcript entries.
|
|
4
|
+
|
|
5
|
+
## Running locally
|
|
6
|
+
|
|
7
|
+
From the monorepo root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi -e ./packages/pi-session-nav
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Warning:** this extension uses your existing Pi resources and settings unless you load it in an isolated home (see the root README). Do not load two copies of the same extension (for example an older standalone file alongside this package): commands and hooks can duplicate. The package never installs or relocates personal settings.
|
|
14
|
+
|
|
15
|
+
## Commands and shortcuts
|
|
16
|
+
|
|
17
|
+
- `/session-nav` or `Alt+O` — opens the navigator.
|
|
18
|
+
- Up/Down to select, Enter to jump, Escape/Ctrl+C to cancel.
|
|
19
|
+
|
|
20
|
+
## Settings and storage
|
|
21
|
+
|
|
22
|
+
No settings file. The last selected message ID is kept in module memory only. Compacted-away and textless/tool-only messages are excluded.
|
|
23
|
+
|
|
24
|
+
## Privacy and operations
|
|
25
|
+
|
|
26
|
+
Reads the in-memory branch and rendered transcript; does not write files, send model requests or change the branch.
|
|
27
|
+
|
|
28
|
+
## Compatibility
|
|
29
|
+
|
|
30
|
+
Node.js >=24 for development checks; run extensions through Pi's TypeScript loader. Host imports are wildcard peers, not bundled runtime copies. Jumping relies on Pi's fullscreen TUI internal layout/component properties, not a stable public scrolling API. Regular terminal mode can open the dialog but cannot jump; a warning is shown. Runtime upgrades may break message matching or scrolling.
|
|
31
|
+
|
|
32
|
+
See [NOTICE.md](NOTICE.md) for provenance. Tests use the monorepo's TypeScript dev dependency and VM loader; they do not contact services or write to user-home resources.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saifymatteo/pi-session-nav",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "AGPL-3.0-only",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"NOTICE.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-tui": "*"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=24"
|
|
26
|
+
},
|
|
27
|
+
"description": "Jump between messages in the active context (compaction-aware).",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/saifymatteo/pi-extensions.git",
|
|
34
|
+
"directory": "packages/pi-session-nav"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { DIALOG_OVERLAY, dialogRows, wheelAsArrows } from "./lib/dialog.ts";
|
|
4
|
+
|
|
5
|
+
type Entry = { id: string; type: string; firstKeptEntryId?: string; message?: { role?: string; content?: unknown; timestamp?: number } };
|
|
6
|
+
type NavItem = { id: string; role: string; preview: string; timestamp: number; available: boolean };
|
|
7
|
+
|
|
8
|
+
let lastSelectedId: string | null = null;
|
|
9
|
+
|
|
10
|
+
function cleanText(value: unknown): string {
|
|
11
|
+
const blocks = typeof value === "string" ? [{ type: "text", text: value }] : Array.isArray(value) ? value : [];
|
|
12
|
+
return blocks.flatMap((block: any) => block?.type === "text" && typeof block.text === "string" ? [block.text] : block?.type === "image" ? ["[image]"] : [])
|
|
13
|
+
.join(" ").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function activeContextIds(branch: Entry[]): Set<string> {
|
|
17
|
+
const latestCompactionIndex = branch.reduce((latest, entry, index) => entry.type === "compaction" ? index : latest, -1);
|
|
18
|
+
if (latestCompactionIndex < 0) return new Set(branch.map((entry) => entry.id));
|
|
19
|
+
const compaction = branch[latestCompactionIndex];
|
|
20
|
+
const keptIndex = branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId);
|
|
21
|
+
const ids = new Set<string>([compaction.id]);
|
|
22
|
+
if (keptIndex >= 0 && keptIndex < latestCompactionIndex) {
|
|
23
|
+
for (const entry of branch.slice(keptIndex, latestCompactionIndex)) ids.add(entry.id);
|
|
24
|
+
}
|
|
25
|
+
for (const entry of branch.slice(latestCompactionIndex + 1)) ids.add(entry.id);
|
|
26
|
+
return ids;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildNavigationItems(branch: Entry[], activeIds: Set<string>): NavItem[] {
|
|
30
|
+
return branch.flatMap((entry) => {
|
|
31
|
+
if (entry.type !== "message" || (entry.message?.role !== "user" && entry.message?.role !== "assistant")) return [];
|
|
32
|
+
const preview = cleanText(entry.message.content);
|
|
33
|
+
// Assistant messages with no visible text are usually tool-call-only messages.
|
|
34
|
+
if (!preview || !activeIds.has(entry.id)) return [];
|
|
35
|
+
return [{ id: entry.id, role: entry.message.role, preview, timestamp: entry.message.timestamp ?? 0, available: true }];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function visibleRange(selected: number, itemCount: number, pageSize: number): number {
|
|
40
|
+
return Math.max(0, Math.min(selected - pageSize + 1, itemCount - pageSize));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeForMatch(value: unknown): string {
|
|
44
|
+
return cleanText(value).normalize("NFKC").toLocaleLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function visibleMessageText(message: any): string {
|
|
48
|
+
return cleanText(message?.content);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function walkComponents(root: any, out: any[] = [], seen = new Set<any>()): any[] {
|
|
52
|
+
if (!root || seen.has(root)) return out;
|
|
53
|
+
seen.add(root);
|
|
54
|
+
out.push(root);
|
|
55
|
+
for (const child of root.children ?? []) walkComponents(child, out, seen);
|
|
56
|
+
if (root.child && root.child !== root) walkComponents(root.child, out, seen);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function jumpToMessage(tui: any, item: NavItem, targetMessage: any, items: NavItem[]): boolean {
|
|
61
|
+
try {
|
|
62
|
+
if (tui?.mode !== "fullscreen") return false;
|
|
63
|
+
const layout = tui.currentLayout?.primaryScrollView;
|
|
64
|
+
const scroll = layout?.primary && typeof layout.scrollTo === "function" ? layout : walkComponents(tui.layoutRoot).find((component) => component.primary && typeof component.scrollTo === "function");
|
|
65
|
+
const doc = scroll?.child;
|
|
66
|
+
if (!scroll || !doc) return false;
|
|
67
|
+
const components = walkComponents(doc);
|
|
68
|
+
const targetText = normalizeForMatch(visibleMessageText(targetMessage));
|
|
69
|
+
const candidates = components.filter((component) => item.role === "user"
|
|
70
|
+
? typeof component.text === "string" && typeof component.rebuild === "function"
|
|
71
|
+
: component.lastMessage?.role === "assistant");
|
|
72
|
+
const matching = candidates.filter((component) => normalizeForMatch(item.role === "user" ? component.text : visibleMessageText(component.lastMessage)) === targetText);
|
|
73
|
+
const itemIndex = items.findIndex((candidate) => candidate.id === item.id);
|
|
74
|
+
if (itemIndex < 0) return false;
|
|
75
|
+
const occurrence = items.slice(0, itemIndex)
|
|
76
|
+
.filter((candidate) => candidate.role === item.role && normalizeForMatch(candidate.preview) === targetText).length;
|
|
77
|
+
const target = matching[occurrence];
|
|
78
|
+
if (!target) return false;
|
|
79
|
+
const width = Math.max(1, scroll.getContentWidth(tui.terminal?.columns ?? 80));
|
|
80
|
+
const stripAnsi = (line: string) => line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
81
|
+
const targetLines = target.render(width).map((line: string) => stripAnsi(line));
|
|
82
|
+
const firstRenderedLine = targetLines.find((line: string) => line.trim().length > 0);
|
|
83
|
+
if (!firstRenderedLine) return false;
|
|
84
|
+
const sameBefore = candidates.slice(0, candidates.indexOf(target))
|
|
85
|
+
.filter((component) => component.render(width).some((line: string) => stripAnsi(line) === firstRenderedLine)).length;
|
|
86
|
+
const docLines: string[] = doc.render(width);
|
|
87
|
+
let occurrenceInDocument = 0;
|
|
88
|
+
let offset = -1;
|
|
89
|
+
for (let index = 0; index < docLines.length; index++) {
|
|
90
|
+
if (stripAnsi(docLines[index]) === firstRenderedLine && occurrenceInDocument++ === sameBefore) {
|
|
91
|
+
offset = index;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (offset < 0) return false;
|
|
96
|
+
scroll.scrollTo(offset, { disableFollow: true });
|
|
97
|
+
tui.requestRender?.();
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class Navigator implements Component {
|
|
105
|
+
private selected: number;
|
|
106
|
+
private offset: number;
|
|
107
|
+
// Fixed lines: 2 borders, 2 blank spacers, footer.
|
|
108
|
+
private get pageSize(): number { return dialogRows(this.tui, 5); }
|
|
109
|
+
|
|
110
|
+
constructor(private readonly tui: any, private readonly items: NavItem[], private readonly done: (id: string | null) => void, private readonly theme: any, initialIndex: number) {
|
|
111
|
+
this.selected = Math.max(0, Math.min(initialIndex, items.length - 1));
|
|
112
|
+
this.offset = visibleRange(this.selected, items.length, this.pageSize);
|
|
113
|
+
}
|
|
114
|
+
invalidate(): void {}
|
|
115
|
+
|
|
116
|
+
render(width: number): string[] {
|
|
117
|
+
const border = (text: string) => this.theme.fg("border", text);
|
|
118
|
+
const inner = Math.max(1, width - 2);
|
|
119
|
+
const contentWidth = Math.max(1, inner - 2);
|
|
120
|
+
const title = ` Navigate conversation · ${this.items.length} messages `;
|
|
121
|
+
const topTitle = truncateToWidth(title, Math.max(1, inner - 1), "…");
|
|
122
|
+
const lines = [border(`┏${topTitle}${"━".repeat(Math.max(0, inner - visibleWidth(topTitle)))}┓`)];
|
|
123
|
+
const messageRow = (text: string, role: "user" | "assistant", selected: boolean) => {
|
|
124
|
+
const fitted = truncateToWidth(text, contentWidth, "…");
|
|
125
|
+
const padded = ` ${fitted}${" ".repeat(Math.max(0, contentWidth - visibleWidth(fitted)))} `;
|
|
126
|
+
const styledText = role === "user"
|
|
127
|
+
? this.theme.bg("userMessageBg", this.theme.fg(selected ? "warning" : "userMessageText", padded))
|
|
128
|
+
: this.theme.fg(selected ? "warning" : "dim", padded);
|
|
129
|
+
return border("┃") + styledText + border("┃");
|
|
130
|
+
};
|
|
131
|
+
const plainRow = (text: string) => {
|
|
132
|
+
const fitted = truncateToWidth(text, contentWidth, "…");
|
|
133
|
+
return border("┃") + ` ${fitted}${" ".repeat(Math.max(0, contentWidth - visibleWidth(fitted)))} ` + border("┃");
|
|
134
|
+
};
|
|
135
|
+
lines.push(plainRow(""));
|
|
136
|
+
const list = this.items.slice(this.offset, this.offset + this.pageSize);
|
|
137
|
+
for (let index = 0; index < this.pageSize; index++) {
|
|
138
|
+
const item = list[index];
|
|
139
|
+
const isSelected = this.offset + index === this.selected;
|
|
140
|
+
if (!item) {
|
|
141
|
+
lines.push(plainRow(""));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const prefix = isSelected ? "▶ " : " ";
|
|
145
|
+
const isLatest = this.offset + index === this.items.length - 1;
|
|
146
|
+
const row = `${prefix}${item.role === "user" ? "You" : "Pi"}${isLatest ? " · latest" : ""}: ${item.preview}`;
|
|
147
|
+
lines.push(messageRow(row, item.role as "user" | "assistant", isSelected));
|
|
148
|
+
}
|
|
149
|
+
lines.push(plainRow(""));
|
|
150
|
+
lines.push(plainRow(this.theme.fg("dim", `↑↓ navigate · ${this.selected + 1}/${this.items.length} · Enter jump · Esc close`)));
|
|
151
|
+
lines.push(border(`┗${"━".repeat(inner)}┛`));
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
handleMouse(event: TuiMouseEvent) { return wheelAsArrows(event, (key) => this.handleInput(key)); }
|
|
156
|
+
|
|
157
|
+
handleInput(data: string): void {
|
|
158
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return this.done(null);
|
|
159
|
+
if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
|
|
160
|
+
else if (matchesKey(data, "down")) this.selected = Math.min(this.items.length - 1, this.selected + 1);
|
|
161
|
+
else if (matchesKey(data, "enter")) {
|
|
162
|
+
const item = this.items[this.selected];
|
|
163
|
+
if (item) {
|
|
164
|
+
lastSelectedId = item.id;
|
|
165
|
+
this.done(item.available ? item.id : `hidden:${item.id}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this.offset = visibleRange(this.selected, this.items.length, this.pageSize);
|
|
169
|
+
this.tui.requestRender();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export default function (pi: ExtensionAPI): void {
|
|
174
|
+
async function open(ctx: any): Promise<void> {
|
|
175
|
+
const sessionManager = ctx.sessionManager;
|
|
176
|
+
const entries = sessionManager.getEntries() as Entry[];
|
|
177
|
+
const branch = sessionManager.getBranch() as Entry[];
|
|
178
|
+
let activeIds: Set<string>;
|
|
179
|
+
try {
|
|
180
|
+
const contextEntries = typeof sessionManager.buildContextEntries === "function" ? sessionManager.buildContextEntries() : undefined;
|
|
181
|
+
activeIds = Array.isArray(contextEntries) ? new Set(contextEntries.map((entry: Entry) => entry.id)) : activeContextIds(branch);
|
|
182
|
+
} catch {
|
|
183
|
+
activeIds = activeContextIds(branch);
|
|
184
|
+
}
|
|
185
|
+
const items = buildNavigationItems(branch, activeIds);
|
|
186
|
+
if (!items.length) return ctx.ui.notify("No user or assistant messages in this session", "info");
|
|
187
|
+
const previousIndex = lastSelectedId ? items.findIndex((item) => item.id === lastSelectedId) : -1;
|
|
188
|
+
const initialIndex = previousIndex >= 0 ? previousIndex : items.length - 1;
|
|
189
|
+
let tuiRef: any;
|
|
190
|
+
const selected: string | null = await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
191
|
+
tuiRef = tui;
|
|
192
|
+
return new Navigator(tui, items, done, theme, initialIndex);
|
|
193
|
+
}, DIALOG_OVERLAY);
|
|
194
|
+
if (selected === null) return;
|
|
195
|
+
if (selected.startsWith("hidden:")) return ctx.ui.notify("That message is hidden by compaction and cannot be jumped to", "warning");
|
|
196
|
+
const item = items.find((candidate) => candidate.id === selected);
|
|
197
|
+
const entry = entries.find((candidate) => candidate.id === selected);
|
|
198
|
+
if (!item?.available || !entry || entry.type !== "message") return ctx.ui.notify("Message is no longer available in this session", "warning");
|
|
199
|
+
if (!jumpToMessage(tuiRef, item, entry.message, items)) ctx.ui.notify("Could not locate that message in the visible transcript", "warning");
|
|
200
|
+
}
|
|
201
|
+
pi.registerCommand("session-nav", { description: "Navigate to a message in the current conversation", handler: async (_args, ctx) => open(ctx) });
|
|
202
|
+
pi.registerShortcut(Key.alt("o"), { description: "Navigate conversation", handler: async (ctx) => open(ctx) });
|
|
203
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// One size for every overlay dialog in this package. Change DIALOG_HEIGHT here and every dialog follows:
|
|
2
|
+
// the overlay is capped at that share of the terminal and each dialog pads its list to fill it.
|
|
3
|
+
|
|
4
|
+
/** Share of the terminal height every dialog fills. */
|
|
5
|
+
export const DIALOG_HEIGHT = 0.8
|
|
6
|
+
|
|
7
|
+
/** `ctx.ui.custom(factory, DIALOG_OVERLAY)`: centered, 92% wide, DIALOG_HEIGHT tall. */
|
|
8
|
+
export const DIALOG_OVERLAY = {
|
|
9
|
+
overlay: true,
|
|
10
|
+
overlayOptions: { anchor: 'center', width: '92%', maxHeight: `${DIALOG_HEIGHT * 100}%`, margin: 1 },
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Scrolling rows that fit once `chrome` fixed lines (borders, search, header, footer) are drawn.
|
|
15
|
+
* Same floor as the overlay's own percentage, so dialog and overlay agree; read it in render() so a
|
|
16
|
+
* resize takes effect on the next frame.
|
|
17
|
+
*/
|
|
18
|
+
export const dialogRows = (tui: { terminal?: { rows: number } }, chrome: number, min = 1): number =>
|
|
19
|
+
Math.max(min, Math.floor((tui.terminal?.rows ?? 24) * DIALOG_HEIGHT) - chrome)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mouse wheel → ↑/↓ key presses, so a dialog reuses its own keyboard handling (clamping, render).
|
|
23
|
+
* Use as `handleMouse(e) { return wheelAsArrows(e, (k) => this.handleInput(k)) }`. Fullscreen mode only:
|
|
24
|
+
* regular mode leaves the mouse to the terminal. Handling the wheel also stops it scrolling the transcript behind.
|
|
25
|
+
*/
|
|
26
|
+
export function wheelAsArrows(event: { type: string; wheelDelta?: number }, handleInput: (data: string) => void): { handled: true } | undefined {
|
|
27
|
+
if (event.type !== 'wheel' || !event.wheelDelta) return undefined
|
|
28
|
+
const key = event.wheelDelta < 0 ? '\x1b[A' : '\x1b[B'
|
|
29
|
+
for (let i = 0; i < Math.min(Math.abs(event.wheelDelta), 20); i++) handleInput(key)
|
|
30
|
+
return { handled: true }
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { loadExtension } from '../../../test/load-extension.mjs';
|
|
4
|
+
const { activeContextIds, buildNavigationItems, visibleRange } = loadExtension(new URL('../src/index.ts', import.meta.url), {
|
|
5
|
+
'@earendil-works/pi-tui': {},
|
|
6
|
+
});
|
|
7
|
+
const message = (id, role, content) => ({ id, type: 'message', message: { role, content, timestamp: 42 } });
|
|
8
|
+
|
|
9
|
+
test('latest compaction keeps its retained prefix and subsequent branch only', () => {
|
|
10
|
+
const branch = [message('old', 'user', 'old'), message('kept', 'user', 'kept'),
|
|
11
|
+
{ id: 'c1', type: 'compaction', firstKeptEntryId: 'kept' }, message('new', 'assistant', 'new')];
|
|
12
|
+
assert.deepEqual([...activeContextIds(branch)], ['c1', 'kept', 'new']);
|
|
13
|
+
const latest = [...branch, { id: 'c2', type: 'compaction', firstKeptEntryId: 'new' }, message('last', 'user', 'last')];
|
|
14
|
+
assert.deepEqual([...activeContextIds(latest)], ['c2', 'new', 'last']);
|
|
15
|
+
assert.deepEqual([...activeContextIds([{ id: 'c', type: 'compaction', firstKeptEntryId: 'missing' }, latest.at(-1)])], ['c', 'last']);
|
|
16
|
+
assert.deepEqual([...activeContextIds(branch.slice(0, 2))], ['old', 'kept']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('navigation excludes inactive, tool, metadata, and textless messages; cleans previews', () => {
|
|
20
|
+
const branch = [message('hidden', 'user', 'hidden'), message('user', 'user', [{ type: 'text', text: ' hello\n world\x1b ' }, { type: 'image' }]),
|
|
21
|
+
message('tool', 'toolResult', 'result'), message('silent', 'assistant', [{ type: 'toolCall', name: 'read' }]),
|
|
22
|
+
message('assistant', 'assistant', 'reply'), { id: 'custom', type: 'custom' }];
|
|
23
|
+
const items = JSON.parse(JSON.stringify(buildNavigationItems(branch, new Set(branch.slice(1).map(x => x.id)))));
|
|
24
|
+
assert.deepEqual(items, [
|
|
25
|
+
{ id: 'user', role: 'user', preview: 'hello world [image]', timestamp: 42, available: true },
|
|
26
|
+
{ id: 'assistant', role: 'assistant', preview: 'reply', timestamp: 42, available: true },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('visible window offset clamps at both edges including short and empty lists', () => {
|
|
31
|
+
assert.equal(visibleRange(0, 20, 5), 0);
|
|
32
|
+
assert.equal(visibleRange(7, 20, 5), 3);
|
|
33
|
+
assert.equal(visibleRange(50, 20, 5), 15);
|
|
34
|
+
assert.equal(visibleRange(2, 3, 10), 0);
|
|
35
|
+
assert.equal(visibleRange(0, 0, 10), 0);
|
|
36
|
+
});
|