@oh-my-pi/pi-coding-agent 13.18.0 → 13.19.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/CHANGELOG.md +50 -0
- package/package.json +7 -11
- package/src/autoresearch/git.ts +25 -30
- package/src/autoresearch/tools/log-experiment.ts +61 -74
- package/src/commit/agentic/agent.ts +0 -3
- package/src/commit/agentic/index.ts +19 -22
- package/src/commit/agentic/tools/git-file-diff.ts +3 -6
- package/src/commit/agentic/tools/git-hunk.ts +3 -3
- package/src/commit/agentic/tools/git-overview.ts +6 -9
- package/src/commit/agentic/tools/index.ts +6 -8
- package/src/commit/agentic/tools/propose-commit.ts +4 -7
- package/src/commit/agentic/tools/recent-commits.ts +3 -3
- package/src/commit/agentic/tools/split-commit.ts +4 -4
- package/src/commit/changelog/index.ts +5 -9
- package/src/commit/pipeline.ts +10 -12
- package/src/config/keybindings.ts +7 -6
- package/src/config/settings-schema.ts +44 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
- package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
- package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
- package/src/index.ts +1 -0
- package/src/main.ts +24 -2
- package/src/modes/components/footer.ts +9 -29
- package/src/modes/components/hook-editor.ts +3 -3
- package/src/modes/components/hook-selector.ts +6 -1
- package/src/modes/components/session-observer-overlay.ts +472 -0
- package/src/modes/components/settings-defs.ts +19 -0
- package/src/modes/components/status-line.ts +15 -61
- package/src/modes/controllers/command-controller.ts +1 -0
- package/src/modes/controllers/event-controller.ts +59 -2
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +26 -0
- package/src/modes/interactive-mode.ts +195 -43
- package/src/modes/session-observer-registry.ts +146 -0
- package/src/modes/shared.ts +0 -42
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/keybinding-matchers.ts +9 -0
- package/src/prompts/system/custom-system-prompt.md +5 -0
- package/src/prompts/system/system-prompt.md +6 -0
- package/src/sdk.ts +28 -13
- package/src/secrets/index.ts +1 -1
- package/src/secrets/obfuscator.ts +24 -16
- package/src/session/agent-session.ts +75 -30
- package/src/session/session-manager.ts +15 -5
- package/src/system-prompt.ts +4 -0
- package/src/task/executor.ts +28 -0
- package/src/task/index.ts +88 -78
- package/src/task/types.ts +25 -0
- package/src/task/worktree.ts +127 -145
- package/src/tools/exit-plan-mode.ts +1 -0
- package/src/tools/gh.ts +120 -297
- package/src/tools/read.ts +13 -79
- package/src/utils/external-editor.ts +11 -5
- package/src/utils/git.ts +1400 -0
- package/src/web/search/render.ts +6 -4
- package/src/commit/git/errors.ts +0 -9
- package/src/commit/git/index.ts +0 -210
- package/src/commit/git/operations.ts +0 -54
- package/src/tools/gh-cli.ts +0 -125
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session observer overlay component.
|
|
3
|
+
*
|
|
4
|
+
* Picker mode: lists main + active subagent sessions with live status.
|
|
5
|
+
* Viewer mode: renders a read-only transcript of the selected subagent's session
|
|
6
|
+
* by reading its JSONL session file — shows thinking, text, tool calls, results.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* - shortcut opens picker
|
|
10
|
+
* - Enter on a subagent -> viewer
|
|
11
|
+
* - shortcut while in viewer -> back to picker
|
|
12
|
+
* - Esc from viewer -> back to picker
|
|
13
|
+
* - Esc from picker -> close overlay
|
|
14
|
+
* - Enter on main session -> close overlay (jump back)
|
|
15
|
+
*/
|
|
16
|
+
import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
17
|
+
import { Container, Markdown, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
18
|
+
import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
|
|
19
|
+
import type { KeyId } from "../../config/keybindings";
|
|
20
|
+
import type { SessionMessageEntry } from "../../session/session-manager";
|
|
21
|
+
import { parseSessionEntries } from "../../session/session-manager";
|
|
22
|
+
import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils";
|
|
23
|
+
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
|
24
|
+
import { getMarkdownTheme, getSelectListTheme, theme } from "../theme/theme";
|
|
25
|
+
import { DynamicBorder } from "./dynamic-border";
|
|
26
|
+
|
|
27
|
+
type Mode = "picker" | "viewer";
|
|
28
|
+
|
|
29
|
+
/** Max thinking characters to show (long thinking can be huge) */
|
|
30
|
+
const MAX_THINKING_CHARS = 600;
|
|
31
|
+
/** Max tool args characters to display */
|
|
32
|
+
const MAX_TOOL_ARGS_CHARS = 200;
|
|
33
|
+
/** Max tool result text to display */
|
|
34
|
+
const MAX_TOOL_RESULT_CHARS = 300;
|
|
35
|
+
|
|
36
|
+
export class SessionObserverOverlayComponent extends Container {
|
|
37
|
+
#registry: SessionObserverRegistry;
|
|
38
|
+
#onDone: () => void;
|
|
39
|
+
#mode: Mode = "picker";
|
|
40
|
+
#selectList: SelectList;
|
|
41
|
+
#viewerContainer: Container;
|
|
42
|
+
#selectedSessionId?: string;
|
|
43
|
+
#observeKeys: KeyId[];
|
|
44
|
+
/** Cached parsed transcript per session file to avoid reparsing on every refresh */
|
|
45
|
+
#transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[] };
|
|
46
|
+
|
|
47
|
+
constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
|
|
48
|
+
super();
|
|
49
|
+
this.#registry = registry;
|
|
50
|
+
this.#onDone = onDone;
|
|
51
|
+
this.#observeKeys = observeKeys;
|
|
52
|
+
this.#selectList = new SelectList([], 0, getSelectListTheme());
|
|
53
|
+
this.#viewerContainer = new Container();
|
|
54
|
+
|
|
55
|
+
this.#setupPicker();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#setupPicker(): void {
|
|
59
|
+
this.#mode = "picker";
|
|
60
|
+
this.children = [];
|
|
61
|
+
|
|
62
|
+
this.addChild(new DynamicBorder());
|
|
63
|
+
this.addChild(new Text(theme.bold(theme.fg("accent", "Session Observer")), 1, 0));
|
|
64
|
+
this.addChild(new Spacer(1));
|
|
65
|
+
|
|
66
|
+
const items = this.#buildPickerItems();
|
|
67
|
+
this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
|
|
68
|
+
|
|
69
|
+
this.#selectList.onSelect = item => {
|
|
70
|
+
if (item.value === "main") {
|
|
71
|
+
this.#onDone();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.#selectedSessionId = item.value;
|
|
75
|
+
this.#setupViewer();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.#selectList.onCancel = () => {
|
|
79
|
+
this.#onDone();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
this.addChild(this.#selectList);
|
|
83
|
+
this.addChild(new DynamicBorder());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#setupViewer(): void {
|
|
87
|
+
this.#mode = "viewer";
|
|
88
|
+
this.children = [];
|
|
89
|
+
this.#viewerContainer = new Container();
|
|
90
|
+
this.#refreshViewer();
|
|
91
|
+
|
|
92
|
+
this.addChild(new DynamicBorder());
|
|
93
|
+
this.addChild(this.#viewerContainer);
|
|
94
|
+
this.addChild(new Spacer(1));
|
|
95
|
+
this.addChild(new Text(theme.fg("dim", "Esc: back to picker | Ctrl+S: back to picker"), 1, 0));
|
|
96
|
+
this.addChild(new DynamicBorder());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Rebuild content from live registry data */
|
|
100
|
+
refreshFromRegistry(): void {
|
|
101
|
+
if (this.#mode === "picker") {
|
|
102
|
+
this.#refreshPickerItems();
|
|
103
|
+
} else if (this.#mode === "viewer" && this.#selectedSessionId) {
|
|
104
|
+
this.#refreshViewer();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#refreshPickerItems(): void {
|
|
109
|
+
// Preserve selection across refresh by matching on value
|
|
110
|
+
const previousValue = this.#selectList.getSelectedItem()?.value;
|
|
111
|
+
|
|
112
|
+
const items = this.#buildPickerItems();
|
|
113
|
+
const newList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
|
|
114
|
+
newList.onSelect = this.#selectList.onSelect;
|
|
115
|
+
newList.onCancel = this.#selectList.onCancel;
|
|
116
|
+
|
|
117
|
+
if (previousValue) {
|
|
118
|
+
const newIndex = items.findIndex(i => i.value === previousValue);
|
|
119
|
+
if (newIndex >= 0) newList.setSelectedIndex(newIndex);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const idx = this.children.indexOf(this.#selectList);
|
|
123
|
+
if (idx >= 0) {
|
|
124
|
+
this.children[idx] = newList;
|
|
125
|
+
}
|
|
126
|
+
this.#selectList = newList;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#refreshViewer(): void {
|
|
130
|
+
this.#viewerContainer.clear();
|
|
131
|
+
|
|
132
|
+
const sessions = this.#registry.getSessions();
|
|
133
|
+
const session = sessions.find(s => s.id === this.#selectedSessionId);
|
|
134
|
+
if (!session) {
|
|
135
|
+
this.#viewerContainer.addChild(new Text(theme.fg("dim", "Session no longer available."), 1, 0));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.#renderSessionHeader(session);
|
|
140
|
+
this.#renderSessionTranscript(session);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#renderSessionHeader(session: ObservableSession): void {
|
|
144
|
+
const c = this.#viewerContainer;
|
|
145
|
+
const progress = session.progress;
|
|
146
|
+
|
|
147
|
+
// Header: label + status + [agent]
|
|
148
|
+
const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
|
|
149
|
+
const statusText = theme.fg(statusColor, session.status);
|
|
150
|
+
const agentTag = session.agent ? theme.fg("dim", ` [${session.agent}]`) : "";
|
|
151
|
+
c.addChild(new Text(`${theme.bold(theme.fg("accent", session.label))} ${statusText}${agentTag}`, 1, 0));
|
|
152
|
+
|
|
153
|
+
if (session.description) {
|
|
154
|
+
c.addChild(new Text(theme.fg("muted", session.description), 1, 0));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Stats from progress
|
|
158
|
+
if (progress) {
|
|
159
|
+
const stats: string[] = [];
|
|
160
|
+
if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
|
|
161
|
+
if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`);
|
|
162
|
+
if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
|
|
163
|
+
if (stats.length > 0) {
|
|
164
|
+
c.addChild(new Text(theme.fg("dim", stats.join(theme.sep.dot)), 1, 0));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (session.sessionFile) {
|
|
169
|
+
c.addChild(new Text(theme.fg("dim", `Session: ${shortenPath(session.sessionFile)}`), 1, 0));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
c.addChild(new DynamicBorder());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Incrementally read and parse the session JSONL, caching already-parsed entries. */
|
|
176
|
+
#loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
|
|
177
|
+
// Invalidate cache if session file changed (e.g. switched to different subagent)
|
|
178
|
+
if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
|
|
179
|
+
this.#transcriptCache = undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fromByte = this.#transcriptCache?.bytesRead ?? 0;
|
|
183
|
+
const result = readFileIncremental(sessionFile, fromByte);
|
|
184
|
+
if (!result) {
|
|
185
|
+
logger.debug("Session observer: failed to read session file", { path: sessionFile });
|
|
186
|
+
return this.#transcriptCache?.entries ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// File shrank (compaction or pruning rewrote it) — invalidate and re-read from scratch
|
|
190
|
+
if (result.newSize < fromByte) {
|
|
191
|
+
this.#transcriptCache = undefined;
|
|
192
|
+
return this.#loadTranscript(sessionFile);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!this.#transcriptCache) {
|
|
196
|
+
this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Parse only new bytes, but only up to the last complete line.
|
|
200
|
+
// A partial trailing record (mid-write) must not be consumed —
|
|
201
|
+
// we leave those bytes for the next refresh.
|
|
202
|
+
if (result.text.length > 0) {
|
|
203
|
+
const lastNewline = result.text.lastIndexOf("\n");
|
|
204
|
+
if (lastNewline >= 0) {
|
|
205
|
+
const completeChunk = result.text.slice(0, lastNewline + 1);
|
|
206
|
+
const newEntries = parseSessionEntries(completeChunk);
|
|
207
|
+
for (const entry of newEntries) {
|
|
208
|
+
if (entry.type === "message") {
|
|
209
|
+
this.#transcriptCache.entries.push(entry as SessionMessageEntry);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
213
|
+
}
|
|
214
|
+
// If no newline found, the entire chunk is partial — leave bytesRead unchanged
|
|
215
|
+
}
|
|
216
|
+
return this.#transcriptCache.entries;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#renderSessionTranscript(session: ObservableSession): void {
|
|
220
|
+
const c = this.#viewerContainer;
|
|
221
|
+
|
|
222
|
+
if (!session.sessionFile) {
|
|
223
|
+
c.addChild(new Text(theme.fg("dim", "No session file available yet."), 1, 0));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const messageEntries = this.#loadTranscript(session.sessionFile);
|
|
228
|
+
if (!messageEntries) {
|
|
229
|
+
c.addChild(new Text(theme.fg("dim", "Unable to read session file."), 1, 0));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (messageEntries.length === 0) {
|
|
233
|
+
c.addChild(new Text(theme.fg("dim", "No messages yet."), 1, 0));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Build a tool call ID -> tool result map for matching
|
|
238
|
+
const toolResults = new Map<string, ToolResultMessage>();
|
|
239
|
+
for (const entry of messageEntries) {
|
|
240
|
+
if (entry.message.role === "toolResult") {
|
|
241
|
+
toolResults.set(entry.message.toolCallId, entry.message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const entry of messageEntries) {
|
|
246
|
+
const msg = entry.message;
|
|
247
|
+
|
|
248
|
+
if (msg.role === "assistant") {
|
|
249
|
+
this.#renderAssistantMessage(c, msg, toolResults);
|
|
250
|
+
} else if (msg.role === "user" || msg.role === "developer") {
|
|
251
|
+
// Show user/developer messages briefly
|
|
252
|
+
const text =
|
|
253
|
+
typeof msg.content === "string"
|
|
254
|
+
? msg.content
|
|
255
|
+
: msg.content
|
|
256
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
257
|
+
.map(b => b.text)
|
|
258
|
+
.join("\n");
|
|
259
|
+
if (text.trim()) {
|
|
260
|
+
const label = msg.role === "developer" ? "System" : "User";
|
|
261
|
+
c.addChild(new Spacer(1));
|
|
262
|
+
c.addChild(
|
|
263
|
+
new Text(
|
|
264
|
+
`${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", truncateToWidth(text.trim(), 80))}`,
|
|
265
|
+
1,
|
|
266
|
+
0,
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// toolResult entries are rendered inline with their tool calls above
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#renderAssistantMessage(c: Container, msg: AssistantMessage, toolResults: Map<string, ToolResultMessage>): void {
|
|
276
|
+
for (const content of msg.content) {
|
|
277
|
+
if (content.type === "thinking" && content.thinking.trim()) {
|
|
278
|
+
const thinking = content.thinking.trim();
|
|
279
|
+
c.addChild(new Spacer(1));
|
|
280
|
+
if (thinking.length > MAX_THINKING_CHARS) {
|
|
281
|
+
// Show truncated thinking as markdown for proper formatting
|
|
282
|
+
const truncated = `${thinking.slice(0, MAX_THINKING_CHARS)}...`;
|
|
283
|
+
c.addChild(
|
|
284
|
+
new Markdown(truncated, 1, 0, getMarkdownTheme(), {
|
|
285
|
+
color: (t: string) => theme.fg("thinkingText", t),
|
|
286
|
+
italic: true,
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
c.addChild(
|
|
291
|
+
new Markdown(thinking, 1, 0, getMarkdownTheme(), {
|
|
292
|
+
color: (t: string) => theme.fg("thinkingText", t),
|
|
293
|
+
italic: true,
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
} else if (content.type === "text" && content.text.trim()) {
|
|
298
|
+
c.addChild(new Spacer(1));
|
|
299
|
+
c.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
|
|
300
|
+
} else if (content.type === "toolCall") {
|
|
301
|
+
this.#renderToolCall(c, content, toolResults);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#renderToolCall(
|
|
307
|
+
c: Container,
|
|
308
|
+
call: { id: string; name: string; arguments: Record<string, unknown>; intent?: string },
|
|
309
|
+
toolResults: Map<string, ToolResultMessage>,
|
|
310
|
+
): void {
|
|
311
|
+
c.addChild(new Spacer(1));
|
|
312
|
+
|
|
313
|
+
// Tool call header with intent
|
|
314
|
+
const intentStr = call.intent ? theme.fg("dim", ` ${truncateToWidth(call.intent, 50)}`) : "";
|
|
315
|
+
c.addChild(new Text(`${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`, 1, 0));
|
|
316
|
+
|
|
317
|
+
// Key arguments (skip very long ones, show summary)
|
|
318
|
+
const argEntries = Object.entries(call.arguments);
|
|
319
|
+
if (argEntries.length > 0) {
|
|
320
|
+
const argSummary = this.#formatToolArgs(call.name, call.arguments);
|
|
321
|
+
if (argSummary) {
|
|
322
|
+
c.addChild(new Text(` ${theme.fg("dim", argSummary)}`, 1, 0));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Inline tool result
|
|
327
|
+
const result = toolResults.get(call.id);
|
|
328
|
+
if (result) {
|
|
329
|
+
this.#renderToolResult(c, result);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#formatToolArgs(toolName: string, args: Record<string, unknown>): string {
|
|
334
|
+
// Show the most relevant arg for common tools
|
|
335
|
+
switch (toolName) {
|
|
336
|
+
case "read":
|
|
337
|
+
return args.path ? `path: ${args.path}` : "";
|
|
338
|
+
case "write":
|
|
339
|
+
return args.path ? `path: ${args.path}` : "";
|
|
340
|
+
case "edit":
|
|
341
|
+
return args.path ? `path: ${args.path}` : "";
|
|
342
|
+
case "grep":
|
|
343
|
+
return [args.pattern ? `pattern: ${args.pattern}` : "", args.path ? `path: ${args.path}` : ""]
|
|
344
|
+
.filter(Boolean)
|
|
345
|
+
.join(", ");
|
|
346
|
+
case "find":
|
|
347
|
+
return args.pattern ? `pattern: ${args.pattern}` : "";
|
|
348
|
+
case "bash": {
|
|
349
|
+
const cmd = args.command;
|
|
350
|
+
if (typeof cmd === "string") {
|
|
351
|
+
return truncateToWidth(replaceTabs(cmd), 70);
|
|
352
|
+
}
|
|
353
|
+
return "";
|
|
354
|
+
}
|
|
355
|
+
case "lsp":
|
|
356
|
+
return [args.action, args.file, args.symbol].filter(Boolean).join(" ");
|
|
357
|
+
case "ast_grep":
|
|
358
|
+
case "ast_edit":
|
|
359
|
+
return args.path ? `path: ${args.path}` : "";
|
|
360
|
+
case "task": {
|
|
361
|
+
const tasks = args.tasks;
|
|
362
|
+
if (Array.isArray(tasks)) {
|
|
363
|
+
return `${tasks.length} task(s)`;
|
|
364
|
+
}
|
|
365
|
+
return "";
|
|
366
|
+
}
|
|
367
|
+
default: {
|
|
368
|
+
// Generic: show first few args truncated
|
|
369
|
+
const parts: string[] = [];
|
|
370
|
+
let total = 0;
|
|
371
|
+
for (const [key, value] of Object.entries(args)) {
|
|
372
|
+
if (key.startsWith("_")) continue;
|
|
373
|
+
const v = typeof value === "string" ? value : JSON.stringify(value);
|
|
374
|
+
const entry = `${key}: ${truncateToWidth(replaceTabs(v ?? ""), 40)}`;
|
|
375
|
+
if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
|
|
376
|
+
parts.push(entry);
|
|
377
|
+
total += entry.length;
|
|
378
|
+
}
|
|
379
|
+
return parts.join(", ");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#renderToolResult(c: Container, result: ToolResultMessage): void {
|
|
385
|
+
const textParts = result.content
|
|
386
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
387
|
+
.map(p => p.text);
|
|
388
|
+
const text = textParts.join("\n").trim();
|
|
389
|
+
|
|
390
|
+
if (result.isError) {
|
|
391
|
+
const preview = truncateToWidth(replaceTabs(text || "Error"), 70);
|
|
392
|
+
c.addChild(new Text(` ${theme.fg("error", `✗ ${preview}`)}`, 1, 0));
|
|
393
|
+
} else if (text) {
|
|
394
|
+
// Show brief result preview
|
|
395
|
+
const lines = text.split("\n");
|
|
396
|
+
if (lines.length === 1 && text.length < MAX_TOOL_RESULT_CHARS) {
|
|
397
|
+
c.addChild(new Text(` ${theme.fg("dim", `✓ ${truncateToWidth(replaceTabs(text), 70)}`)}`, 1, 0));
|
|
398
|
+
} else {
|
|
399
|
+
c.addChild(new Text(` ${theme.fg("dim", `✓ ${lines.length} lines`)}`, 1, 0));
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
c.addChild(new Text(` ${theme.fg("dim", "✓ done")}`, 1, 0));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#buildPickerItems(): SelectItem[] {
|
|
407
|
+
const sessions = this.#registry.getSessions();
|
|
408
|
+
return sessions.map(s => {
|
|
409
|
+
const statusIcon =
|
|
410
|
+
s.status === "active" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : "○";
|
|
411
|
+
const statusColor = s.status === "active" ? "success" : s.status === "failed" ? "error" : "dim";
|
|
412
|
+
const prefix = theme.fg(statusColor, statusIcon);
|
|
413
|
+
const agentSuffix = s.agent ? theme.fg("dim", ` [${s.agent}]`) : "";
|
|
414
|
+
const label = s.kind === "main" ? `${prefix} ${s.label} (return)` : `${prefix} ${s.label}${agentSuffix}`;
|
|
415
|
+
|
|
416
|
+
// Show current activity in the picker description for subagents
|
|
417
|
+
let description = s.description;
|
|
418
|
+
if (s.progress?.currentTool) {
|
|
419
|
+
const intent = s.progress.lastIntent;
|
|
420
|
+
description = intent ? `${s.progress.currentTool}: ${truncateToWidth(intent, 40)}` : s.progress.currentTool;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return { value: s.id, label, description };
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
handleInput(keyData: string): void {
|
|
428
|
+
for (const key of this.#observeKeys) {
|
|
429
|
+
if (matchesKey(keyData, key)) {
|
|
430
|
+
if (this.#mode === "viewer") {
|
|
431
|
+
this.#setupPicker();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.#onDone();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (this.#mode === "picker") {
|
|
440
|
+
this.#selectList.handleInput(keyData);
|
|
441
|
+
} else if (this.#mode === "viewer") {
|
|
442
|
+
if (matchesKey(keyData, "escape")) {
|
|
443
|
+
this.#setupPicker();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Sync helpers for render path — avoid async in component rendering
|
|
451
|
+
import * as fs from "node:fs";
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Read new bytes from a file starting at the given byte offset.
|
|
455
|
+
* Returns the new text and updated file size, or null on error.
|
|
456
|
+
*/
|
|
457
|
+
function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
|
|
458
|
+
try {
|
|
459
|
+
const stat = fs.statSync(filePath);
|
|
460
|
+
if (stat.size <= fromByte) return { text: "", newSize: stat.size };
|
|
461
|
+
const buf = Buffer.alloc(stat.size - fromByte);
|
|
462
|
+
const fd = fs.openSync(filePath, "r");
|
|
463
|
+
try {
|
|
464
|
+
fs.readSync(fd, buf, 0, buf.length, fromByte);
|
|
465
|
+
} finally {
|
|
466
|
+
fs.closeSync(fd);
|
|
467
|
+
}
|
|
468
|
+
return { text: buf.toString("utf-8"), newSize: stat.size };
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -109,6 +109,25 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
|
|
109
109
|
{ value: "300000", label: "300K tokens", description: "Large context window" },
|
|
110
110
|
{ value: "500000", label: "500K tokens", description: "Very large context window" },
|
|
111
111
|
],
|
|
112
|
+
"compaction.idleThresholdTokens": [
|
|
113
|
+
{ value: "100000", label: "100K tokens" },
|
|
114
|
+
{ value: "200000", label: "200K tokens" },
|
|
115
|
+
{ value: "300000", label: "300K tokens" },
|
|
116
|
+
{ value: "400000", label: "400K tokens" },
|
|
117
|
+
{ value: "500000", label: "500K tokens" },
|
|
118
|
+
{ value: "600000", label: "600K tokens" },
|
|
119
|
+
{ value: "700000", label: "700K tokens" },
|
|
120
|
+
{ value: "800000", label: "800K tokens" },
|
|
121
|
+
{ value: "900000", label: "900K tokens" },
|
|
122
|
+
],
|
|
123
|
+
"compaction.idleTimeoutSeconds": [
|
|
124
|
+
{ value: "60", label: "1 minute" },
|
|
125
|
+
{ value: "120", label: "2 minutes" },
|
|
126
|
+
{ value: "300", label: "5 minutes" },
|
|
127
|
+
{ value: "600", label: "10 minutes" },
|
|
128
|
+
{ value: "1800", label: "30 minutes" },
|
|
129
|
+
{ value: "3600", label: "1 hour" },
|
|
130
|
+
],
|
|
112
131
|
// Retry max retries
|
|
113
132
|
"retry.maxRetries": [
|
|
114
133
|
{ value: "1", label: "1 retry" },
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
3
3
|
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
4
|
-
import { formatCount } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { $ } from "bun";
|
|
6
6
|
import { settings } from "../../config/settings";
|
|
7
7
|
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
|
|
8
8
|
import { theme } from "../../modes/theme/theme";
|
|
9
9
|
import type { AgentSession } from "../../session/agent-session";
|
|
10
10
|
import { calculatePromptTokens } from "../../session/compaction/compaction";
|
|
11
|
-
import
|
|
11
|
+
import * as git from "../../utils/git";
|
|
12
|
+
import { sanitizeStatusText } from "../shared";
|
|
12
13
|
import {
|
|
13
14
|
canReuseCachedPr,
|
|
14
15
|
createPrCacheContext,
|
|
15
16
|
isSamePrCacheContext,
|
|
16
17
|
type PrCacheContext,
|
|
17
|
-
parseDefaultBranch,
|
|
18
18
|
} from "./status-line/git-utils";
|
|
19
19
|
import { getPreset } from "./status-line/presets";
|
|
20
20
|
import { renderSegment, type SegmentContext } from "./status-line/segments";
|
|
@@ -120,7 +120,7 @@ export class StatusLineComponent implements Component {
|
|
|
120
120
|
this.#gitWatcher = null;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
const gitHeadPath =
|
|
123
|
+
const gitHeadPath = git.repo.resolveSync(getProjectDir())?.headPath ?? null;
|
|
124
124
|
if (!gitHeadPath) return;
|
|
125
125
|
|
|
126
126
|
try {
|
|
@@ -152,46 +152,33 @@ export class StatusLineComponent implements Component {
|
|
|
152
152
|
this.#cachedPrContext = undefined;
|
|
153
153
|
}
|
|
154
154
|
#getCurrentBranch(): string | null {
|
|
155
|
-
const
|
|
155
|
+
const head = git.head.resolveSync(getProjectDir());
|
|
156
|
+
const gitHeadPath = head?.headPath ?? null;
|
|
156
157
|
if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
|
|
157
158
|
return this.#cachedBranch;
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
this.#cachedBranchRepoId = gitHeadPath;
|
|
161
|
-
if (!
|
|
162
|
+
if (!head) {
|
|
162
163
|
this.#cachedBranch = null;
|
|
163
164
|
return null;
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
const content = fs.readFileSync(gitHeadPath, "utf8").trim();
|
|
168
|
-
|
|
169
|
-
if (content.startsWith("ref: refs/heads/")) {
|
|
170
|
-
this.#cachedBranch = content.slice(16);
|
|
171
|
-
} else {
|
|
172
|
-
this.#cachedBranch = "detached";
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
this.#cachedBranch = null;
|
|
176
|
-
}
|
|
167
|
+
this.#cachedBranch = head.kind === "ref" ? (head.branchName ?? head.ref) : "detached";
|
|
177
168
|
|
|
178
169
|
return this.#cachedBranch ?? null;
|
|
179
170
|
}
|
|
180
171
|
|
|
181
172
|
#isDefaultBranch(branch: string): boolean {
|
|
182
173
|
if (this.#defaultBranch === undefined) {
|
|
183
|
-
// Kick off async resolution, use hardcoded fallback until it resolves
|
|
184
174
|
this.#defaultBranch = "main";
|
|
185
175
|
(async () => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.#
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const upstream = await $`git rev-parse --abbrev-ref upstream/HEAD`.quiet().nothrow();
|
|
193
|
-
if (upstream.exitCode === 0) {
|
|
194
|
-
this.#defaultBranch = parseDefaultBranch(upstream.stdout.toString().trim());
|
|
176
|
+
const resolved = await git.branch.default(getProjectDir());
|
|
177
|
+
if (resolved) {
|
|
178
|
+
this.#defaultBranch = resolved;
|
|
179
|
+
if (this.#onBranchChange) {
|
|
180
|
+
this.#onBranchChange();
|
|
181
|
+
}
|
|
195
182
|
}
|
|
196
183
|
})();
|
|
197
184
|
}
|
|
@@ -205,42 +192,9 @@ export class StatusLineComponent implements Component {
|
|
|
205
192
|
|
|
206
193
|
this.#gitStatusInFlight = true;
|
|
207
194
|
|
|
208
|
-
// Fire async fetch, return cached value
|
|
209
195
|
(async () => {
|
|
210
196
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (result.exitCode !== 0) {
|
|
214
|
-
this.#cachedGitStatus = null;
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const output = result.stdout.toString();
|
|
219
|
-
|
|
220
|
-
let staged = 0;
|
|
221
|
-
let unstaged = 0;
|
|
222
|
-
let untracked = 0;
|
|
223
|
-
|
|
224
|
-
for (const line of output.split("\n")) {
|
|
225
|
-
if (!line) continue;
|
|
226
|
-
const x = line[0];
|
|
227
|
-
const y = line[1];
|
|
228
|
-
|
|
229
|
-
if (x === "?" && y === "?") {
|
|
230
|
-
untracked++;
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (x && x !== " " && x !== "?") {
|
|
235
|
-
staged++;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (y && y !== " ") {
|
|
239
|
-
unstaged++;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
this.#cachedGitStatus = { staged, unstaged, untracked };
|
|
197
|
+
this.#cachedGitStatus = await git.status.summary(getProjectDir());
|
|
244
198
|
} catch {
|
|
245
199
|
this.#cachedGitStatus = null;
|
|
246
200
|
} finally {
|
|
@@ -586,6 +586,7 @@ export class CommandController {
|
|
|
586
586
|
}
|
|
587
587
|
}
|
|
588
588
|
await this.ctx.session.newSession();
|
|
589
|
+
this.ctx.resetObserverRegistry();
|
|
589
590
|
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
|
|
590
591
|
|
|
591
592
|
this.ctx.statusLine.invalidate();
|