@code-yeongyu/senpi-codemode 2026.9.13-2 → 2026.9.15-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.
@@ -71,10 +71,15 @@ const TOOL_CALL_COLLAPSED_VISUAL_LINES = 4;
71
71
  const TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS = 512;
72
72
  const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]";
73
73
  const LIVE_ELAPSED_TICK_MS = 1_000;
74
+ // A live row repaints on every tick, so this many ticks without a render means the row is gone.
75
+ const LIVE_TICKER_MAX_IDLE_TICKS = 60;
74
76
 
75
77
  class PlainTextComponent implements EvalRenderComponent {
76
78
  #blocks: readonly RenderBlock[] = [];
77
79
  #ticker: ReturnType<typeof setInterval> | undefined;
80
+ #live = false;
81
+ #invalidate: (() => void) | undefined;
82
+ #idleTicks = 0;
78
83
 
79
84
  setBlocks(blocks: readonly RenderBlock[]): void {
80
85
  this.#blocks = blocks;
@@ -84,16 +89,19 @@ class PlainTextComponent implements EvalRenderComponent {
84
89
  * The host only animates tool rows for streaming args, `task`, and results carrying
85
90
  * `details.progress`; an eval row matches none of them, so nothing repaints it between
86
91
  * update events. While a cell is non-terminal this drives the repaint itself so the
87
- * header's elapsed time advances, and it emits no tool updates or RPC traffic.
92
+ * header's elapsed time advances, and it emits no tool updates or RPC traffic. Detached
93
+ * and terminal cards never arm it, and a ticker whose row stopped rendering (transcript
94
+ * rebuild, session switch) stops itself after LIVE_TICKER_MAX_IDLE_TICKS and rearms on
95
+ * the next render, so dropped rows cannot accumulate intervals.
88
96
  */
89
97
  syncLiveTicker(isLive: boolean, invalidate: () => void): void {
98
+ this.#live = isLive;
99
+ this.#invalidate = invalidate;
90
100
  if (!isLive) {
91
101
  this.stopLiveTicker();
92
102
  return;
93
103
  }
94
- if (this.#ticker !== undefined) return;
95
- this.#ticker = setInterval(invalidate, LIVE_ELAPSED_TICK_MS);
96
- this.#ticker.unref?.();
104
+ this.#armTicker();
97
105
  }
98
106
 
99
107
  stopLiveTicker(): void {
@@ -102,7 +110,24 @@ class PlainTextComponent implements EvalRenderComponent {
102
110
  this.#ticker = undefined;
103
111
  }
104
112
 
113
+ #armTicker(): void {
114
+ if (this.#ticker !== undefined || this.#invalidate === undefined) return;
115
+ this.#ticker = setInterval(() => this.#tick(), LIVE_ELAPSED_TICK_MS);
116
+ this.#ticker.unref?.();
117
+ }
118
+
119
+ #tick(): void {
120
+ this.#idleTicks += 1;
121
+ if (this.#idleTicks >= LIVE_TICKER_MAX_IDLE_TICKS) {
122
+ this.stopLiveTicker();
123
+ return;
124
+ }
125
+ this.#invalidate?.();
126
+ }
127
+
105
128
  render(width: number): string[] {
129
+ this.#idleTicks = 0;
130
+ if (this.#live) this.#armTicker();
106
131
  const lines: string[] = [];
107
132
  for (const block of this.#blocks) {
108
133
  switch (block.kind) {
@@ -269,7 +294,7 @@ function spinner(frame: number | undefined): string {
269
294
  }
270
295
 
271
296
  function isLiveCellStatus(status: CellStatus): boolean {
272
- return status === "pending" || status === "running" || status === "detached";
297
+ return status === "pending" || status === "running";
273
298
  }
274
299
 
275
300
  function cellPresentation(status: CellStatus, spinnerFrame: number | undefined): StatusPresentation {
@@ -0,0 +1,35 @@
1
+ import type { EvalDetachedCellSnapshot } from "./detached-cell-contract.ts";
2
+
3
+ export const TERMINAL_SNAPSHOT_CAP = 32;
4
+
5
+ /** Bounded LRU of settled-cell snapshots; without it every settled cell pins its result and closures for the session lifetime (#1695). */
6
+ export class TerminalSnapshotStore {
7
+ readonly #snapshots = new Map<string, EvalDetachedCellSnapshot>();
8
+ readonly #cap: number;
9
+
10
+ constructor(cap = TERMINAL_SNAPSHOT_CAP) {
11
+ this.#cap = cap;
12
+ }
13
+
14
+ remember(snapshot: EvalDetachedCellSnapshot): void {
15
+ this.#snapshots.delete(snapshot.cellId);
16
+ this.#snapshots.set(snapshot.cellId, snapshot);
17
+ while (this.#snapshots.size > this.#cap) {
18
+ const oldest = this.#snapshots.keys().next();
19
+ if (oldest.done === true) break;
20
+ this.#snapshots.delete(oldest.value);
21
+ }
22
+ }
23
+
24
+ get(cellId: string): EvalDetachedCellSnapshot | undefined {
25
+ return this.#snapshots.get(cellId);
26
+ }
27
+
28
+ delete(cellId: string): void {
29
+ this.#snapshots.delete(cellId);
30
+ }
31
+
32
+ clear(): void {
33
+ this.#snapshots.clear();
34
+ }
35
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentToolResult } from "@code-yeongyu/senpi";
2
+
3
+ export function marshalToolResult(result: AgentToolResult<unknown>) {
4
+ const texts = result.content.filter((part) => part.type === "text").map((part) => part.text);
5
+ const images = result.content
6
+ .filter((part) => part.type === "image")
7
+ .map((part) => ({ mimeType: part.mimeType, dataBase64: part.data }));
8
+ const details =
9
+ typeof result.details === "object" &&
10
+ result.details !== null &&
11
+ !Array.isArray(result.details) &&
12
+ Object.keys(result.details).length === 0
13
+ ? undefined
14
+ : result.details;
15
+ const hasError = toolResultIsError(result);
16
+ const text = texts.join("\n");
17
+ return images.length === 0 && details === undefined && !hasError ? { text } : { text, details, images, hasError };
18
+ }
19
+
20
+ export function toolResultIsError(result: AgentToolResult<unknown>): boolean {
21
+ const details = result.details;
22
+ return typeof details === "object" && details !== null && "isError" in details && details.isError === true;
23
+ }
@@ -139,6 +139,15 @@ function renderWith<TParams extends TSchema, TDetails, TState>(
139
139
  }
140
140
  }
141
141
 
142
+ function grepDetailsSummary(summary: EvalToolCallSummary): string | undefined {
143
+ const details = summary.details;
144
+ if (details === undefined || details === null || typeof details !== "object") return undefined;
145
+ const record = details as { matchCount?: unknown; fileCount?: unknown; engine?: unknown };
146
+ if (typeof record.fileCount !== "number" || typeof record.engine !== "string") return undefined;
147
+ if (record.matchCount !== null && typeof record.matchCount !== "number") return undefined;
148
+ return `matches=${record.matchCount ?? "n/a"} files=${record.fileCount} (${record.engine})`;
149
+ }
150
+
142
151
  function createCoreWidgets(cwd: string): CoreWidgets {
143
152
  const bashDefinition = createBashToolDefinition(cwd);
144
153
  const readDefinition = createReadToolDefinition(cwd);
@@ -155,7 +164,13 @@ function createCoreWidgets(cwd: string): CoreWidgets {
155
164
  }),
156
165
  read: (summary, options) => renderWith(readDefinition, summary, options, {}),
157
166
  write: (summary, options) => renderWith(writeDefinition, summary, options, {}),
158
- grep: (summary, options) => renderWith(grepDefinition, summary, options, {}),
167
+ grep: (summary, options) => {
168
+ const lines = renderWith(grepDefinition, summary, options, {});
169
+ if (lines === undefined) return undefined;
170
+ const extra = grepDetailsSummary(summary);
171
+ if (extra !== undefined) lines.push(` ${style(options.theme, "muted", extra)}`);
172
+ return lines;
173
+ },
159
174
  find: (summary, options) => renderWith(findDefinition, summary, options, {}),
160
175
  ls: (summary, options) => renderWith(lsDefinition, summary, options, {}),
161
176
  };
package/src/tool/types.ts CHANGED
@@ -153,6 +153,7 @@ export interface EvalToolCallSummary {
153
153
  readonly argsTruncated?: boolean;
154
154
  readonly durationMs?: number;
155
155
  readonly resultPreview?: string;
156
+ readonly details?: unknown;
156
157
  }
157
158
 
158
159
  export type EvalStatusEvent = { readonly op: string } & Readonly<Record<string, unknown>>;