@esso0428/pi-subagents 0.17.2 → 0.17.4

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.
@@ -29,6 +29,44 @@ const MATCH_BG = "\x1b[48;5;238m";
29
29
  const MATCH_BG_CURRENT = "\x1b[48;5;220m\x1b[30m";
30
30
  const BG_RESET = "\x1b[49m\x1b[39m";
31
31
 
32
+ function renderScrollbarCell(
33
+ theme: Theme,
34
+ totalLines: number,
35
+ viewportHeight: number,
36
+ scrollOffset: number,
37
+ lineIndex: number,
38
+ active: boolean,
39
+ ): string {
40
+ const total = Math.max(0, Math.floor(totalLines));
41
+ const viewport = Math.max(1, Math.floor(viewportHeight));
42
+ if (total <= viewport) return theme.fg("scrollbarTrack", " ");
43
+ const maxOffset = Math.max(0, total - viewport);
44
+ const offset = Math.max(0, Math.min(maxOffset, Math.floor(scrollOffset)));
45
+ const thumbHeight = Math.max(1, Math.round((viewport * viewport) / total));
46
+ const thumbStart = Math.round(((viewport - thumbHeight) * offset) / maxOffset);
47
+ const inThumb = lineIndex >= thumbStart && lineIndex < thumbStart + thumbHeight;
48
+ return theme.fg(
49
+ inThumb ? "scrollbarThumb" : "scrollbarTrack",
50
+ inThumb ? (active ? "█" : "┃") : "│",
51
+ );
52
+ }
53
+
54
+ function renderScrollMoreRule(
55
+ theme: Theme,
56
+ width: number,
57
+ direction: "top" | "bottom",
58
+ hiddenLines: number,
59
+ ): string {
60
+ const safeWidth = Math.max(1, Math.floor(width));
61
+ if (hiddenLines <= 0) return theme.fg("border", `├${"─".repeat(safeWidth)}┤`);
62
+ const label = `${direction === "top" ? "↑" : "↓"} ${hiddenLines} more`;
63
+ const labelWidth = visibleWidth(label);
64
+ if (labelWidth + 2 > safeWidth) return theme.fg("border", `├${"─".repeat(safeWidth)}┤`);
65
+ const left = Math.floor((safeWidth - labelWidth) / 2);
66
+ const right = Math.max(0, safeWidth - left - labelWidth);
67
+ return `${theme.fg("border", `├${"─".repeat(left)}`)}${theme.fg("dim", label)}${theme.fg("border", `${"─".repeat(right)}┤`)}`;
68
+ }
69
+
32
70
  /** The live fields needed by the viewer; historical viewers use a static source. */
33
71
  export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
34
72
 
@@ -48,6 +86,7 @@ class FullToolPreview implements Component {
48
86
  private totalLines = 1;
49
87
  private pendingTop = false;
50
88
  private hoveredClose = false;
89
+ private scrollbarActive = false;
51
90
 
52
91
  constructor(
53
92
  private readonly tui: TUI,
@@ -99,10 +138,12 @@ class FullToolPreview implements Component {
99
138
  render(width: number): string[] {
100
139
  const safeWidth = Math.max(12, Math.floor(Number.isFinite(width) ? width : 12));
101
140
  const inner = Math.max(1, safeWidth - 2);
141
+ // Reserve the rightmost inner cell for the same track/thumb rail used by
142
+ // pi-tui's native ScrollView layout.
102
143
  const bodyWidth = Math.max(1, inner - 1);
103
144
  const terminalRows = Math.max(1, this.tui.terminal.rows);
104
145
  const viewport = Math.max(1, Math.min(30, Math.floor(terminalRows * 0.8), terminalRows - 6));
105
- const wrapped = this.body.render(bodyWidth);
146
+ const wrapped = this.body.render(Math.max(1, bodyWidth - 1));
106
147
  this.totalLines = wrapped.length;
107
148
  this.pageSize = viewport;
108
149
  this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.totalLines - this.pageSize));
@@ -112,15 +153,28 @@ class FullToolPreview implements Component {
112
153
  const clipped = truncateToWidth(text, rowWidth, "…", true);
113
154
  return clipped + " ".repeat(Math.max(0, rowWidth - visibleWidth(clipped)));
114
155
  };
156
+ const hiddenAbove = this.scrollOffset;
157
+ const hiddenBelow = Math.max(0, this.totalLines - this.scrollOffset - this.pageSize);
115
158
  const close = this.theme.fg(this.hoveredClose ? "text" : "dim", "[esc]");
116
159
  const header = this.theme.bold(this.theme.fg("accent", this.title));
117
160
  const status = `${this.scrollOffset + 1}-${Math.min(this.totalLines, this.scrollOffset + this.pageSize)} / ${this.totalLines} lines · j/k gg/G ↑↓ PgUp/PgDn · [esc] close`;
118
161
  return [
119
162
  border(`╭${"─".repeat(inner)}╮`),
120
163
  `${border("│")}${pad(` ${header}`, inner - visibleWidth(close))}${close}${border("│")}`,
121
- `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
122
- ...Array.from({ length: viewport }, (_, index) => `${border("│")}${pad(` ${visible[index] ?? ""}`, bodyWidth)}${border("│")}`),
123
- `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
164
+ renderScrollMoreRule(this.theme, inner, "top", hiddenAbove),
165
+ ...Array.from({ length: viewport }, (_, index) => {
166
+ const content = pad(` ${visible[index] ?? ""}`, bodyWidth);
167
+ const rail = renderScrollbarCell(
168
+ this.theme,
169
+ this.totalLines,
170
+ this.pageSize,
171
+ this.scrollOffset,
172
+ index,
173
+ this.scrollbarActive,
174
+ );
175
+ return `${border("│")}${content}${rail}${border("│")}`;
176
+ }),
177
+ renderScrollMoreRule(this.theme, inner, "bottom", hiddenBelow),
124
178
  `${border("│")}${pad(this.theme.fg("dim", ` ${status}`))}${border("│")}`,
125
179
  border(`╰${"─".repeat(inner)}╯`),
126
180
  ];
@@ -134,6 +188,7 @@ class FullToolPreview implements Component {
134
188
  const next = Math.max(0, Math.min(Math.max(0, this.totalLines - this.pageSize), offset));
135
189
  if (next === this.scrollOffset) return;
136
190
  this.scrollOffset = next;
191
+ this.scrollbarActive = true;
137
192
  this.tui.requestRender();
138
193
  }
139
194
  }
@@ -164,6 +219,7 @@ export class ConversationViewer implements Component {
164
219
  private lastInnerW = 0;
165
220
  private closed = false;
166
221
  private stopArmed = false;
222
+ private hoveredPreview = false;
167
223
  private hoveredClose = false;
168
224
  private keys: ViewerKeys;
169
225
  private composer: Input | undefined;
@@ -171,6 +227,10 @@ export class ConversationViewer implements Component {
171
227
  private searchQuery = "";
172
228
  private searchMatches: ConversationMatch[] = [];
173
229
  private currentMatchIdx = -1;
230
+ /** Follow-up drafts, newest first, matching pi editor history semantics. */
231
+ private steerHistory: string[] = [];
232
+ private steerHistoryIndex = -1;
233
+ private steerHistoryDraft = "";
174
234
  private timeline: ConversationTimeline;
175
235
 
176
236
  constructor(
@@ -202,7 +262,16 @@ export class ConversationViewer implements Component {
202
262
 
203
263
  handleInput(data: string): void {
204
264
  if (this.composer) {
205
- this.composer.handleInput(data);
265
+ if (matchesKey(data, Key.alt("up")) || data === "a-up" || data === "alt+up") {
266
+ this.recallSteerDraft();
267
+ } else {
268
+ const before = this.composer.getValue();
269
+ this.composer.handleInput(data);
270
+ if (this.composer && before !== this.composer.getValue()) {
271
+ this.steerHistoryIndex = -1;
272
+ this.steerHistoryDraft = "";
273
+ }
274
+ }
206
275
  this.tui.requestRender();
207
276
  return;
208
277
  }
@@ -305,11 +374,16 @@ export class ConversationViewer implements Component {
305
374
 
306
375
  handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
307
376
  const innerW = Math.max(1, this.lastInnerW || event.width - 4);
377
+ const previewHit = this.isPreviewButtonHit(event.x, event.y, innerW);
308
378
  const closeHit = this.isCloseButtonHit(event.x, event.y, innerW);
309
379
  if (event.type === "move") {
380
+ const previewChanged = previewHit !== this.hoveredPreview;
310
381
  const closeChanged = closeHit !== this.hoveredClose;
382
+ this.hoveredPreview = previewHit;
311
383
  this.hoveredClose = closeHit;
312
- if (this.composer || this.searchMode) return closeChanged ? { handled: true, render: true } : undefined;
384
+ if (this.composer || this.searchMode) {
385
+ return previewChanged || closeChanged ? { handled: true, render: true } : undefined;
386
+ }
313
387
  const contentLines = this.buildContentLines(innerW);
314
388
  const viewportHeight = this.viewportHeight();
315
389
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
@@ -318,7 +392,14 @@ export class ConversationViewer implements Component {
318
392
  const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
319
393
  const contentY = event.y - contentOrigin + visibleStart;
320
394
  const timelineResult = this.timeline.handleMouse({ ...event, y: contentY });
321
- return timelineResult ?? (closeChanged ? { handled: true, render: true } : undefined);
395
+ return timelineResult ?? (previewChanged || closeChanged ? { handled: true, render: true } : undefined);
396
+ }
397
+ if (previewHit && event.button === "left") {
398
+ if (event.type === "press") return { handled: true, capture: true, render: false };
399
+ if (event.type === "click") {
400
+ this.openFocusedToolPreview();
401
+ return { handled: true, render: false };
402
+ }
322
403
  }
323
404
  if (closeHit && event.button === "left") {
324
405
  if (event.type === "press") return { handled: true, capture: true, render: false };
@@ -362,11 +443,24 @@ export class ConversationViewer implements Component {
362
443
  return result;
363
444
  }
364
445
 
446
+ private headerActionGeometry(innerW: number): { previewStart: number; previewEnd: number; closeStart: number; closeEnd: number } {
447
+ const closeEnd = 2 + innerW;
448
+ const closeStart = closeEnd - visibleWidth("[esc]");
449
+ const previewEnd = closeStart - 1;
450
+ const previewStart = previewEnd - visibleWidth("[preview]");
451
+ return { previewStart, previewEnd, closeStart, closeEnd };
452
+ }
453
+
454
+ private isPreviewButtonHit(x: number, y: number, innerW: number): boolean {
455
+ if (y !== 1) return false;
456
+ const { previewStart, previewEnd } = this.headerActionGeometry(innerW);
457
+ return x >= previewStart && x < previewEnd;
458
+ }
459
+
365
460
  private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
366
461
  if (y !== 1) return false;
367
- const labelWidth = visibleWidth("[esc]");
368
- const labelStart = 2 + Math.max(0, innerW - labelWidth);
369
- return x >= labelStart && x < 2 + innerW;
462
+ const { closeStart, closeEnd } = this.headerActionGeometry(innerW);
463
+ return x >= closeStart && x < closeEnd;
370
464
  }
371
465
 
372
466
  render(width: number): string[] {
@@ -396,11 +490,15 @@ export class ConversationViewer implements Component {
396
490
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
397
491
  }
398
492
  const headerText = `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`;
493
+ const previewAvailable = this.focusedToolBlock() !== undefined;
494
+ const previewLabel = previewAvailable && this.hoveredPreview
495
+ ? th.fg("text", th.bold("[preview]"))
496
+ : th.fg("dim", "[preview]");
399
497
  const closeLabel = this.hoveredClose ? th.fg("text", th.bold("[esc]")) : th.fg("dim", "[esc]");
400
- const closeWidth = visibleWidth(closeLabel);
401
- const headerLeft = truncateToWidth(headerText, Math.max(0, innerW - closeWidth - 1), "", true);
402
- const headerGap = Math.max(1, innerW - visibleWidth(headerLeft) - closeWidth);
403
- lines.push(row(headerLeft + " ".repeat(headerGap) + closeLabel));
498
+ const actionsWidth = visibleWidth(previewLabel) + 1 + visibleWidth(closeLabel);
499
+ const headerLeft = truncateToWidth(headerText, Math.max(0, innerW - actionsWidth - 1), "", true);
500
+ const headerGap = Math.max(1, innerW - visibleWidth(headerLeft) - actionsWidth);
501
+ lines.push(row(headerLeft + " ".repeat(headerGap) + previewLabel + " " + closeLabel));
404
502
  const invocationLine = this.invocationLine();
405
503
  if (invocationLine) lines.push(row(invocationLine));
406
504
  lines.push(hrMid);
@@ -442,7 +540,7 @@ export class ConversationViewer implements Component {
442
540
  lines.push(hrMid);
443
541
  if (this.composer) {
444
542
  lines.push(row(this.composer.render(innerW)[0] ?? ""));
445
- const hint = th.fg("dim", "Enter send · Esc cancel");
543
+ const hint = th.fg("dim", "Enter send · Esc cancel · Alt+Up recall");
446
544
  const label = th.fg("accent", "✎ steer");
447
545
  lines.push(row(label + " ".repeat(Math.max(1, innerW - visibleWidth(label) - visibleWidth(hint))) + hint));
448
546
  } else if (this.searchMode) {
@@ -710,14 +808,18 @@ export class ConversationViewer implements Component {
710
808
  this.scrollOffset = Math.max(0, Math.min(maxScroll, this.scrollOffset));
711
809
  }
712
810
 
713
- private openFocusedToolPreview(): void {
811
+ private focusedToolBlock(): ConversationBlock | undefined {
714
812
  this.buildContentLines(this.lastInnerW || 80);
715
813
  const focusedToolCallId = this.timeline.getFocusedToolCallId();
716
- const block = focusedToolCallId
717
- ? this.cachedMessageBlocks.find((candidate) => candidate.toolCallId === focusedToolCallId)
718
- : this.currentBlock();
814
+ if (!focusedToolCallId) return undefined;
815
+ const block = this.cachedMessageBlocks.find((candidate) => candidate.toolCallId === focusedToolCallId);
816
+ return block?.kind === "tool" ? block : undefined;
817
+ }
818
+
819
+ private openFocusedToolPreview(): void {
820
+ const block = this.focusedToolBlock();
719
821
  const custom = this.operations?.ctx?.ui?.custom;
720
- if (block?.kind !== "tool" || typeof custom !== "function") return;
822
+ if (!block || typeof custom !== "function") return;
721
823
 
722
824
  const toolName = block.toolName || "tool";
723
825
  const args = block.args ?? block.toolArguments;
@@ -865,17 +967,39 @@ export class ConversationViewer implements Component {
865
967
  private openComposer(): void {
866
968
  const input = new Input();
867
969
  input.focused = true;
970
+ this.steerHistoryIndex = -1;
971
+ this.steerHistoryDraft = "";
868
972
  input.onSubmit = (value: string) => {
869
973
  const message = value.trim();
870
974
  this.composer = undefined;
871
- if (message) this.onSteer?.(message);
975
+ this.steerHistoryIndex = -1;
976
+ this.steerHistoryDraft = "";
977
+ if (message) {
978
+ if (this.steerHistory[0] !== message) this.steerHistory.unshift(message);
979
+ if (this.steerHistory.length > 100) this.steerHistory.pop();
980
+ this.onSteer?.(message);
981
+ }
872
982
  this.tui.requestRender();
873
983
  };
874
984
  input.onEscape = () => {
875
985
  this.composer = undefined;
986
+ this.steerHistoryIndex = -1;
987
+ this.steerHistoryDraft = "";
876
988
  this.tui.requestRender();
877
989
  };
878
990
  this.composer = input;
879
991
  this.tui.requestRender();
880
992
  }
993
+
994
+ /** Recall the newest prior steer, preserving the draft for future editing. */
995
+ private recallSteerDraft(): void {
996
+ if (!this.composer || this.steerHistory.length === 0) return;
997
+ if (this.steerHistoryIndex < 0) {
998
+ this.steerHistoryDraft = this.composer.getValue();
999
+ this.steerHistoryIndex = 0;
1000
+ } else if (this.steerHistoryIndex < this.steerHistory.length - 1) {
1001
+ this.steerHistoryIndex++;
1002
+ }
1003
+ this.composer.setValue(this.steerHistory[this.steerHistoryIndex] ?? this.steerHistoryDraft);
1004
+ }
881
1005
  }