@larose/pi-web 0.3.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 +235 -0
- package/README.md +50 -0
- package/THIRD_PARTY_LICENSES.md +40 -0
- package/dist/client/home.js +1619 -0
- package/dist/client/session.js +3703 -0
- package/dist/server/api.js +485 -0
- package/dist/server/cli.js +51 -0
- package/dist/server/directory-browser.js +104 -0
- package/dist/server/errors.js +10 -0
- package/dist/server/event-buffer.js +40 -0
- package/dist/server/extension-ui.js +245 -0
- package/dist/server/git-workspaces.js +559 -0
- package/dist/server/runtime-registry.js +703 -0
- package/dist/server/server.js +190 -0
- package/dist/server/session-repository.js +374 -0
- package/package.json +46 -0
- package/public/home.html +139 -0
- package/public/session.html +144 -0
- package/public/styles.css +2463 -0
- package/screenshots/home.png +0 -0
- package/screenshots/session.png +0 -0
- package/src/client/display-title.ts +36 -0
- package/src/client/event-stream.ts +194 -0
- package/src/client/home.ts +1575 -0
- package/src/client/markdown.ts +98 -0
- package/src/client/message-queue.ts +67 -0
- package/src/client/path-combobox.ts +271 -0
- package/src/client/session.ts +2174 -0
- package/src/client/shared.ts +99 -0
- package/src/client/slash-completion.ts +184 -0
- package/src/client/transcript-activity.ts +188 -0
- package/src/client/usage-format.ts +156 -0
- package/src/client/workspace-browser.ts +36 -0
- package/src/server/api.ts +652 -0
- package/src/server/cli.ts +63 -0
- package/src/server/directory-browser.ts +137 -0
- package/src/server/errors.ts +11 -0
- package/src/server/event-buffer.ts +59 -0
- package/src/server/extension-ui.ts +359 -0
- package/src/server/git-workspaces.ts +750 -0
- package/src/server/runtime-registry.ts +943 -0
- package/src/server/server.ts +248 -0
- package/src/server/session-repository.ts +488 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export type GitHead = { type: "branch"; name: string } | { type: "detached"; commit: string; shortCommit: string };
|
|
2
|
+
|
|
3
|
+
export interface GitContext {
|
|
4
|
+
repositoryRoot: string;
|
|
5
|
+
worktreeRoot: string;
|
|
6
|
+
relativeCwd: string;
|
|
7
|
+
head: GitHead;
|
|
8
|
+
isLinkedWorktree: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GitWorktreeSummary {
|
|
12
|
+
worktreeRoot: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
head: GitHead;
|
|
15
|
+
isLinkedWorktree: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GitWorkspaceInspection {
|
|
19
|
+
cwd: string;
|
|
20
|
+
context: GitContext | null;
|
|
21
|
+
worktrees: GitWorktreeSummary[];
|
|
22
|
+
creation:
|
|
23
|
+
| { available: true; reasonCode: null; reason: null }
|
|
24
|
+
| { available: false; reasonCode: string; reason: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SessionWorkspaceContext {
|
|
28
|
+
repositoryRoot: string;
|
|
29
|
+
worktreeRoot: string;
|
|
30
|
+
relativeCwd: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionSummary {
|
|
34
|
+
id: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
gitContext: GitContext | null;
|
|
37
|
+
workspaceContext: SessionWorkspaceContext | null;
|
|
38
|
+
name?: string;
|
|
39
|
+
created: string;
|
|
40
|
+
modified: string;
|
|
41
|
+
messageCount: number;
|
|
42
|
+
firstMessage: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RepositorySessionGroup {
|
|
46
|
+
type: "repository";
|
|
47
|
+
cwd: string;
|
|
48
|
+
repositoryRoot: string;
|
|
49
|
+
sessions: SessionSummary[];
|
|
50
|
+
worktrees: GitWorktreeSummary[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StandaloneSessionGroup {
|
|
54
|
+
type: "standalone";
|
|
55
|
+
cwd: string;
|
|
56
|
+
sessions: SessionSummary[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type SessionGroup = RepositorySessionGroup | StandaloneSessionGroup;
|
|
60
|
+
|
|
61
|
+
export interface SessionListing {
|
|
62
|
+
groups: SessionGroup[];
|
|
63
|
+
knownCwds: string[];
|
|
64
|
+
activeSessionIds: string[];
|
|
65
|
+
runningSessionIds: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function requiredElement<T extends Element>(selector: string, root: ParentNode = document): T {
|
|
69
|
+
const element = root.querySelector<T>(selector);
|
|
70
|
+
if (!element) {
|
|
71
|
+
throw new Error(`Missing required element: ${selector}`);
|
|
72
|
+
}
|
|
73
|
+
return element;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function textElement(tag: string, className: string, text: string): HTMLElement {
|
|
77
|
+
const element = document.createElement(tag);
|
|
78
|
+
element.className = className;
|
|
79
|
+
element.textContent = text;
|
|
80
|
+
return element;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readableError(error: unknown): string {
|
|
84
|
+
return error instanceof Error ? error.message : String(error);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
|
88
|
+
const response = await fetch(path, options);
|
|
89
|
+
const body = (await response.json().catch(() => undefined)) as { error?: { message?: string } } | undefined;
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(body?.error?.message ?? `Request failed (${response.status})`);
|
|
93
|
+
}
|
|
94
|
+
return body as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function sessionPath(id: string): string {
|
|
98
|
+
return `/sessions/${encodeURIComponent(id)}`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
export interface SlashCompletionCommand {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FuzzyMatch {
|
|
7
|
+
matches: boolean;
|
|
8
|
+
score: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SlashCompletionState<T extends SlashCompletionCommand = SlashCompletionCommand> {
|
|
12
|
+
query: string;
|
|
13
|
+
matches: T[];
|
|
14
|
+
selectedIndex: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SlashCompletionWindow<T extends SlashCompletionCommand = SlashCompletionCommand> {
|
|
18
|
+
startIndex: number;
|
|
19
|
+
items: T[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Mirrors pi-tui's command-name fuzzy matching and scoring. */
|
|
23
|
+
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
24
|
+
const queryLower = query.toLowerCase();
|
|
25
|
+
const textLower = text.toLowerCase();
|
|
26
|
+
|
|
27
|
+
const matchQuery = (normalizedQuery: string): FuzzyMatch => {
|
|
28
|
+
if (normalizedQuery.length === 0) {
|
|
29
|
+
return { matches: true, score: 0 };
|
|
30
|
+
}
|
|
31
|
+
if (normalizedQuery.length > textLower.length) {
|
|
32
|
+
return { matches: false, score: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let queryIndex = 0;
|
|
36
|
+
let score = 0;
|
|
37
|
+
let lastMatchIndex = -1;
|
|
38
|
+
let consecutiveMatches = 0;
|
|
39
|
+
|
|
40
|
+
for (let index = 0; index < textLower.length && queryIndex < normalizedQuery.length; index += 1) {
|
|
41
|
+
if (textLower[index] !== normalizedQuery[queryIndex]) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isWordBoundary = index === 0 || /[\s\-_./:]/.test(textLower[index - 1] ?? "");
|
|
46
|
+
if (lastMatchIndex === index - 1) {
|
|
47
|
+
consecutiveMatches += 1;
|
|
48
|
+
score -= consecutiveMatches * 5;
|
|
49
|
+
} else {
|
|
50
|
+
consecutiveMatches = 0;
|
|
51
|
+
if (lastMatchIndex >= 0) {
|
|
52
|
+
score += (index - lastMatchIndex - 1) * 2;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (isWordBoundary) {
|
|
56
|
+
score -= 10;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
score += index * 0.1;
|
|
60
|
+
lastMatchIndex = index;
|
|
61
|
+
queryIndex += 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (queryIndex < normalizedQuery.length) {
|
|
65
|
+
return { matches: false, score: 0 };
|
|
66
|
+
}
|
|
67
|
+
if (normalizedQuery === textLower) {
|
|
68
|
+
score -= 100;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { matches: true, score };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const primaryMatch = matchQuery(queryLower);
|
|
75
|
+
if (primaryMatch.matches) {
|
|
76
|
+
return primaryMatch;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const alphaNumericMatch = queryLower.match(/^(?<letters>[a-z]+)(?<digits>[0-9]+)$/);
|
|
80
|
+
const numericAlphaMatch = queryLower.match(/^(?<digits>[0-9]+)(?<letters>[a-z]+)$/);
|
|
81
|
+
const swappedQuery = alphaNumericMatch
|
|
82
|
+
? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}`
|
|
83
|
+
: numericAlphaMatch
|
|
84
|
+
? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}`
|
|
85
|
+
: "";
|
|
86
|
+
if (!swappedQuery) {
|
|
87
|
+
return primaryMatch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const swappedMatch = matchQuery(swappedQuery);
|
|
91
|
+
return swappedMatch.matches ? { matches: true, score: swappedMatch.score + 5 } : primaryMatch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mirrors pi-tui's stable fuzzy filtering, including whitespace and slash tokenization. */
|
|
95
|
+
export function fuzzyFilter<T>(items: readonly T[], query: string, getText: (item: T) => string): T[] {
|
|
96
|
+
if (!query.trim()) {
|
|
97
|
+
return [...items];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const tokens = query
|
|
101
|
+
.trim()
|
|
102
|
+
.split(/[\s/]+/)
|
|
103
|
+
.filter((token) => token.length > 0);
|
|
104
|
+
if (tokens.length === 0) {
|
|
105
|
+
return [...items];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const results: Array<{ item: T; totalScore: number }> = [];
|
|
109
|
+
for (const item of items) {
|
|
110
|
+
let totalScore = 0;
|
|
111
|
+
let allMatch = true;
|
|
112
|
+
|
|
113
|
+
for (const token of tokens) {
|
|
114
|
+
const match = fuzzyMatch(token, getText(item));
|
|
115
|
+
if (!match.matches) {
|
|
116
|
+
allMatch = false;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
totalScore += match.score;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (allMatch) {
|
|
123
|
+
results.push({ item, totalScore });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
results.sort((left, right) => left.totalScore - right.totalScore);
|
|
128
|
+
return results.map(({ item }) => item);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function slashCommandQuery(value: string): string | null {
|
|
132
|
+
return /^\/(\S*)$/.exec(value)?.[1] ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createSlashCompletionState<T extends SlashCompletionCommand>(
|
|
136
|
+
commands: readonly T[],
|
|
137
|
+
value: string,
|
|
138
|
+
selectedName?: string,
|
|
139
|
+
): SlashCompletionState<T> | null {
|
|
140
|
+
const query = slashCommandQuery(value);
|
|
141
|
+
if (query === null) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const matches = fuzzyFilter(commands, query, (command) => command.name);
|
|
146
|
+
if (matches.length === 0) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const previousIndex = selectedName === undefined ? -1 : matches.findIndex((command) => command.name === selectedName);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
query,
|
|
154
|
+
matches,
|
|
155
|
+
selectedIndex: previousIndex >= 0 ? previousIndex : 0,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function moveSlashCompletionSelection<T extends SlashCompletionCommand>(
|
|
160
|
+
state: SlashCompletionState<T>,
|
|
161
|
+
offset: number,
|
|
162
|
+
): SlashCompletionState<T> {
|
|
163
|
+
const count = state.matches.length;
|
|
164
|
+
const selectedIndex = (((state.selectedIndex + offset) % count) + count) % count;
|
|
165
|
+
|
|
166
|
+
return { ...state, selectedIndex };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getSlashCompletionWindow<T extends SlashCompletionCommand>(
|
|
170
|
+
state: SlashCompletionState<T>,
|
|
171
|
+
maxVisible = 5,
|
|
172
|
+
): SlashCompletionWindow<T> {
|
|
173
|
+
const size = Math.max(1, Math.floor(maxVisible));
|
|
174
|
+
const startIndex = Math.max(0, Math.min(state.matches.length - size, state.selectedIndex - Math.floor(size / 2)));
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
startIndex,
|
|
178
|
+
items: state.matches.slice(startIndex, startIndex + size),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function completeSlashCommand(command: SlashCompletionCommand): string {
|
|
183
|
+
return `/${command.name} `;
|
|
184
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export const MAX_ACTIVITY_PREVIEW_CHARS = 120;
|
|
2
|
+
|
|
3
|
+
export interface TranscriptMessage {
|
|
4
|
+
role: string;
|
|
5
|
+
content?: unknown;
|
|
6
|
+
toolCallId?: string;
|
|
7
|
+
toolName?: string;
|
|
8
|
+
isError?: boolean;
|
|
9
|
+
display?: boolean;
|
|
10
|
+
stopReason?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TranscriptMessageEntry {
|
|
14
|
+
kind: "message";
|
|
15
|
+
entryId: string;
|
|
16
|
+
message: TranscriptMessage;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TranscriptCustomEntry {
|
|
20
|
+
kind: "custom";
|
|
21
|
+
entryId: string;
|
|
22
|
+
customType: string;
|
|
23
|
+
data: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TranscriptEntry = TranscriptMessageEntry | TranscriptCustomEntry;
|
|
27
|
+
|
|
28
|
+
export type ActivityItem =
|
|
29
|
+
| { kind: "thinking"; text: string }
|
|
30
|
+
| { kind: "toolCall"; name: string; arguments: unknown }
|
|
31
|
+
| { kind: "toolResult"; message: TranscriptMessage };
|
|
32
|
+
|
|
33
|
+
export interface ActivityGroup {
|
|
34
|
+
kind: "activity";
|
|
35
|
+
items: ActivityItem[];
|
|
36
|
+
preview?: string;
|
|
37
|
+
lastAction?: string;
|
|
38
|
+
outcome?: "aborted";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type GroupedTranscriptEntry = TranscriptEntry | ActivityGroup;
|
|
42
|
+
|
|
43
|
+
export interface PartitionedAssistantContent {
|
|
44
|
+
activity: ActivityItem[];
|
|
45
|
+
responseContent: unknown;
|
|
46
|
+
hasResponse: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function boundedText(value: string, maximum: number): string {
|
|
50
|
+
const characters = Array.from(value);
|
|
51
|
+
if (characters.length <= maximum) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const prefix = characters
|
|
56
|
+
.slice(0, maximum - 1)
|
|
57
|
+
.join("")
|
|
58
|
+
.trimEnd();
|
|
59
|
+
return `${prefix}…`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function activityPreview(value: string): string {
|
|
63
|
+
const plainText = value
|
|
64
|
+
.replace(/\[([^\]]+)\]\([^\s)]+\)/gu, "$1")
|
|
65
|
+
.replace(/`([^`]+)`/gu, "$1")
|
|
66
|
+
.replace(/[*~]{1,3}/gu, "")
|
|
67
|
+
.replace(/^#{1,6}\s+/gmu, "");
|
|
68
|
+
const normalized = plainText.replace(/\s+/gu, " ").trim();
|
|
69
|
+
const sentenceEnd = /[.!?。!?](?=\s|$)/u.exec(normalized);
|
|
70
|
+
const sentence = sentenceEnd ? normalized.slice(0, sentenceEnd.index + sentenceEnd[0].length) : normalized;
|
|
71
|
+
|
|
72
|
+
return boundedText(sentence, MAX_ACTIVITY_PREVIEW_CHARS);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function toolActionLabel(toolName: unknown, completed = false): string {
|
|
76
|
+
const name = typeof toolName === "string" && toolName.trim() ? toolName.trim() : "tool";
|
|
77
|
+
return `Tool · ${name}${completed ? " · done" : ""}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function partitionAssistantContent(content: unknown): PartitionedAssistantContent {
|
|
81
|
+
if (!Array.isArray(content)) {
|
|
82
|
+
return {
|
|
83
|
+
activity: [],
|
|
84
|
+
responseContent: content,
|
|
85
|
+
hasResponse: content !== undefined && content !== "",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const activity: ActivityItem[] = [];
|
|
90
|
+
const response: unknown[] = [];
|
|
91
|
+
|
|
92
|
+
for (const part of content) {
|
|
93
|
+
if (!part || typeof part !== "object") {
|
|
94
|
+
response.push(part);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const block = part as Record<string, unknown>;
|
|
99
|
+
if (block.type === "thinking") {
|
|
100
|
+
activity.push({ kind: "thinking", text: typeof block.thinking === "string" ? block.thinking : "" });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (block.type === "toolCall") {
|
|
105
|
+
activity.push({
|
|
106
|
+
kind: "toolCall",
|
|
107
|
+
name: typeof block.name === "string" && block.name.trim() ? block.name : "tool",
|
|
108
|
+
arguments: block.arguments,
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
response.push(part);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { activity, responseContent: response, hasResponse: response.length > 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function appendActivityItem(group: ActivityGroup, item: ActivityItem): void {
|
|
120
|
+
group.items.push(item);
|
|
121
|
+
|
|
122
|
+
if (item.kind === "thinking") {
|
|
123
|
+
const preview = activityPreview(item.text);
|
|
124
|
+
if (preview) {
|
|
125
|
+
group.preview = preview;
|
|
126
|
+
}
|
|
127
|
+
} else if (item.kind === "toolCall") {
|
|
128
|
+
group.lastAction = toolActionLabel(item.name);
|
|
129
|
+
} else {
|
|
130
|
+
group.lastAction = toolActionLabel(item.message.toolName, true);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function groupTranscriptActivity(entries: TranscriptEntry[]): GroupedTranscriptEntry[] {
|
|
135
|
+
const grouped: GroupedTranscriptEntry[] = [];
|
|
136
|
+
let activity: ActivityGroup | undefined;
|
|
137
|
+
|
|
138
|
+
const ensureActivity = (): ActivityGroup => {
|
|
139
|
+
if (activity) {
|
|
140
|
+
return activity;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
activity = { kind: "activity", items: [] };
|
|
144
|
+
grouped.push(activity);
|
|
145
|
+
return activity;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (entry.kind !== "message") {
|
|
150
|
+
grouped.push(entry);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { message } = entry;
|
|
155
|
+
if (message.role === "user") {
|
|
156
|
+
activity = undefined;
|
|
157
|
+
grouped.push(entry);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (message.role === "assistant") {
|
|
162
|
+
const partitioned = partitionAssistantContent(message.content);
|
|
163
|
+
for (const item of partitioned.activity) {
|
|
164
|
+
appendActivityItem(ensureActivity(), item);
|
|
165
|
+
}
|
|
166
|
+
if (message.stopReason === "aborted") {
|
|
167
|
+
ensureActivity().outcome = "aborted";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (partitioned.hasResponse) {
|
|
171
|
+
grouped.push({
|
|
172
|
+
...entry,
|
|
173
|
+
message: { ...message, content: partitioned.responseContent },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (message.role === "toolResult" || message.role === "live-tool") {
|
|
180
|
+
appendActivityItem(ensureActivity(), { kind: "toolResult", message });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
grouped.push(entry);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return grouped;
|
|
188
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export interface SessionUsage {
|
|
2
|
+
tokens: {
|
|
3
|
+
input: number;
|
|
4
|
+
output: number;
|
|
5
|
+
cacheRead: number;
|
|
6
|
+
cacheWrite: number;
|
|
7
|
+
total: number;
|
|
8
|
+
};
|
|
9
|
+
context: {
|
|
10
|
+
tokens: number | null;
|
|
11
|
+
contextWindow: number;
|
|
12
|
+
percent: number | null;
|
|
13
|
+
} | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SessionContextUsageDisplay {
|
|
17
|
+
state: "loading" | "known" | "unknown" | "unavailable";
|
|
18
|
+
percentageText: string;
|
|
19
|
+
capacityText: string | null;
|
|
20
|
+
meterPercent: number | null;
|
|
21
|
+
autoCompactionEnabled: boolean;
|
|
22
|
+
accessibleText: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionUsageDisplay {
|
|
26
|
+
tokenLines: string[];
|
|
27
|
+
tokenAccessibleText: string;
|
|
28
|
+
context: SessionContextUsageDisplay;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fullNumberFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
|
32
|
+
|
|
33
|
+
/** Format token counts using the same compact thresholds as Pi's footer. */
|
|
34
|
+
export function formatCompactTokens(count: number): string {
|
|
35
|
+
if (count < 1_000) {
|
|
36
|
+
return count.toString();
|
|
37
|
+
}
|
|
38
|
+
if (count < 10_000) {
|
|
39
|
+
return `${(count / 1_000).toFixed(1)}k`;
|
|
40
|
+
}
|
|
41
|
+
if (count < 1_000_000) {
|
|
42
|
+
return `${Math.round(count / 1_000)}k`;
|
|
43
|
+
}
|
|
44
|
+
if (count < 10_000_000) {
|
|
45
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
46
|
+
}
|
|
47
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fullTokens(count: number): string {
|
|
51
|
+
return `${fullNumberFormatter.format(count)} tokens`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function punctuate(parts: string[], fallback: string): string {
|
|
55
|
+
return `${parts.length > 0 ? parts.join("; ") : fallback}.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatSessionUsage(usage: SessionUsage | null, autoCompactionEnabled: boolean): SessionUsageDisplay {
|
|
59
|
+
if (!usage) {
|
|
60
|
+
return {
|
|
61
|
+
tokenLines: ["Loading…"],
|
|
62
|
+
tokenAccessibleText: "Token usage is loading.",
|
|
63
|
+
context: {
|
|
64
|
+
state: "loading",
|
|
65
|
+
percentageText: "Loading…",
|
|
66
|
+
capacityText: null,
|
|
67
|
+
meterPercent: null,
|
|
68
|
+
autoCompactionEnabled: false,
|
|
69
|
+
accessibleText: "Context usage is loading.",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tokenLines: string[] = [];
|
|
75
|
+
const tokenAccessibleParts: string[] = [];
|
|
76
|
+
const inputOutputParts: string[] = [];
|
|
77
|
+
const cacheParts: string[] = [];
|
|
78
|
+
|
|
79
|
+
if (usage.tokens.input) {
|
|
80
|
+
inputOutputParts.push(`In ${formatCompactTokens(usage.tokens.input)}`);
|
|
81
|
+
tokenAccessibleParts.push(`Input: ${fullTokens(usage.tokens.input)}`);
|
|
82
|
+
}
|
|
83
|
+
if (usage.tokens.output) {
|
|
84
|
+
inputOutputParts.push(`Out ${formatCompactTokens(usage.tokens.output)}`);
|
|
85
|
+
tokenAccessibleParts.push(`Output: ${fullTokens(usage.tokens.output)}`);
|
|
86
|
+
}
|
|
87
|
+
if (usage.tokens.cacheRead) {
|
|
88
|
+
cacheParts.push(`Cache read ${formatCompactTokens(usage.tokens.cacheRead)}`);
|
|
89
|
+
tokenAccessibleParts.push(`Cache read: ${fullTokens(usage.tokens.cacheRead)}`);
|
|
90
|
+
}
|
|
91
|
+
if (usage.tokens.cacheWrite) {
|
|
92
|
+
cacheParts.push(`write ${formatCompactTokens(usage.tokens.cacheWrite)}`);
|
|
93
|
+
tokenAccessibleParts.push(`Cache write: ${fullTokens(usage.tokens.cacheWrite)}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (inputOutputParts.length > 0) {
|
|
97
|
+
tokenLines.push(inputOutputParts.join(" · "));
|
|
98
|
+
}
|
|
99
|
+
if (cacheParts.length > 0) {
|
|
100
|
+
tokenLines.push(cacheParts.join(" · "));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tokenAccessibleText = punctuate(tokenAccessibleParts, "No token usage yet");
|
|
104
|
+
|
|
105
|
+
if (!usage.context) {
|
|
106
|
+
return {
|
|
107
|
+
tokenLines,
|
|
108
|
+
tokenAccessibleText,
|
|
109
|
+
context: {
|
|
110
|
+
state: "unavailable",
|
|
111
|
+
percentageText: "Unavailable",
|
|
112
|
+
capacityText: null,
|
|
113
|
+
meterPercent: null,
|
|
114
|
+
autoCompactionEnabled,
|
|
115
|
+
accessibleText: autoCompactionEnabled
|
|
116
|
+
? "Context usage unavailable; auto-compaction enabled."
|
|
117
|
+
: "Context usage unavailable.",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const contextWindow = formatCompactTokens(usage.context.contextWindow);
|
|
123
|
+
if (usage.context.tokens === null || usage.context.percent === null) {
|
|
124
|
+
return {
|
|
125
|
+
tokenLines,
|
|
126
|
+
tokenAccessibleText,
|
|
127
|
+
context: {
|
|
128
|
+
state: "unknown",
|
|
129
|
+
percentageText: "—",
|
|
130
|
+
capacityText: `? / ${contextWindow} tokens`,
|
|
131
|
+
meterPercent: null,
|
|
132
|
+
autoCompactionEnabled,
|
|
133
|
+
accessibleText:
|
|
134
|
+
`Context: unknown of ${fullTokens(usage.context.contextWindow)} until the next model response; ` +
|
|
135
|
+
`auto-compaction ${autoCompactionEnabled ? "enabled" : "disabled"}.`,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const roundedPercent = Math.round(usage.context.percent);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
tokenLines,
|
|
144
|
+
tokenAccessibleText,
|
|
145
|
+
context: {
|
|
146
|
+
state: "known",
|
|
147
|
+
percentageText: `${roundedPercent}% used`,
|
|
148
|
+
capacityText: `${formatCompactTokens(usage.context.tokens)} / ${contextWindow} tokens`,
|
|
149
|
+
meterPercent: Math.min(100, Math.max(0, usage.context.percent)),
|
|
150
|
+
autoCompactionEnabled,
|
|
151
|
+
accessibleText:
|
|
152
|
+
`Context: ${fullTokens(usage.context.tokens)} of ${fullTokens(usage.context.contextWindow)} ` +
|
|
153
|
+
`(${roundedPercent}%); auto-compaction ${autoCompactionEnabled ? "enabled" : "disabled"}.`,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GitContext } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
export function repositoryName(root: string): string {
|
|
4
|
+
const normalized = root.replace(/[\\/]+$/, "");
|
|
5
|
+
return normalized.split(/[\\/]/).pop() || root;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function displayRelativePath(relativePath: string): string {
|
|
9
|
+
return relativePath === "." ? "(root)" : relativePath;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function relativePathWithin(root: string, candidate: string): string | null {
|
|
13
|
+
const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/";
|
|
14
|
+
const normalizedRoot = root.replaceAll("\\", "/").replace(/\/+$/, "") || "/";
|
|
15
|
+
const normalizedCandidate = candidate.replaceAll("\\", "/").replace(/\/+$/, "") || "/";
|
|
16
|
+
const caseInsensitive = /^[A-Za-z]:/.test(normalizedRoot);
|
|
17
|
+
const comparableRoot = caseInsensitive ? normalizedRoot.toLocaleLowerCase() : normalizedRoot;
|
|
18
|
+
const comparableCandidate = caseInsensitive ? normalizedCandidate.toLocaleLowerCase() : normalizedCandidate;
|
|
19
|
+
if (comparableCandidate === comparableRoot) {
|
|
20
|
+
return ".";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rootPrefix = comparableRoot === "/" ? "/" : `${comparableRoot}/`;
|
|
24
|
+
if (!comparableCandidate.startsWith(rootPrefix)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return normalizedCandidate.slice(rootPrefix.length).replaceAll("/", separator);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function worktreeTargetPath(context: GitContext, name: string): string {
|
|
32
|
+
const root = context.repositoryRoot.replace(/[\\/]$/, "");
|
|
33
|
+
const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/";
|
|
34
|
+
const relativeCwd = context.relativeCwd === "." ? "" : `${separator}${context.relativeCwd}`;
|
|
35
|
+
return `${root}${separator}.pi${separator}worktrees${separator}${name || "<name>"}${relativeCwd}`;
|
|
36
|
+
}
|