@ashwin-pc/pi-web 0.1.2
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/.pi/extensions/auto-session-name.ts +82 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/bin/pi-web.js +35 -0
- package/contexts/web-ui.md +18 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/index-BNqFbA4l.css +1 -0
- package/dist/assets/index-RfnHuF-b.js +79 -0
- package/dist/icon.svg +4 -0
- package/dist/index.html +174 -0
- package/dist/manifest.webmanifest +1 -0
- package/dist/pwa-192x192.png +0 -0
- package/dist/pwa-512x512.png +0 -0
- package/dist/registerSW.js +1 -0
- package/dist/sw.js +128 -0
- package/dist/workbox-a24bf94b.js +3645 -0
- package/docs/frontend-architecture.md +72 -0
- package/package.json +55 -0
- package/server/extensions.ts +121 -0
- package/server/mock.ts +402 -0
- package/server/settings.ts +155 -0
- package/server/types.ts +76 -0
- package/server.ts +1789 -0
- package/supervisor.ts +205 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Frontend architecture
|
|
2
|
+
|
|
3
|
+
The frontend is intentionally plain TypeScript on top of Vite. It is organized as small feature controllers instead of a framework component tree.
|
|
4
|
+
|
|
5
|
+
## Entry point
|
|
6
|
+
|
|
7
|
+
`src/main.ts` should stay a composition/bootstrap file. It is responsible for:
|
|
8
|
+
|
|
9
|
+
1. importing global CSS;
|
|
10
|
+
2. creating shared objects (`elements`, `state`, `api`);
|
|
11
|
+
3. constructing feature controllers;
|
|
12
|
+
4. wiring cross-controller callbacks; and
|
|
13
|
+
5. starting the initial state refresh and WebSocket connection.
|
|
14
|
+
|
|
15
|
+
Avoid adding feature logic directly to `main.ts`. If a change needs more than a small wiring callback, put it in the feature module that owns the behavior.
|
|
16
|
+
|
|
17
|
+
## Shared app objects
|
|
18
|
+
|
|
19
|
+
- `src/app/elements.ts` - all required DOM lookups and viewport-height syncing.
|
|
20
|
+
- `src/app/types.ts` - shared frontend state and API/event types.
|
|
21
|
+
- `src/app/api.ts` - auth headers and WebSocket URL construction.
|
|
22
|
+
- `src/app/icons.ts` - shared Lucide icon helpers.
|
|
23
|
+
|
|
24
|
+
Feature modules receive the pieces they need explicitly. Prefer passing `state`, `elements`, `api`, and callbacks over importing another feature module's internals.
|
|
25
|
+
|
|
26
|
+
## Feature modules
|
|
27
|
+
|
|
28
|
+
- `src/composer/composer.ts` - prompt form, slash commands, image attachments, queue mode, stop/send buttons, token form, editor expansion.
|
|
29
|
+
- `src/messages/messageList.ts` - chat message rendering, streaming assistant deltas, message history refresh.
|
|
30
|
+
- `src/messages/content.ts` - raw Pi message content parsing helpers.
|
|
31
|
+
- `src/markdown/render.ts` - Markdown sanitizing, syntax highlighting, lazy rendering, copy buttons.
|
|
32
|
+
- `src/components/imageActions.ts` - image fullscreen/download/open controls used by messages and Markdown.
|
|
33
|
+
- `src/tools/toolCards.ts` - running and historical tool cards.
|
|
34
|
+
- `src/models/modelSettings.ts` - model/reasoning popover and model selection API calls.
|
|
35
|
+
- `src/status/statusBar.ts` - session title/path display, title rename flow, WebSocket connection status UI.
|
|
36
|
+
- `src/sessions/sessionDrawer.ts` - sessions drawer, cwd grouping, new/open session flows, folder picker, empty-cwd chooser.
|
|
37
|
+
- `src/realtime/realtime.ts` - WebSocket lifecycle and Pi event dispatch.
|
|
38
|
+
- `src/git/*` - Git panel API, state, and views.
|
|
39
|
+
|
|
40
|
+
## State and data flow
|
|
41
|
+
|
|
42
|
+
`AppState` in `src/app/types.ts` contains the shared mutable UI state. Controllers update it directly when they own the relevant interaction, then call the smallest UI update function needed.
|
|
43
|
+
|
|
44
|
+
Common shared callbacks are defined in `main.ts`:
|
|
45
|
+
|
|
46
|
+
- `updateMeta(data)` updates model/session/cwd metadata and delegates summary/status rendering.
|
|
47
|
+
- `refreshMessages()` reloads history and asks the tool-card controller to clear active cards.
|
|
48
|
+
- `refreshState()` reloads server state, model metadata, messages, and the session title.
|
|
49
|
+
|
|
50
|
+
When adding a new feature, keep ownership clear:
|
|
51
|
+
|
|
52
|
+
- rendering for a UI area should live with that area's controller;
|
|
53
|
+
- server calls should live in the controller that owns the user interaction;
|
|
54
|
+
- cross-feature updates should go through narrow callbacks passed from `main.ts`.
|
|
55
|
+
|
|
56
|
+
## Testing expectations
|
|
57
|
+
|
|
58
|
+
After TypeScript or frontend behavior changes, run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm run typecheck
|
|
62
|
+
npm run build
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For behavior changes, also run the relevant tests or the full suite:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm run test:unit
|
|
69
|
+
npm run test:e2e
|
|
70
|
+
# or
|
|
71
|
+
npm test
|
|
72
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ashwin-pc/pi-web",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Local/Tailscale web UI for pi coding agent.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pi-web": "./bin/pi-web.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
".pi/extensions/",
|
|
12
|
+
"bin/",
|
|
13
|
+
"contexts/",
|
|
14
|
+
"dist/",
|
|
15
|
+
"docs/",
|
|
16
|
+
"server/",
|
|
17
|
+
"server.ts",
|
|
18
|
+
"supervisor.ts",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "PI_WEB_DEV=1 node --import tsx supervisor.ts",
|
|
23
|
+
"dev:server": "PI_WEB_DEV=1 node --import tsx --watch server.ts",
|
|
24
|
+
"build": "vite build",
|
|
25
|
+
"start": "node --import tsx server.ts",
|
|
26
|
+
"start:supervised": "node --import tsx supervisor.ts",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "npm run typecheck && vitest run && playwright test",
|
|
29
|
+
"test:unit": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"test:e2e": "playwright test",
|
|
32
|
+
"release:pack": "rm -rf release && npm run build && mkdir -p release && npm pack --pack-destination release",
|
|
33
|
+
"test:all": "npm test"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@mariozechner/pi-coding-agent": "^0.73.0",
|
|
40
|
+
"highlight.js": "^11.11.1",
|
|
41
|
+
"lucide": "^1.14.0",
|
|
42
|
+
"marked": "^15.0.12",
|
|
43
|
+
"tsx": "^4.20.6",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vite": "^7.2.7",
|
|
46
|
+
"ws": "^8.18.3"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@playwright/test": "^1.59.1",
|
|
50
|
+
"@types/node": "^24.10.1",
|
|
51
|
+
"@types/ws": "^8.5.14",
|
|
52
|
+
"vite-plugin-pwa": "^1.3.0",
|
|
53
|
+
"vitest": "^4.1.5"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
type BundledExtensionPathOptions = {
|
|
5
|
+
piCwd: string;
|
|
6
|
+
appDir: string;
|
|
7
|
+
bundledExtensionsDir: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function isExtensionFile(name: string) {
|
|
11
|
+
return name.endsWith(".ts") || name.endsWith(".js");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function dedupePaths(paths: string[]) {
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
return paths.filter((path) => {
|
|
17
|
+
const key = resolve(path);
|
|
18
|
+
if (seen.has(key)) return false;
|
|
19
|
+
seen.add(key);
|
|
20
|
+
return true;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readManifestExtensionEntries(dir: string, seenDirs: Set<string>) {
|
|
25
|
+
const packageJsonPath = join(dir, "package.json");
|
|
26
|
+
if (!existsSync(packageJsonPath)) return [];
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { pi?: { extensions?: unknown } };
|
|
30
|
+
if (!Array.isArray(pkg.pi?.extensions)) return [];
|
|
31
|
+
|
|
32
|
+
return pkg.pi.extensions.flatMap((entry) => {
|
|
33
|
+
if (typeof entry !== "string") return [];
|
|
34
|
+
const entryPath = resolve(dir, entry);
|
|
35
|
+
if (!existsSync(entryPath)) return [];
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const stats = statSync(entryPath);
|
|
39
|
+
if (stats.isFile()) return [entryPath];
|
|
40
|
+
if (stats.isDirectory()) return discoverExtensionEntryPaths(entryPath, seenDirs);
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore invalid manifest entries.
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveExtensionEntries(dir: string, seenDirs: Set<string>) {
|
|
52
|
+
const manifestEntries = readManifestExtensionEntries(dir, seenDirs);
|
|
53
|
+
if (manifestEntries.length > 0) return manifestEntries;
|
|
54
|
+
|
|
55
|
+
const indexTs = join(dir, "index.ts");
|
|
56
|
+
if (existsSync(indexTs)) return [indexTs];
|
|
57
|
+
|
|
58
|
+
const indexJs = join(dir, "index.js");
|
|
59
|
+
if (existsSync(indexJs)) return [indexJs];
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function discoverExtensionEntryPaths(dir: string, seenDirs = new Set<string>()): string[] {
|
|
65
|
+
if (!existsSync(dir)) return [];
|
|
66
|
+
|
|
67
|
+
const dirKey = resolve(dir);
|
|
68
|
+
if (seenDirs.has(dirKey)) return [];
|
|
69
|
+
seenDirs.add(dirKey);
|
|
70
|
+
|
|
71
|
+
const rootEntries = resolveExtensionEntries(dir, seenDirs);
|
|
72
|
+
if (rootEntries) return dedupePaths(rootEntries);
|
|
73
|
+
|
|
74
|
+
const discovered: string[] = [];
|
|
75
|
+
try {
|
|
76
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
79
|
+
|
|
80
|
+
const entryPath = join(dir, entry.name);
|
|
81
|
+
let isFile = entry.isFile();
|
|
82
|
+
let isDirectory = entry.isDirectory();
|
|
83
|
+
|
|
84
|
+
if (entry.isSymbolicLink()) {
|
|
85
|
+
try {
|
|
86
|
+
const stats = statSync(entryPath);
|
|
87
|
+
isFile = stats.isFile();
|
|
88
|
+
isDirectory = stats.isDirectory();
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (isFile && isExtensionFile(entry.name)) {
|
|
95
|
+
discovered.push(entryPath);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (isDirectory) {
|
|
100
|
+
const nestedEntries = resolveExtensionEntries(entryPath, seenDirs);
|
|
101
|
+
if (nestedEntries) discovered.push(...nestedEntries);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return dedupePaths(discovered);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resolveBundledExtensionPaths(options: BundledExtensionPathOptions) {
|
|
112
|
+
// When developing pi-web from this repo, the same directory is already loaded
|
|
113
|
+
// by pi as the project-local .pi/extensions path. For other PI_WEB_CWD values
|
|
114
|
+
// and packaged installs, add pi-web's bundled extensions explicitly.
|
|
115
|
+
if (resolve(options.piCwd) === resolve(options.appDir) || !existsSync(options.bundledExtensionsDir)) return [];
|
|
116
|
+
|
|
117
|
+
// DefaultResourceLoader resolves additionalExtensionPaths as package sources.
|
|
118
|
+
// Passing the extensions directory itself can produce the directory as a single
|
|
119
|
+
// extension path, so expand it to concrete entry files before handing it to pi.
|
|
120
|
+
return discoverExtensionEntryPaths(options.bundledExtensionsDir);
|
|
121
|
+
}
|
package/server/mock.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { PiWebSession, PiWebSessionInfo } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface MockSessionOptions {
|
|
5
|
+
piCwd: string;
|
|
6
|
+
broadcast(value: unknown): void;
|
|
7
|
+
isCurrentSession(session: PiWebSession): boolean;
|
|
8
|
+
currentState(): unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createMockHarness(options: MockSessionOptions) {
|
|
12
|
+
const { piCwd, broadcast, isCurrentSession, currentState } = options;
|
|
13
|
+
const mockModel = { provider: "mock", id: "model", name: "Mock Model", reasoning: true, contextWindow: 128000, maxTokens: 4096 };
|
|
14
|
+
|
|
15
|
+
function initialMockSessions(): PiWebSessionInfo[] {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
id: "mock-current",
|
|
19
|
+
path: join(piCwd, ".mock-sessions/current.jsonl"),
|
|
20
|
+
name: "Current mock session",
|
|
21
|
+
firstMessage: "Can you add image attachments?",
|
|
22
|
+
created: new Date("2026-05-01T10:00:00Z"),
|
|
23
|
+
modified: new Date("2026-05-07T10:00:00Z"),
|
|
24
|
+
messageCount: 2,
|
|
25
|
+
allMessagesText: "Can you add image attachments?",
|
|
26
|
+
cwd: piCwd,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "mock-older",
|
|
30
|
+
path: join(piCwd, ".mock-sessions/older.jsonl"),
|
|
31
|
+
name: "Older mock session",
|
|
32
|
+
firstMessage: "Review the mobile composer layout",
|
|
33
|
+
created: new Date("2026-05-01T09:00:00Z"),
|
|
34
|
+
modified: new Date("2026-05-06T09:00:00Z"),
|
|
35
|
+
messageCount: 4,
|
|
36
|
+
allMessagesText: "Review the mobile composer layout",
|
|
37
|
+
cwd: piCwd,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const mockSessions: PiWebSessionInfo[] = initialMockSessions();
|
|
43
|
+
|
|
44
|
+
function resetMockSessions() {
|
|
45
|
+
mockSessions.splice(0, mockSessions.length, ...initialMockSessions());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function initialMessages(path: string): unknown[] {
|
|
49
|
+
return path === mockSessions[1].path
|
|
50
|
+
? [
|
|
51
|
+
{ role: "user", content: "Review the mobile composer layout", timestamp: "2026-05-06T09:00:00Z" },
|
|
52
|
+
{ role: "assistant", content: "Resumed older session.", usage: { input: 4200, output: 320, cacheRead: 1200, cacheWrite: 0, cost: { total: 0.018 } }, timestamp: "2026-05-06T09:01:00Z" },
|
|
53
|
+
]
|
|
54
|
+
: [
|
|
55
|
+
{ role: "user", content: "Can you add image attachments?", timestamp: "2026-05-07T10:00:00Z" },
|
|
56
|
+
{ role: "assistant", content: ("## Image attachment support\n\nImage attachment support is **enabled**.\n\n- Upload images\n- Preview images\n\n```ts\nconst enabled = true;\n```\n\n").repeat(18), usage: { input: 18600, output: 3400, cacheRead: 9200, cacheWrite: 800, cost: { total: 0.092 } }, timestamp: "2026-05-07T10:01:00Z" },
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function textFromMockContent(content: unknown): string {
|
|
61
|
+
if (typeof content === "string") return content;
|
|
62
|
+
if (!Array.isArray(content)) return "";
|
|
63
|
+
return content.map((part) => {
|
|
64
|
+
if (!part || typeof part !== "object") return "";
|
|
65
|
+
const value = part as Record<string, unknown>;
|
|
66
|
+
return value.type === "text" && typeof value.text === "string" ? value.text : "";
|
|
67
|
+
}).filter(Boolean).join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createInitialEntries(path: string) {
|
|
71
|
+
const [first, second] = initialMessages(path) as Array<Record<string, unknown>>;
|
|
72
|
+
const entries = [
|
|
73
|
+
{ type: "message", id: "mock-u1", parentId: null, timestamp: String(first.timestamp), message: first },
|
|
74
|
+
{ type: "message", id: "mock-a1", parentId: "mock-u1", timestamp: String(second.timestamp), message: second },
|
|
75
|
+
];
|
|
76
|
+
if (path === mockSessions[0].path) {
|
|
77
|
+
entries.push(
|
|
78
|
+
{ type: "message", id: "mock-u-alt", parentId: "mock-u1", timestamp: "2026-05-07T10:02:00Z", message: { role: "user", content: "Actually, make the attachment picker mobile-first.", timestamp: "2026-05-07T10:02:00Z" } },
|
|
79
|
+
{ type: "message", id: "mock-a-alt", parentId: "mock-u-alt", timestamp: "2026-05-07T10:03:00Z", message: { role: "assistant", content: "Use a bottom sheet with large tap targets for image actions.", timestamp: "2026-05-07T10:03:00Z" } },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createMockSession(path = mockSessions[0].path): PiWebSession {
|
|
86
|
+
let mockEntries = createInitialEntries(path);
|
|
87
|
+
let mockLeafId: string | null = "mock-a1";
|
|
88
|
+
let entrySequence = 2;
|
|
89
|
+
const labelsById = new Map<string, string>();
|
|
90
|
+
const mockMessages: unknown[] = [];
|
|
91
|
+
|
|
92
|
+
function entryById(id: string) {
|
|
93
|
+
return mockEntries.find((entry) => entry.id === id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getBranch(fromId = mockLeafId): any[] {
|
|
97
|
+
if (!fromId) return [];
|
|
98
|
+
const branch = [];
|
|
99
|
+
let current: string | null = fromId;
|
|
100
|
+
while (current) {
|
|
101
|
+
const entry = entryById(current);
|
|
102
|
+
if (!entry) break;
|
|
103
|
+
branch.unshift(entry);
|
|
104
|
+
current = entry.parentId;
|
|
105
|
+
}
|
|
106
|
+
return branch;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function syncMessagesToLeaf() {
|
|
110
|
+
mockMessages.length = 0;
|
|
111
|
+
mockMessages.push(...getBranch().filter((entry) => entry.type === "message").map((entry) => entry.message));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildTree(parentId: string | null): any[] {
|
|
115
|
+
return mockEntries
|
|
116
|
+
.filter((entry) => entry.parentId === parentId)
|
|
117
|
+
.map((entry) => ({
|
|
118
|
+
entry,
|
|
119
|
+
label: labelsById.get(entry.id),
|
|
120
|
+
children: buildTree(entry.id),
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function appendMockMessage(message: Record<string, unknown>) {
|
|
125
|
+
const timestamp = String(message.timestamp || new Date().toISOString());
|
|
126
|
+
message.timestamp = timestamp;
|
|
127
|
+
const id = `mock-e${++entrySequence}`;
|
|
128
|
+
mockEntries.push({ type: "message", id, parentId: mockLeafId, timestamp, message });
|
|
129
|
+
mockLeafId = id;
|
|
130
|
+
syncMessagesToLeaf();
|
|
131
|
+
return id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resetMockEntries(nextPath: string) {
|
|
135
|
+
mockEntries = createInitialEntries(nextPath);
|
|
136
|
+
mockLeafId = "mock-a1";
|
|
137
|
+
entrySequence = 2;
|
|
138
|
+
labelsById.clear();
|
|
139
|
+
syncMessagesToLeaf();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
syncMessagesToLeaf();
|
|
143
|
+
let mockSession: PiWebSession;
|
|
144
|
+
let compactionAbortRequested = false;
|
|
145
|
+
|
|
146
|
+
async function runMockCompaction(customInstructions?: string, slow = false) {
|
|
147
|
+
mockSession.isCompacting = true;
|
|
148
|
+
compactionAbortRequested = false;
|
|
149
|
+
broadcastRuntimeChanged();
|
|
150
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "compaction_start", reason: "manual" } });
|
|
151
|
+
if (isCurrentSession(mockSession)) broadcast({ type: "state_changed", ...currentState() as object });
|
|
152
|
+
const deadline = Date.now() + (slow ? 5_000 : 1_000);
|
|
153
|
+
while (!compactionAbortRequested && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
154
|
+
mockSession.isCompacting = false;
|
|
155
|
+
if (compactionAbortRequested) {
|
|
156
|
+
broadcastRuntimeChanged();
|
|
157
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "compaction_end", reason: "manual", aborted: true } });
|
|
158
|
+
if (isCurrentSession(mockSession)) broadcast({ type: "state_changed", ...currentState() as object });
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const result = {
|
|
162
|
+
tokensBefore: 12345,
|
|
163
|
+
summary: customInstructions ? `Mock compacted context summary. Instructions: ${customInstructions}` : "Mock compacted context summary.",
|
|
164
|
+
};
|
|
165
|
+
appendMockMessage({ role: "compactionSummary", content: result.summary, tokensBefore: result.tokensBefore, summary: result.summary, timestamp: new Date().toISOString() } as any);
|
|
166
|
+
broadcastRuntimeChanged();
|
|
167
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "compaction_end", reason: "manual", result } });
|
|
168
|
+
if (isCurrentSession(mockSession)) broadcast({ type: "state_changed", ...currentState() as object });
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const mockSessionManager = {
|
|
173
|
+
newSession() {
|
|
174
|
+
mockSession.sessionId = `mock-${Date.now()}`;
|
|
175
|
+
mockSession.sessionFile = join(piCwd, `.mock-sessions/${mockSession.sessionId}.jsonl`);
|
|
176
|
+
mockEntries = [];
|
|
177
|
+
mockLeafId = null;
|
|
178
|
+
labelsById.clear();
|
|
179
|
+
syncMessagesToLeaf();
|
|
180
|
+
},
|
|
181
|
+
setSessionFile(path: string) {
|
|
182
|
+
mockSession.sessionFile = path;
|
|
183
|
+
mockSession.sessionId = mockSessions.find((info) => info.path === path)?.id || "mock-opened";
|
|
184
|
+
resetMockEntries(path);
|
|
185
|
+
},
|
|
186
|
+
buildSessionContext() { return { messages: mockMessages }; },
|
|
187
|
+
getSessionDir() { return join(piCwd, ".mock-sessions"); },
|
|
188
|
+
getLeafId() { return mockLeafId; },
|
|
189
|
+
getEntry(id: string) { return entryById(id); },
|
|
190
|
+
getBranch,
|
|
191
|
+
getTree() { return buildTree(null); },
|
|
192
|
+
getLabel(id: string) { return labelsById.get(id); },
|
|
193
|
+
branch(entryId: string) {
|
|
194
|
+
if (!entryById(entryId)) throw new Error("Entry not found");
|
|
195
|
+
mockLeafId = entryId;
|
|
196
|
+
syncMessagesToLeaf();
|
|
197
|
+
},
|
|
198
|
+
resetLeaf() {
|
|
199
|
+
mockLeafId = null;
|
|
200
|
+
syncMessagesToLeaf();
|
|
201
|
+
},
|
|
202
|
+
appendLabelChange(targetId: string, label: string | undefined) {
|
|
203
|
+
if (label) labelsById.set(targetId, label);
|
|
204
|
+
else labelsById.delete(targetId);
|
|
205
|
+
return `mock-label-${Date.now()}`;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function broadcastRuntimeChanged() {
|
|
210
|
+
broadcast({
|
|
211
|
+
type: "session_runtime_changed",
|
|
212
|
+
sessionId: mockSession.sessionId,
|
|
213
|
+
sessionFile: mockSession.sessionFile,
|
|
214
|
+
runtime: {
|
|
215
|
+
loaded: true,
|
|
216
|
+
isRunning: Boolean(mockSession.isStreaming) || Boolean(mockSession.isCompacting),
|
|
217
|
+
isStreaming: Boolean(mockSession.isStreaming),
|
|
218
|
+
isCompacting: Boolean(mockSession.isCompacting),
|
|
219
|
+
pendingMessageCount: 0,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
mockSession = {
|
|
225
|
+
sessionId: mockSessions.find((info) => info.path === path)?.id || "mock-current",
|
|
226
|
+
sessionFile: path,
|
|
227
|
+
isStreaming: false,
|
|
228
|
+
model: mockModel,
|
|
229
|
+
thinkingLevel: "medium",
|
|
230
|
+
messages: mockMessages,
|
|
231
|
+
agent: { state: { messages: mockMessages } },
|
|
232
|
+
sessionManager: mockSessionManager,
|
|
233
|
+
modelRegistry: {
|
|
234
|
+
getAvailable: () => [mockModel],
|
|
235
|
+
find: (provider: string, id: string) => provider === mockModel.provider && id === mockModel.id ? mockModel : undefined,
|
|
236
|
+
},
|
|
237
|
+
extensionRunner: {
|
|
238
|
+
getRegisteredCommands: () => [{
|
|
239
|
+
invocationName: "mock-extension",
|
|
240
|
+
description: "Mock extension command",
|
|
241
|
+
sourceInfo: { path: "<mock-extension>", source: "mock", scope: "temporary", origin: "top-level" },
|
|
242
|
+
}],
|
|
243
|
+
},
|
|
244
|
+
promptTemplates: [{
|
|
245
|
+
name: "mock-prompt",
|
|
246
|
+
description: "Mock prompt template",
|
|
247
|
+
sourceInfo: { path: "<mock-prompt>", source: "mock", scope: "temporary", origin: "top-level" },
|
|
248
|
+
}],
|
|
249
|
+
resourceLoader: {
|
|
250
|
+
getSkills: () => ({ skills: [{
|
|
251
|
+
name: "mock-skill",
|
|
252
|
+
description: "Mock skill",
|
|
253
|
+
sourceInfo: { path: "<mock-skill>", source: "mock", scope: "temporary", origin: "top-level" },
|
|
254
|
+
}] }),
|
|
255
|
+
},
|
|
256
|
+
getAvailableThinkingLevels: () => ["off", "low", "medium", "high"],
|
|
257
|
+
get sessionName() { return mockSessions.find((info) => info.path === mockSession.sessionFile)?.name; },
|
|
258
|
+
getContextUsage: () => {
|
|
259
|
+
const lastAssistant = [...mockMessages].reverse().find((message: any) => message?.role === "assistant" && message?.usage) as any;
|
|
260
|
+
const tokens = Number(lastAssistant?.usage?.input || 0) || null;
|
|
261
|
+
const contextWindow = mockModel.contextWindow;
|
|
262
|
+
return { tokens, contextWindow, percent: tokens === null ? null : Math.round((tokens / contextWindow) * 1000) / 10 };
|
|
263
|
+
},
|
|
264
|
+
setSessionName: (name: string) => {
|
|
265
|
+
const info = mockSessions.find((item) => item.path === mockSession.sessionFile);
|
|
266
|
+
if (info) info.name = name.trim();
|
|
267
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "session_info_changed", name: name.trim() || undefined } });
|
|
268
|
+
if (isCurrentSession(mockSession)) broadcast({ type: "state_changed", ...currentState() as object });
|
|
269
|
+
},
|
|
270
|
+
setModel: async (model: unknown) => { mockSession.model = model as typeof mockModel; },
|
|
271
|
+
setThinkingLevel: (level: string) => { mockSession.thinkingLevel = level; },
|
|
272
|
+
reload: async () => undefined,
|
|
273
|
+
navigateTree: async (targetId: string, navigateOptions?: { summarize?: boolean; customInstructions?: string; label?: string }) => {
|
|
274
|
+
const target = entryById(targetId);
|
|
275
|
+
if (!target) throw new Error("Entry not found");
|
|
276
|
+
const oldLeafId = mockLeafId;
|
|
277
|
+
let nextLeafId: string | null = targetId;
|
|
278
|
+
let editorText: string | undefined;
|
|
279
|
+
if (target.type === "message" && target.message.role === "user") {
|
|
280
|
+
nextLeafId = target.parentId;
|
|
281
|
+
editorText = textFromMockContent(target.message.content);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (navigateOptions?.summarize && oldLeafId && oldLeafId !== targetId) {
|
|
285
|
+
const timestamp = new Date().toISOString();
|
|
286
|
+
const id = `mock-summary-${Date.now()}`;
|
|
287
|
+
mockEntries.push({
|
|
288
|
+
type: "branch_summary",
|
|
289
|
+
id,
|
|
290
|
+
parentId: nextLeafId,
|
|
291
|
+
timestamp,
|
|
292
|
+
fromId: oldLeafId,
|
|
293
|
+
summary: navigateOptions.customInstructions ? `Mock branch summary focused on: ${navigateOptions.customInstructions}` : "Mock branch summary of the branch you left.",
|
|
294
|
+
} as any);
|
|
295
|
+
mockLeafId = id;
|
|
296
|
+
if (navigateOptions.label) labelsById.set(id, navigateOptions.label);
|
|
297
|
+
} else {
|
|
298
|
+
mockLeafId = nextLeafId;
|
|
299
|
+
if (navigateOptions?.label) labelsById.set(targetId, navigateOptions.label);
|
|
300
|
+
}
|
|
301
|
+
syncMessagesToLeaf();
|
|
302
|
+
return { editorText, cancelled: false };
|
|
303
|
+
},
|
|
304
|
+
compact: async (customInstructions?: string) => runMockCompaction(customInstructions),
|
|
305
|
+
prompt: async (message: string, promptOptions?: { images?: unknown[] }) => {
|
|
306
|
+
appendMockMessage({ role: "user", content: message, timestamp: new Date().toISOString() });
|
|
307
|
+
const withCompaction = /compact|compaction/i.test(message);
|
|
308
|
+
if (withCompaction) {
|
|
309
|
+
await runMockCompaction(undefined, /slow/i.test(message));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const slow = /slow|running/i.test(message);
|
|
313
|
+
const withShowcase = /showcase/i.test(message);
|
|
314
|
+
const withProviderError = /provider error|assistant error|usage limit/i.test(message);
|
|
315
|
+
const withMalformedEditTool = /malformed edit/i.test(message);
|
|
316
|
+
const withEditTool = !withShowcase && !withMalformedEditTool && /edit diff/i.test(message);
|
|
317
|
+
const withPendingToolRefresh = /pending tool refresh/i.test(message);
|
|
318
|
+
const withTools = !withShowcase && !withEditTool && !withMalformedEditTool && /tool|interleav/i.test(message);
|
|
319
|
+
mockSession.isStreaming = true;
|
|
320
|
+
broadcastRuntimeChanged();
|
|
321
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "agent_start" } });
|
|
322
|
+
if (slow) await new Promise((resolve) => setTimeout(resolve, 750));
|
|
323
|
+
if (withProviderError) {
|
|
324
|
+
appendMockMessage({
|
|
325
|
+
role: "assistant",
|
|
326
|
+
content: [],
|
|
327
|
+
stopReason: "error",
|
|
328
|
+
errorMessage: "Codex error: {\"type\":\"error\",\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"The usage limit has been reached\",\"resets_in_seconds\":120},\"status_code\":429}",
|
|
329
|
+
timestamp: new Date().toISOString(),
|
|
330
|
+
});
|
|
331
|
+
} else if (withShowcase) {
|
|
332
|
+
const editArgs = { path: "/Users/ashwin/projects/pi-web/src/style.css", edits: [{ oldText: ".sessionItem {\n min-height: 40px;\n}", newText: ".sessionItem {\n height: auto;\n min-height: 0;\n}\n\n@media (max-width: 700px) {\n .sessionDrawer { width: 100vw; }\n}" }] };
|
|
333
|
+
appendMockMessage({ role: "assistant", content: [
|
|
334
|
+
{ type: "text", text: "## Mobile-first coding UI\n\nI reviewed the session drawer, checked the CSS, and tightened the responsive layout.\n\n```ts\nawait runVisualRegression({ projects: [\"mobile\", \"desktop\"] });\n```" },
|
|
335
|
+
{ type: "toolCall", id: "call-showcase-read", toolName: "read", arguments: { path: "/Users/ashwin/projects/pi-web/src/style.css" } },
|
|
336
|
+
{ type: "text", text: "The global button height was constraining session rows, so I patched the drawer-specific styles." },
|
|
337
|
+
{ type: "toolCall", id: "call-showcase-edit", toolName: "edit", arguments: editArgs },
|
|
338
|
+
{ type: "text", text: "Visual snapshots now cover the polished desktop and mobile states.\n\n" },
|
|
339
|
+
], timestamp: new Date().toISOString() });
|
|
340
|
+
appendMockMessage({ role: "toolResult", toolCallId: "call-showcase-read", toolName: "read", content: "session drawer CSS and responsive composer styles", timestamp: new Date().toISOString() });
|
|
341
|
+
appendMockMessage({ role: "toolResult", toolCallId: "call-showcase-edit", toolName: "edit", toolArgs: editArgs, content: "Successfully replaced 1 block(s) in /Users/ashwin/projects/pi-web/src/style.css.", timestamp: new Date().toISOString() });
|
|
342
|
+
} else if (withEditTool || withMalformedEditTool) {
|
|
343
|
+
const editArgs = withMalformedEditTool
|
|
344
|
+
? { path: "/some/file.ts", edits: [{ newText: "const answer = 42;" }, { oldText: "console.log(answer);" }] }
|
|
345
|
+
: { path: "/some/file.ts", edits: [{ oldText: "const answer = 41;\nconsole.log(answer);", newText: "const answer = 42;\nconsole.info(answer);" }] };
|
|
346
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "tool_execution_start", toolName: "edit", toolCallId: "call-edit", args: editArgs } });
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
348
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "tool_execution_end", toolName: "edit", toolCallId: "call-edit", isError: false, result: "Successfully replaced 1 block(s) in /some/file.ts." } });
|
|
349
|
+
appendMockMessage({ role: "assistant", content: [{ type: "toolCall", id: "call-edit", toolName: "edit", arguments: editArgs }], timestamp: new Date().toISOString() });
|
|
350
|
+
appendMockMessage({ role: "toolResult", toolCallId: "call-edit", toolName: "edit", toolArgs: editArgs, content: "Successfully replaced 1 block(s) in /some/file.ts.", timestamp: new Date().toISOString() });
|
|
351
|
+
} else if (withTools) {
|
|
352
|
+
const toolCallId = withPendingToolRefresh ? `call-pending-${Date.now()}` : "call-1";
|
|
353
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Let me check that for you. " } } });
|
|
354
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
355
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "tool_execution_start", toolName: "read", toolCallId, args: { path: "/some/file" } } });
|
|
356
|
+
if (withPendingToolRefresh) {
|
|
357
|
+
appendMockMessage({ role: "assistant", content: [
|
|
358
|
+
{ type: "text", text: "Let me check that for you. " },
|
|
359
|
+
{ type: "toolCall", id: toolCallId, toolName: "read", arguments: { path: "/some/file" } },
|
|
360
|
+
], timestamp: new Date().toISOString() });
|
|
361
|
+
}
|
|
362
|
+
await new Promise((resolve) => setTimeout(resolve, withPendingToolRefresh ? 3_000 : 150));
|
|
363
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "tool_execution_end", toolName: "read", toolCallId, isError: false, result: "file contents here" } });
|
|
364
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
365
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Done reading." } } });
|
|
366
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
367
|
+
if (!withPendingToolRefresh) {
|
|
368
|
+
appendMockMessage({ role: "assistant", content: [
|
|
369
|
+
{ type: "text", text: "Let me check that for you. " },
|
|
370
|
+
{ type: "toolCall", id: toolCallId, toolName: "read", arguments: { path: "/some/file" } },
|
|
371
|
+
{ type: "text", text: "Done reading." },
|
|
372
|
+
], timestamp: new Date().toISOString() });
|
|
373
|
+
}
|
|
374
|
+
appendMockMessage({ role: "toolResult", toolCallId, toolName: "read", content: "file contents here", timestamp: new Date().toISOString() });
|
|
375
|
+
} else if (/markdown artifact/i.test(message)) {
|
|
376
|
+
appendMockMessage({ role: "assistant", content: "Here is a markdown artifact:\n\n[report.md](/api/artifacts/report.md)", timestamp: new Date().toISOString() });
|
|
377
|
+
} else if (/html artifact/i.test(message)) {
|
|
378
|
+
appendMockMessage({ role: "assistant", content: "Here is an HTML artifact:\n\n[preview.html](/api/artifacts/preview.html)", timestamp: new Date().toISOString() });
|
|
379
|
+
} else if (/video artifact/i.test(message)) {
|
|
380
|
+
appendMockMessage({ role: "assistant", content: "Here is a video artifact:\n\n[e2e-video-artifact.webm](/api/artifacts/e2e-video-artifact.webm)", timestamp: new Date().toISOString() });
|
|
381
|
+
} else if (/artifact/i.test(message)) {
|
|
382
|
+
appendMockMessage({ role: "assistant", content: "Here is a screenshot:\n\n", timestamp: new Date().toISOString() });
|
|
383
|
+
} else if (/markdown/i.test(message)) {
|
|
384
|
+
appendMockMessage({ role: "assistant", content: "Here is **bold** markdown.\n\n- one\n- two\n\n```ts\nconst answer = 42;\n```", timestamp: new Date().toISOString() });
|
|
385
|
+
} else {
|
|
386
|
+
appendMockMessage({ role: "assistant", content: `Mock response${promptOptions?.images?.length ? " with image" : ""}.`, timestamp: new Date().toISOString() });
|
|
387
|
+
}
|
|
388
|
+
mockSession.isStreaming = false;
|
|
389
|
+
broadcastRuntimeChanged();
|
|
390
|
+
broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: { type: "agent_end" } });
|
|
391
|
+
if (isCurrentSession(mockSession)) broadcast({ type: "state_changed", ...currentState() as object });
|
|
392
|
+
},
|
|
393
|
+
abort: async () => { mockSession.isStreaming = false; broadcastRuntimeChanged(); },
|
|
394
|
+
abortCompaction: () => { compactionAbortRequested = true; },
|
|
395
|
+
clearQueue: () => undefined,
|
|
396
|
+
subscribe: () => undefined,
|
|
397
|
+
};
|
|
398
|
+
return mockSession;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { mockSessions, createMockSession, resetMockSessions };
|
|
402
|
+
}
|