@esso0428/pi-subagents 0.15.9 → 0.15.10

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.
Files changed (35) hide show
  1. package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +53 -0
  2. package/.pi/workflows/index.json +5 -0
  3. package/CHANGELOG.md +10 -0
  4. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  5. package/dist/ui/ccstyle/diff/diff-renderer.js +4 -2
  6. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  7. package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
  8. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  9. package/dist/ui/ccstyle/tool-renderer.js +12 -2
  10. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  11. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  12. package/dist/ui/ccstyle/tool-result.js +14 -3
  13. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  14. package/dist/ui/conversation-timeline.d.ts +8 -0
  15. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  16. package/dist/ui/conversation-timeline.js +101 -20
  17. package/dist/ui/conversation-timeline.js.map +1 -1
  18. package/dist/ui/conversation-viewer.d.ts +3 -0
  19. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  20. package/dist/ui/conversation-viewer.js +63 -17
  21. package/dist/ui/conversation-viewer.js.map +1 -1
  22. package/dist/write-execution.d.ts +2 -0
  23. package/dist/write-execution.d.ts.map +1 -1
  24. package/dist/write-execution.js +5 -1
  25. package/dist/write-execution.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/ui/ccstyle/diff/diff-renderer.ts +7 -2
  28. package/src/ui/ccstyle/tool-renderer.ts +18 -2
  29. package/src/ui/ccstyle/tool-result.ts +14 -3
  30. package/src/ui/conversation-timeline.ts +126 -18
  31. package/src/ui/conversation-viewer.ts +62 -17
  32. package/src/write-execution.ts +7 -1
  33. package/test/ui/conversation-diff.test.ts +13 -0
  34. package/test/ui/conversation-viewer.test.ts +54 -6
  35. package/test/write-execution.test.ts +2 -0
@@ -62,6 +62,7 @@ export class ConversationViewer implements Component {
62
62
  private lastInnerW = 0;
63
63
  private closed = false;
64
64
  private stopArmed = false;
65
+ private hoveredClose = false;
65
66
  private keys: ViewerKeys;
66
67
  private composer: Input | undefined;
67
68
  private searchMode = false;
@@ -135,11 +136,11 @@ export class ConversationViewer implements Component {
135
136
  this.timeline.toggleFocusedTool();
136
137
  return;
137
138
  }
138
- if (this.canSteer()) {
139
- this.stopArmed = false;
140
- this.openComposer();
141
- return;
142
- }
139
+ }
140
+ if (data === "e" && this.canSteer()) {
141
+ this.stopArmed = false;
142
+ this.openComposer();
143
+ return;
143
144
  }
144
145
  if (matchesKey(data, "x")) {
145
146
  if (this.isStoppable()) {
@@ -210,9 +211,35 @@ export class ConversationViewer implements Component {
210
211
  void maxScroll;
211
212
  }
212
213
 
213
- handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
214
- if (this.composer || this.searchMode) return undefined;
214
+ handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
215
215
  const innerW = Math.max(1, this.lastInnerW || event.width - 4);
216
+ const closeHit = this.isCloseButtonHit(event.x, event.y, innerW);
217
+ if (event.type === "move") {
218
+ const closeChanged = closeHit !== this.hoveredClose;
219
+ this.hoveredClose = closeHit;
220
+ if (this.composer || this.searchMode) {
221
+ return closeChanged ? { handled: true, render: true } : undefined;
222
+ }
223
+ const contentLines = this.buildContentLines(innerW);
224
+ const viewportHeight = this.viewportHeight();
225
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
226
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
227
+ const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
228
+ const contentY = event.y - contentOrigin + visibleStart;
229
+ // Forward moves outside the content too so a previously hovered tool
230
+ // hint is cleared when the pointer returns to the header/footer.
231
+ const timelineResult = this.timeline.handleMouse({ ...event, y: contentY });
232
+ return timelineResult ?? (closeChanged ? { handled: true, render: true } : undefined);
233
+ }
234
+ if (closeHit && event.button === "left") {
235
+ if (event.type === "press") return { handled: true, capture: true, render: false };
236
+ if (event.type === "click") {
237
+ this.closed = true;
238
+ this.done(undefined);
239
+ return { handled: true, render: false };
240
+ }
241
+ }
242
+ if (this.composer || this.searchMode) return undefined;
216
243
  const contentLines = this.buildContentLines(innerW);
217
244
  const viewportHeight = this.viewportHeight();
218
245
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
@@ -231,13 +258,16 @@ export class ConversationViewer implements Component {
231
258
  if (contentY < 0 || contentY >= contentLines.length) return undefined;
232
259
  const result = this.timeline.handleMouse({ ...event, y: contentY });
233
260
  if (result?.handled) {
234
- const focusedTool = this.timeline.getFocusedToolCallId();
235
- const refreshed = this.timeline.getSnapshot();
236
- const selected = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
237
- if (selected >= 0) this.selectedMessageIdx = selected;
238
- this.cacheVersion++;
239
- this.invalidate();
240
- this.tui.requestRender();
261
+ if (event.type === "click") {
262
+ const focusedTool = this.timeline.getFocusedToolCallId();
263
+ const refreshed = this.timeline.getSnapshot();
264
+ const selected = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
265
+ if (selected >= 0) this.selectedMessageIdx = selected;
266
+ this.cacheVersion++;
267
+ this.invalidate();
268
+ this.tui.requestRender();
269
+ }
270
+ return result;
241
271
  }
242
272
  return result;
243
273
  }
@@ -268,7 +298,12 @@ export class ConversationViewer implements Component {
268
298
  const percent = getSessionContextPercent(this.activity?.session);
269
299
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
270
300
  }
271
- lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
301
+ const headerText = `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`;
302
+ const closeLabel = this.hoveredClose ? th.fg("text", th.bold("[esc]")) : th.fg("dim", "[esc]");
303
+ const closeWidth = visibleWidth(closeLabel);
304
+ const headerLeft = truncateToWidth(headerText, Math.max(0, innerW - closeWidth - 1), "", true);
305
+ const headerGap = Math.max(1, innerW - visibleWidth(headerLeft) - closeWidth);
306
+ lines.push(row(headerLeft + " ".repeat(headerGap) + closeLabel));
272
307
  const invocationLine = this.invocationLine();
273
308
  if (invocationLine) lines.push(row(invocationLine));
274
309
  lines.push(hrMid);
@@ -322,7 +357,7 @@ export class ConversationViewer implements Component {
322
357
  lines.push(row(truncateToWidth(th.fg("accent", query) + th.fg("dim", matchCount) + hints, innerW, "...", true)));
323
358
  } else {
324
359
  const actions: string[] = [];
325
- if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
360
+ if (this.canSteer()) actions.push(th.fg("dim", "e steer"));
326
361
  if (this.isStoppable()) actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
327
362
  const editHint = this.isLive() ? " O" : "";
328
363
  const shortcutHints = [
@@ -354,6 +389,13 @@ export class ConversationViewer implements Component {
354
389
  return lines;
355
390
  }
356
391
 
392
+ private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
393
+ if (y !== 1) return false;
394
+ const labelWidth = visibleWidth("[esc]");
395
+ const labelStart = 2 + Math.max(0, innerW - labelWidth);
396
+ return x >= labelStart && x < 2 + innerW;
397
+ }
398
+
357
399
  invalidate(): void {
358
400
  this.cachedWidth = 0;
359
401
  this.cachedLines = [];
@@ -429,7 +471,10 @@ export class ConversationViewer implements Component {
429
471
  }
430
472
 
431
473
  private activeHeaderLine(): number {
432
- return this.cachedMessageStarts[this.currentMessageIndex()] ?? -1;
474
+ const block = this.cachedMessageBlocks[this.currentMessageIndex()];
475
+ return block?.role === "user" || block?.role === "assistant"
476
+ ? this.cachedMessageStarts[this.currentMessageIndex()] ?? -1
477
+ : -1;
433
478
  }
434
479
 
435
480
  private currentBlock(): ConversationBlock | undefined {
@@ -9,6 +9,8 @@ export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
9
9
  export const MAX_WRITE_METADATA_ENTRIES = 100;
10
10
 
11
11
  export interface DurableWriteDetails {
12
+ /** Persisted so history can distinguish create vs overwrite without file access. */
13
+ fileExistedBeforeWrite?: boolean;
12
14
  diff?: string;
13
15
  patch?: string;
14
16
  firstChangedLine?: number;
@@ -106,7 +108,10 @@ function durableDetails(
106
108
  content: string,
107
109
  ): DurableWriteDetails {
108
110
  if (metadata.diffUnavailableReason) {
109
- return { diffUnavailableReason: metadata.diffUnavailableReason };
111
+ return {
112
+ fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
113
+ diffUnavailableReason: metadata.diffUnavailableReason,
114
+ };
110
115
  }
111
116
  const previousContent = metadata.previousContent ?? "";
112
117
  const diffResult = generateDiffString(previousContent, content);
@@ -116,6 +121,7 @@ function durableDetails(
116
121
  return { diffUnavailableReason: `durable diff exceeds ${MAX_DURABLE_DIFF_BYTES} bytes` };
117
122
  }
118
123
  return {
124
+ fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
119
125
  diff: diffResult.diff,
120
126
  patch,
121
127
  firstChangedLine: diffResult.firstChangedLine,
@@ -66,6 +66,19 @@ describe("viewer-local ccstyle diff renderer", () => {
66
66
  expect(rendered).not.toContain("later");
67
67
  });
68
68
 
69
+ it("labels persisted overwrites correctly without reading the current file", () => {
70
+ const component = renderRichToolResult("write", {
71
+ content: [{ type: "text", text: "Successfully wrote" }],
72
+ details: {
73
+ diff: "-1 old\n+1 new",
74
+ patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
75
+ },
76
+ }, { expanded: false }, theme, { args: { path: "file.txt", content: "new\n" } });
77
+
78
+ expect(component?.render(100).join("\\n")).toContain("overwritten");
79
+ expect(component?.render(100).join("\\n")).toContain("click to show more");
80
+ });
81
+
69
82
  it("returns an explicit unavailable row instead of an empty success card", () => {
70
83
  const component = renderRichToolResult("write", {
71
84
  content: [{ type: "text", text: "Successfully wrote" }],
@@ -83,8 +83,13 @@ describe("ConversationViewer", () => {
83
83
  );
84
84
  const liveFooter = live.render(120).join("\n");
85
85
  expect(liveFooter).toContain("j/k scroll · J/K messages · g/G t M o O /n N q");
86
- expect(liveFooter).toContain("Enter steer");
86
+ expect(liveFooter).toContain("e steer");
87
87
  expect(liveFooter).toContain("x stop");
88
+ live.handleInput("\r");
89
+ expect(live.render(120).join("\n")).not.toContain("Enter send");
90
+ live.handleInput("e");
91
+ expect(live.render(120).join("\n")).toContain("Enter send");
92
+ live.handleInput("\x1b");
88
93
 
89
94
  const history = new ConversationViewer(
90
95
  tui(),
@@ -147,7 +152,9 @@ describe("ConversationViewer", () => {
147
152
  vi.fn(),
148
153
  );
149
154
 
150
- expect(viewer.render(120).join("\n")).not.toContain("six");
155
+ const initial = viewer.render(120).join("\n");
156
+ expect(initial).not.toContain("six");
157
+ expect(initial).not.toContain("READ");
151
158
  viewer.handleInput("\r");
152
159
  expect(viewer.render(120).join("\n")).toContain("six");
153
160
  });
@@ -209,7 +216,7 @@ describe("ConversationViewer", () => {
209
216
  expect((viewer as any).scrollOffset).toBeLessThan(before);
210
217
  });
211
218
 
212
- it("toggles mouse expansion only on the normalized click event", () => {
219
+ it("expands on the first click and collapses only on the second click", () => {
213
220
  const viewer = new ConversationViewer(
214
221
  tui(),
215
222
  createStaticConversationSource([
@@ -222,11 +229,52 @@ describe("ConversationViewer", () => {
222
229
  vi.fn(),
223
230
  );
224
231
  expect(viewer.render(120).join("\n")).not.toContain("six");
225
- const mouse = { button: "left", x: 1, screenX: 1, screenY: 5, width: 120, height: 30, shift: false, alt: false, ctrl: false };
226
- expect(viewer.handleMouse({ ...mouse, type: "press", y: 5 } as any)?.handled).toBe(true);
232
+ const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
233
+ expect(span.clickStartLine).toEqual(expect.any(Number));
234
+ const hintY = 4 + span.clickStartLine;
235
+ const mouse = { button: "left", x: 1, screenX: 1, screenY: hintY, width: 120, height: 30, shift: false, alt: false, ctrl: false };
236
+ expect(viewer.handleMouse({ ...mouse, type: "press", y: hintY } as any)?.handled).toBe(true);
227
237
  expect(viewer.render(120).join("\n")).not.toContain("six");
228
- viewer.handleMouse({ ...mouse, type: "click", y: 5 } as any);
238
+ viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 1 } as any);
229
239
  expect(viewer.render(120).join("\n")).toContain("six");
240
+ viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 1 } as any);
241
+ expect(viewer.render(120).join("\n")).toContain("six");
242
+ viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 2 } as any);
243
+ expect(viewer.render(120).join("\n")).not.toContain("six");
244
+ });
245
+
246
+ it("highlights the tool hint on hover and closes from the viewer [esc] button", () => {
247
+ const semanticTheme = {
248
+ fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
249
+ bold: (text: string) => `<bold>${text}</bold>`,
250
+ };
251
+ const done = vi.fn();
252
+ const viewer = new ConversationViewer(
253
+ tui(),
254
+ createStaticConversationSource([
255
+ { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
256
+ { role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
257
+ ] as any),
258
+ record(),
259
+ undefined,
260
+ semanticTheme,
261
+ done,
262
+ );
263
+ const initial = viewer.render(120).join("\n");
264
+ const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
265
+ expect(span.clickStartLine).toEqual(expect.any(Number));
266
+ const hintY = 4 + span.clickStartLine;
267
+ expect(initial).not.toContain("<text>click to show more</text>");
268
+ viewer.handleMouse({ type: "move", button: "none", x: 2, y: hintY, screenX: 2, screenY: hintY, width: 120, height: 30, shift: false, alt: false, ctrl: false } as any);
269
+ expect(viewer.render(120).join("\n")).toContain("<text>click to show more</text>");
270
+
271
+ expect(viewer.render(120).join("\n")).toContain("[esc]");
272
+ viewer.handleMouse({ type: "move", button: "none", x: 114, y: 1, screenX: 114, screenY: 1, width: 120, height: 30, shift: false, alt: false, ctrl: false } as any);
273
+ expect(viewer.render(120).join("\n")).toContain("<text><bold>[esc]</bold></text>");
274
+ const close = { button: "left", x: 114, screenX: 114, screenY: 1, y: 1, width: 120, height: 30, shift: false, alt: false, ctrl: false };
275
+ expect(viewer.handleMouse({ ...close, type: "press" } as any)?.handled).toBe(true);
276
+ expect(viewer.handleMouse({ ...close, type: "click", clickCount: 1 } as any)?.handled).toBe(true);
277
+ expect(done).toHaveBeenCalledTimes(1);
230
278
  });
231
279
 
232
280
  it("keeps historical viewers read-only and closes on Escape", () => {
@@ -31,6 +31,7 @@ describe("executeWriteWithMetadata", () => {
31
31
 
32
32
  expect(await readFile(join(cwd, "nested/file.txt"), "utf8")).toBe("new\n");
33
33
  expect(result.content[0]?.text).toContain("Successfully wrote to nested/file.txt");
34
+ expect(result.details?.fileExistedBeforeWrite).toBe(false);
34
35
  expect(result.details?.diff).toContain("+1 new");
35
36
  expect(result.details?.patch).toContain("+new");
36
37
  expect(store.get("call-new")?.diff).toBe(result.details?.diff);
@@ -48,6 +49,7 @@ describe("executeWriteWithMetadata", () => {
48
49
 
49
50
  await writeFile(join(cwd, "file.txt"), "later\n", "utf8");
50
51
 
52
+ expect(persisted.fileExistedBeforeWrite).toBe(true);
51
53
  expect(persisted.diff).toContain("-1 old");
52
54
  expect(persisted.diff).toContain("+1 new");
53
55
  expect(persisted.patch).toContain("-old");