@hicaru/pi-rlm 0.1.9 → 0.2.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.
@@ -6,9 +6,10 @@
6
6
  * and collects sub-calls manually from emitter events. No RlmEventAggregator is used
7
7
  * (ReplDetails ≠ RlmDetails structural mismatch).
8
8
  *
9
- * Sandbox handlers (llm_query, rlm_query, todo, ask_user_question) use mutable refs
10
- * so the tool can swap per-invocation state (emitter, depth, limits) without recreating
11
- * the sandbox preserving REPL variable state across calls.
9
+ * Sandbox handlers (llm_query, rlm_query, todo, ask_user_question) are the *shared* bridges
10
+ * from bridge/llm-query.ts and bridge/rlm-query.ts, bound to NativeBridgeState accessors so
11
+ * the tool can swap per-invocation state (emitter, depth, limits) without recreating the
12
+ * sandbox — preserving REPL variable state across calls.
12
13
  */
13
14
 
14
15
  import { Type } from "typebox";
@@ -16,34 +17,39 @@ import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
16
17
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
17
18
  import type { Model, Usage, Api } from "@earendil-works/pi-ai";
18
19
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
19
- import { modelRef, resolveModelId } from "../config/settings.ts";
20
+ import { displayModelRef } from "../config/settings.ts";
20
21
  import { buildInteractiveHandlers } from "../bridge/interactive.ts";
21
22
  import { buildLibraryHandler } from "../bridge/library.ts";
22
23
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
23
- import { type ChatMsg, modelComplete } from "../bridge/model.ts";
24
- import { previewText } from "../text/preview.ts";
25
- import { mapPool } from "../util/concurrency.ts";
26
- import { LimitGuard } from "../core/limits.ts";
27
- import { checkResourceLimits } from "../core/resource-limits.ts";
28
- import type { InteractiveDeps, RlmConfig, Sampling } from "../core/types.ts";
24
+ import { createLlmBridge } from "../bridge/llm-query.ts";
25
+ import { createRlmHandlers } from "../bridge/rlm-query.ts";
26
+ import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
27
+ import type { RemainingResources } from "../core/resource-limits.ts";
28
+ import type { InteractiveDeps, RlmConfig, RunRlm } from "../core/types.ts";
29
29
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
30
- import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
31
30
  import type { ReplResult } from "../sandbox/protocol.ts";
32
31
  import { RlmEmitter } from "./rlm-events.ts";
33
32
  import { SubcallStore } from "./subcall-store.ts";
34
33
  import type { ReplDetails } from "./repl-details.ts";
35
34
  import type { RlmSubcall } from "./rlm-details.ts";
36
35
  import { createEngine } from "../core/engine.ts";
37
- import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
38
- import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
36
+ import { spinnerFrame } from "../ui/theme.ts";
37
+ import { previewText } from "../text/preview.ts";
38
+ import { errorMessage } from "../util/errors.ts";
39
39
  import {
40
- headlineStatusGlyph,
41
- renderCollapsedSubcallTree,
40
+ cardHeader,
41
+ cardStatsLine,
42
+ renderCollapsedCard,
42
43
  renderExpandedSubcallTree,
43
44
  } from "./subcall-render.ts";
44
45
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
45
46
  import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
46
47
 
48
+ /** Chars of code shown on the tool call line, and of stdout in the expanded view. */
49
+ const CALL_PREVIEW_CHARS = 80;
50
+ const EXPANDED_STDOUT_CHARS = 2_000;
51
+ const EXPANDED_STDERR_CHARS = 500;
52
+
47
53
  // ── Parameter schema ──
48
54
 
49
55
  export const ReplToolParams = Object.freeze(Type.Object({
@@ -93,10 +99,9 @@ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly s
93
99
  // ── Mutable bridge state (handler indirection) ──
94
100
 
95
101
  /**
96
- * Holds per-invocation mutable state that sandbox handlers dereference.
97
- * The sandbox is created once with handlers that read from this object's
98
- * current fields, so the tool can swap emitter/depth/limits between calls
99
- * without recreating the sandbox (preserving REPL variable state).
102
+ * Holds per-invocation mutable state that the shared bridges dereference through accessors.
103
+ * The sandbox is created once with handlers bound to this object, so the tool can swap
104
+ * emitter/depth/limits between calls without recreating the sandbox (preserving REPL state).
100
105
  */
101
106
  class NativeBridgeState {
102
107
  currentEmitter: RlmEmitter | null = null;
@@ -113,203 +118,11 @@ class NativeBridgeState {
113
118
  this.currentInteractive = inv.interactive;
114
119
  }
115
120
 
116
- buildLlmHandlers(deps: {
117
- workerModel: Model<Api>;
118
- getWorkerModel?: () => Model<Api> | undefined;
119
- registry: ModelRegistry;
120
- maxPromptChars: number;
121
- maxConcurrent: number;
122
- sampling?: Sampling;
123
- subSystem?: string;
124
- signal?: AbortSignal;
125
- }): Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched"> {
126
- const state = this;
127
-
128
- const workerModel = (): Model<Api> => deps.getWorkerModel?.() ?? deps.workerModel;
129
- const displayModel = (model: string | null): string =>
130
- modelRef(model ? (resolveModelId(deps.registry, model) ?? workerModel()) : workerModel()) ?? workerModel().id;
131
-
132
- async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
133
- const limits = state.currentLimits;
134
- if (limits) {
135
- const limitError = checkResourceLimits({ budgetUsd: limits.remainingBudgetUsd(), timeoutMs: limits.remainingTimeoutMs() });
136
- if (limitError !== undefined) return limitError;
137
- }
138
- if (prompt.length > deps.maxPromptChars) {
139
- return formatError(`sub-LLM prompt exceeded size limit (${prompt.length.toLocaleString()} chars > ${deps.maxPromptChars.toLocaleString()})`);
140
- }
141
- const resolved = model ? resolveModelId(deps.registry, model) : undefined;
142
- if (model && !resolved) return formatError(`unknown model override '${model}'`);
143
- try {
144
- const messages: ChatMsg[] = [{ role: "user", content: prompt }];
145
- const res = await modelComplete(messages, {
146
- model: resolved ?? workerModel(),
147
- registry: deps.registry,
148
- system: deps.subSystem,
149
- maxTokens: deps.sampling?.maxTokens,
150
- temperature: deps.sampling?.temperature,
151
- reasoning: deps.sampling?.reasoning,
152
- signal: deps.signal,
153
- });
154
- limits?.addUsage(res.usage);
155
- track(res.usage);
156
- return res.text;
157
- } catch (err) {
158
- const msg = errorMessage(err);
159
- const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
160
- ? " — try smaller batches or individual llm_query calls"
161
- : "";
162
- return formatError(`${msg}${hint}`);
163
- }
164
- }
165
-
166
- return {
167
- async llmQuery(prompt, model, _depth) {
168
- const id = state.currentEmitter?.emitSubcallCreated({
169
- kind: "llm", parentId: state.currentParentId, label: "llm_query",
170
- model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
171
- depth: state.currentDepth,
172
- });
173
- let cost = 0; let tokens = 0;
174
- const out = await complete1(prompt, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; });
175
- if (id) state.currentEmitter?.emitSubcallUpdated({ id,
176
- status: isErrorText(out) ? "error" : "done",
177
- costUsd: cost, tokens, resultPreview: previewText(out),
178
- detail: isErrorText(out) ? out : undefined,
179
- });
180
- return out;
181
- },
182
-
183
- async llmQueryBatched(prompts: readonly string[], model, _depth): Promise<string[]> {
184
- const id = state.currentEmitter?.emitSubcallCreated({
185
- kind: "batch", parentId: state.currentParentId, label: `llm_query ×${prompts.length}`,
186
- model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
187
- depth: state.currentDepth,
188
- });
189
- let cost = 0; let tokens = 0;
190
- const out: string[] = await mapPool(prompts, deps.maxConcurrent, (p) =>
191
- complete1(p, model, (u) => { cost += u.cost.total; tokens += u.totalTokens; }),
192
- );
193
- const failed = out.filter(isErrorText).length;
194
- const allFailed = failed === out.length;
195
- const error = allFailed
196
- ? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
197
- : failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
198
- if (id) state.currentEmitter?.emitSubcallUpdated({ id,
199
- status: error ? "error" : "done", costUsd: cost, tokens,
200
- resultPreview: previewText(out[0] ?? ""), detail: error,
201
- failedCount: failed, totalCount: out.length,
202
- });
203
- return out;
204
- },
205
- };
206
- }
207
-
208
- /**
209
- * Build real recursive rlm_query / rlm_query_batched handlers that spawn
210
- * child RLM engines (each with its own sandbox and turn loop) rather than
211
- * falling back to a one-shot llm_query.
212
- *
213
- * At the maxDepth cap the handler degrades to a plain llm_query via the
214
- * already-wired llmHandlers (which read from the same mutable state).
215
- */
216
- buildRlmHandlers(deps: {
217
- model: Model<Api>;
218
- workerModel: Model<Api>;
219
- getModel?: () => Model<Api> | undefined;
220
- getWorkerModel?: () => Model<Api> | undefined;
221
- registry: ModelRegistry;
222
- config: RlmConfig;
223
- signal?: AbortSignal;
224
- onUsage?: (usage: Usage, role: "sub") => void;
225
- llmHandlers: Pick<SubLlmHandlers, "llmQuery" | "llmQueryBatched">;
226
- }): Pick<SubLlmHandlers, "rlmQuery" | "rlmQueryBatched"> {
227
- const state = this;
228
-
229
- async function rlmQueryImpl(prompt: string, model: string | null, depth: number): Promise<string> {
230
- const emitter = state.currentEmitter;
231
- const limits = state.currentLimits;
232
- if (!emitter || !limits) return formatError("RLM bridge not wired for this invocation");
233
-
234
- const childDepth = state.currentDepth + 1;
235
-
236
- // Depth cap: degrade to a one-shot llm_query.
237
- if (childDepth >= deps.config.maxDepth) {
238
- return deps.llmHandlers.llmQuery(prompt, model, depth);
239
- }
240
-
241
- const remBudget = limits.remainingBudgetUsd();
242
- const remTimeout = limits.remainingTimeoutMs();
243
- const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
244
- if (limitError) return limitError;
245
-
246
- const rootModel = deps.getModel?.() ?? deps.model;
247
- const workerModel = deps.getWorkerModel?.() ?? deps.workerModel;
248
- const resolvedOverride = model ? resolveModelId(deps.registry, model) : undefined;
249
- const subId = emitter.emitSubcallCreated({
250
- kind: "rlm", parentId: state.currentParentId, label: "rlm_query",
251
- model: model ? (modelRef(resolvedOverride) ?? `unknown/${model}`) : (modelRef(rootModel) ?? rootModel.id),
252
- detail: prompt.slice(0, 60),
253
- depth: childDepth,
254
- });
255
-
256
- // Per-call engine creation with the visible emitter — child llm_query subcalls,
257
- // turn progress, and cost deltas land on the per-invocation emitter, visible to
258
- // SubcallStore and the live visual tree.
259
- const runRlm = createEngine({
260
- model: rootModel,
261
- workerModel,
262
- registry: deps.registry,
263
- config: deps.config,
264
- signal: deps.signal,
265
- emitter: emitter,
266
- onUsage: deps.onUsage as ((usage: Usage, role: "root" | "sub") => void) | undefined,
267
- limits: {
268
- maxBudgetUsd: deps.config.maxBudgetUsd,
269
- maxTimeoutMs: deps.config.maxTimeoutMs,
270
- maxTokens: deps.config.maxTokens,
271
- maxErrors: deps.config.maxErrors,
272
- },
273
- onTodo: state.currentInteractive?.onTodo,
274
- onAskUserQuestion: state.currentInteractive?.onAskUserQuestion,
275
- });
276
-
277
- try {
278
- const res = await runRlm({
279
- rootPrompt: "",
280
- context: prompt,
281
- depth: childDepth,
282
- parentNodeId: subId,
283
- modelOverride: model ?? undefined,
284
- remainingBudgetUsd: remBudget,
285
- remainingTimeoutMs: remTimeout,
286
- });
287
-
288
- // Debit parent limit guard for the entire child run.
289
- limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
290
-
291
- // Child engine emits live usage deltas via the shared emitter — SubcallStore
292
- // accumulates them. No final aggregate costUsd/tokens to prevent double-counting
293
- // (matches canonical rlm-query.ts:60-63).
294
- emitter.emitSubcallUpdated({
295
- id: subId,
296
- status: "done",
297
- resultPreview: res.answer.slice(0, 200),
298
- });
299
-
300
- return res.answer;
301
- } catch (err) {
302
- const msg = errorMessage(err);
303
- emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
304
- return formatError(`child RLM failed - ${msg}`);
305
- }
306
- }
307
-
308
- return {
309
- rlmQuery: rlmQueryImpl,
310
- rlmQueryBatched: (prompts, model, depth) =>
311
- mapPool(prompts, deps.config.maxConcurrentSubcalls, (p) => rlmQueryImpl(p, model, depth)),
312
- };
121
+ /** Remaining budget/timeout of the invocation that currently owns the exec slot. */
122
+ remainingBudget(): RemainingResources | undefined {
123
+ const limits = this.currentLimits;
124
+ if (!limits) return undefined;
125
+ return { budgetUsd: limits.remainingBudgetUsd(), timeoutMs: limits.remainingTimeoutMs() };
313
126
  }
314
127
  }
315
128
 
@@ -322,7 +135,8 @@ export interface ReplToolDeps {
322
135
  readonly getModel?: () => Model<Api> | undefined;
323
136
  readonly getWorkerModel?: () => Model<Api> | undefined;
324
137
  readonly registry: ModelRegistry;
325
- readonly config: RlmConfig;
138
+ /** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
139
+ readonly getConfig: () => RlmConfig;
326
140
  readonly signal?: AbortSignal;
327
141
  readonly onUsage?: (usage: Usage, role: "sub") => void;
328
142
  readonly ensureContext?: () => Promise<void>;
@@ -331,40 +145,64 @@ export interface ReplToolDeps {
331
145
  }
332
146
 
333
147
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
334
- const { sandboxManager, workerModel, registry, config, signal, onUsage } = deps;
148
+ const { sandboxManager, workerModel, registry, getConfig, signal, onUsage } = deps;
335
149
  const bridgeState = new NativeBridgeState();
336
150
 
337
151
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
338
152
  let sessionCwd = process.cwd();
339
153
 
340
- // Build handlers once llm/rlm/library use late-bound deps so the same closures stay correct
341
- // across repl() calls; counter resets when the sandbox is discarded and re-spawned.
342
- const llmHandlers = bridgeState.buildLlmHandlers({
343
- workerModel,
344
- getWorkerModel: deps.getWorkerModel,
154
+ const rootModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
155
+
156
+ // Build handlers once — llm/rlm/library read late-bound state so the same closures stay
157
+ // correct across repl() calls; counters reset when the sandbox is discarded and re-spawned.
158
+ const llmHandlers = createLlmBridge({
159
+ workerModel: () => deps.getWorkerModel?.() ?? workerModel,
345
160
  registry,
346
- maxPromptChars: config.maxPromptChars,
347
- maxConcurrent: config.maxConcurrentSubcalls,
348
- sampling: config.subSampling,
349
- subSystem: config.subSystemPrompt,
161
+ config: getConfig,
350
162
  signal,
163
+ onUsage: (usage) => { bridgeState.currentLimits?.addUsage(usage); },
164
+ remainingBudget: () => bridgeState.remainingBudget(),
165
+ emitter: () => bridgeState.currentEmitter ?? undefined,
166
+ parentId: () => bridgeState.currentParentId,
167
+ depth: () => bridgeState.currentDepth,
351
168
  });
352
169
 
353
- // Real recursive rlm_query via createEngine — each call spawns a child RLM
354
- // with its own sandbox and turn loop, not a flat one-shot llm_query.
355
- const rlmHandlers = bridgeState.buildRlmHandlers({
356
- model: deps.model,
357
- workerModel,
358
- getModel: deps.getModel,
359
- getWorkerModel: deps.getWorkerModel,
360
- registry,
361
- config,
362
- signal,
363
- onUsage,
364
- llmHandlers,
170
+ // Real recursive rlm_query — each call spawns a child RLM with its own sandbox and turn
171
+ // loop, bound to the *current* invocation's emitter so child sub-calls, turn progress, and
172
+ // cost deltas land on the live visual tree.
173
+ const runChildRlm: RunRlm = (input) => {
174
+ const emitter = bridgeState.currentEmitter;
175
+ // Only reachable while an invocation owns the exec slot, which always swaps in an emitter.
176
+ if (!emitter) throw new Error("RLM bridge not wired for this invocation");
177
+ const config = getConfig();
178
+ return createEngine({
179
+ model: rootModel(),
180
+ workerModel: deps.getWorkerModel?.() ?? workerModel,
181
+ registry,
182
+ config,
183
+ signal,
184
+ emitter,
185
+ onUsage: onUsage === undefined ? undefined : (usage, role) => { if (role === "sub") onUsage(usage, role); },
186
+ limits: limitsFromConfig(config),
187
+ onTodo: bridgeState.currentInteractive?.onTodo,
188
+ onAskUserQuestion: bridgeState.currentInteractive?.onAskUserQuestion,
189
+ })(input);
190
+ };
191
+
192
+ const rlmHandlers = createRlmHandlers({
193
+ run: runChildRlm,
194
+ llm: llmHandlers,
195
+ config: getConfig,
196
+ modelLabel: (override) => displayModelRef(registry, override, rootModel()),
197
+ emitter: () => bridgeState.currentEmitter ?? undefined,
198
+ parentNodeId: () => bridgeState.currentParentId,
199
+ remainingBudget: () => bridgeState.remainingBudget(),
200
+ onChildUsage: (costUsd, inputTokens, outputTokens) => {
201
+ bridgeState.currentLimits?.addRaw(costUsd, inputTokens, outputTokens);
202
+ },
365
203
  });
366
204
 
367
- const libraryBundle = config.libraryLoader
205
+ const libraryBundle = getConfig().libraryLoader
368
206
  ? buildLibraryHandler({
369
207
  getCwd: () => sessionCwd,
370
208
  getEmitter: () => bridgeState.currentEmitter,
@@ -380,11 +218,19 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
380
218
  label: "REPL",
381
219
  description:
382
220
  "PRIMARY tool for ALL repository reading and analysis (read/grep are disabled in RLM mode). " +
383
- "Persistent Python sandbox with every file pre-loaded in `context`. You are an orchestrator: " +
384
- "chunk `context` and delegate semantic work to llm_query / llm_query_batched / " +
385
- "llm_query_chunked / rlm_query — stdout returned to you is hard-capped at 4K chars, so " +
386
- "printing file bodies is useless. Variables, imports, and state persist across calls. " +
387
- "Also supports todo, ask_user_question, and load_library inside the sandbox.",
221
+ "Persistent Python sandbox with every file pre-loaded in `context`. Locate first with the " +
222
+ "free primitives search(query) / grep_context(pattern) / outline(path), then delegate the " +
223
+ "semantic reading to map_files / llm_query / llm_query_batched / llm_query_chunked " +
224
+ "(rlm_query for iterative sub-tasks) stdout returned to you is hard-capped at 4K chars, " +
225
+ "so printing file bodies is useless. Variables, imports, and the `answers`/`plan` memo " +
226
+ "persist across calls. Also supports todo, ask_user_question, and load_library.",
227
+ promptSnippet:
228
+ "repl: run Python in a persistent sandbox holding the whole repository in `context`; " +
229
+ "search/grep_context/outline to locate, map_files/llm_query* to read.",
230
+ promptGuidelines: [
231
+ "In RLM mode, read the repository through `repl` only — `read`/`grep` and bash readers are blocked.",
232
+ "Inside `repl`, locate with search()/grep_context()/outline() before delegating bulk reading to map_files()/llm_query_batched().",
233
+ ],
388
234
  parameters: ReplToolParams,
389
235
 
390
236
  async execute(_toolCallId, rawParams, _execSignal, onUpdate, ctx) {
@@ -405,12 +251,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
405
251
  let capturedStderr = "";
406
252
  let progressStatus: ReplDetails["status"] = "running";
407
253
  const startedAt = Date.now();
408
- const limits = new LimitGuard({
409
- maxBudgetUsd: config.maxBudgetUsd,
410
- maxTimeoutMs: config.maxTimeoutMs,
411
- maxTokens: config.maxTokens,
412
- maxErrors: config.maxErrors,
413
- });
254
+ const limits = new LimitGuard(limitsFromConfig(getConfig()));
414
255
 
415
256
  // ── Progressive rendering: spinner + live sub-call tree ──
416
257
  const progress = createProgressNotifier<ReplDetails>({
@@ -442,7 +283,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
442
283
  // Build interactive handlers (session-stable callbacks)
443
284
  const interactive = createPiInteractiveDeps(ctx);
444
285
  const interactiveHandlers = buildInteractiveHandlers({
445
- onAskUserQuestion: config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
286
+ onAskUserQuestion: getConfig().askUserQuestion ? interactive.onAskUserQuestion : undefined,
446
287
  onTodo: interactive.onTodo,
447
288
  onTodoRow: undefined,
448
289
  emitter,
@@ -538,10 +379,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
538
379
  },
539
380
 
540
381
  renderCall(args, theme) {
541
- const preview = args.code.length > 80 ? `${args.code.slice(0, 80)}...` : args.code;
542
382
  return new Text(
543
- theme.fg("toolTitle", theme.bold("repl ")) +
544
- theme.fg("dim", preview.replace(/\n/g, " ")),
383
+ theme.fg("toolTitle", theme.bold("repl ")) + theme.fg("dim", previewText(args.code, CALL_PREVIEW_CHARS)),
545
384
  0, 0,
546
385
  );
547
386
  },
@@ -560,26 +399,13 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
560
399
 
561
400
  // ── Collapsed view ──
562
401
 
563
- function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
564
- const glyph = details.status === "running"
565
- ? headlineStatusGlyph("running", theme)
566
- : details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
567
-
568
- const parts: string[] = [];
569
- parts.push(formatCost(details.totals.costUsd));
570
- if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
571
- if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
572
- const stats = parts.length > 0 ? ` ${theme.fg("dim", parts.join(" · "))}` : "";
573
-
574
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`;
575
-
576
- let body = "";
577
- if (details.subcalls.length > 0) {
578
- body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
579
- }
402
+ function replStats(details: ReplDetails, theme: Theme): string {
403
+ const elapsed = details.executionTimeMs > 0 ? `${details.executionTimeMs}ms` : undefined;
404
+ return cardStatsLine(details.totals, theme, elapsed);
405
+ }
580
406
 
581
- const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
582
- return new Text(`${header}${body}${expandHint}`, 0, 0);
407
+ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
408
+ return renderCollapsedCard("REPL", details.status, replStats(details, theme), details.subcalls, theme);
583
409
  }
584
410
 
585
411
  // ── Expanded view ──
@@ -587,19 +413,14 @@ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
587
413
  function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
588
414
  const container = new Container();
589
415
 
590
- // Header: status + stats
591
- const glyph = details.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓");
592
- const parts: string[] = [];
593
- parts.push(formatCost(details.totals.costUsd));
594
- if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
595
- if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
596
- const stats = parts.length > 0 ? ` · ${theme.fg("dim", parts.join(" · "))}` : "";
597
- container.addChild(new Text(`${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`, 0, 0));
416
+ container.addChild(new Text(cardHeader("REPL", details.status, replStats(details, theme), theme), 0, 0));
598
417
 
599
418
  // Output
600
419
  if (details.output) {
601
420
  container.addChild(new Spacer(1));
602
- const out = details.output.length > 2000 ? `${details.output.slice(0, 2000)}...` : details.output;
421
+ const out = details.output.length > EXPANDED_STDOUT_CHARS
422
+ ? `${details.output.slice(0, EXPANDED_STDOUT_CHARS)}…`
423
+ : details.output;
603
424
  container.addChild(new Text(out, 0, 0));
604
425
  }
605
426
 
@@ -611,7 +432,7 @@ function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
611
432
  // Stderr
612
433
  if (details.stderr) {
613
434
  container.addChild(new Spacer(1));
614
- container.addChild(new Text(theme.fg("error", details.stderr.slice(0, 500)), 0, 0));
435
+ container.addChild(new Text(theme.fg("error", details.stderr.slice(0, EXPANDED_STDERR_CHARS)), 0, 0));
615
436
  }
616
437
 
617
438
  // Sub-call tree
@@ -44,13 +44,3 @@ export interface RlmDetails {
44
44
  readonly warnings?: readonly string[];
45
45
  }
46
46
 
47
- export interface SubcallInit {
48
- readonly parentId?: string;
49
- readonly kind: SubcallKind;
50
- readonly label: string;
51
- readonly model?: string;
52
- readonly detail?: string;
53
- readonly args?: string;
54
- /** Recursion depth. Required — all call sites pass this. */
55
- readonly depth: number;
56
- }
@@ -5,23 +5,29 @@
5
5
  * onUpdate(partialResult) for progressive TUI re-rendering.
6
6
  */
7
7
 
8
- import { getMarkdownTheme, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
8
+ import { type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
10
10
  import { Type } from "typebox";
11
11
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
12
12
  import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
13
- import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
13
+ import { spinnerFrame } from "../ui/theme.ts";
14
+ import { markdownTheme } from "../ui/theme-adapter.ts";
15
+ import { previewText } from "../text/preview.ts";
14
16
  import { errorMessage } from "../util/errors.ts";
15
17
  import { type RlmDetails } from "./rlm-details.ts";
16
18
  import { RlmEmitter } from "./rlm-events.ts";
17
19
  import { RlmEventAggregator } from "./rlm-aggregator.ts";
18
20
  import {
19
- headlineStatusGlyph,
20
- renderCollapsedSubcallTree,
21
+ cardHeader,
22
+ cardStatsLine,
23
+ renderCollapsedCard,
21
24
  renderExpandedSubcallTree,
22
25
  } from "./subcall-render.ts";
23
26
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
24
27
 
28
+ /** Chars of the prompt shown on the tool call line. */
29
+ const CALL_PREVIEW_CHARS = 80;
30
+
25
31
  // ── Parameter schema ──
26
32
 
27
33
  export const RlmToolParams = Object.freeze(Type.Object({
@@ -32,11 +38,8 @@ export const RlmToolParams = Object.freeze(Type.Object({
32
38
  // ── Rendering helpers ──
33
39
 
34
40
  function rootStats(details: RlmDetails, theme: Theme): string {
35
- const parts: string[] = [];
36
- parts.push(formatCost(details.totals.costUsd));
37
- parts.push(`${formatTokens(details.totals.tokens)} tok`);
38
- if (details.turns.current > 0) parts.push(`${details.turns.current} turn${details.turns.current > 1 ? "s" : ""}`);
39
- return theme.fg("dim", parts.join(" · "));
41
+ const turns = details.turns.current;
42
+ return cardStatsLine(details.totals, theme, turns > 0 ? `${turns} turn${turns > 1 ? "s" : ""}` : undefined);
40
43
  }
41
44
 
42
45
  // ── Tool definition ──
@@ -110,18 +113,14 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
110
113
  }
111
114
  },
112
115
 
113
- renderCall(args, theme, _context) {
114
- const preview = args.prompt.length > 80
115
- ? `${args.prompt.slice(0, 80)}...`
116
- : args.prompt;
116
+ renderCall(args, theme) {
117
117
  return new Text(
118
- theme.fg("toolTitle", theme.bold("rlm ")) +
119
- theme.fg("dim", preview.replace(/\n/g, " ")),
118
+ theme.fg("toolTitle", theme.bold("rlm ")) + theme.fg("dim", previewText(args.prompt, CALL_PREVIEW_CHARS)),
120
119
  0, 0,
121
120
  );
122
121
  },
123
122
 
124
- renderResult(result, { expanded, isPartial: _isPartial }, theme, _context) {
123
+ renderResult(result, { expanded }, theme) {
125
124
  const details = result.details as RlmDetails | undefined;
126
125
  if (!details) {
127
126
  const text = result.content[0];
@@ -139,10 +138,7 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
139
138
 
140
139
  function renderExpanded(details: RlmDetails, theme: Theme): Component {
141
140
  const container = new Container();
142
-
143
- const glyph = headlineStatusGlyph(details.status, theme);
144
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("RLM"))} · ${rootStats(details, theme)}`;
145
- container.addChild(new Text(header, 0, 0));
141
+ container.addChild(new Text(cardHeader("RLM", details.status, rootStats(details, theme), theme), 0, 0));
146
142
 
147
143
  if (details.subcalls.length > 0) {
148
144
  container.addChild(new Spacer(1));
@@ -153,7 +149,7 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
153
149
  if (details.answer) {
154
150
  container.addChild(new Spacer(1));
155
151
  container.addChild(new Text(theme.fg("muted", "─── Answer ───"), 0, 0));
156
- container.addChild(new Markdown(details.answer, 0, 0, getMarkdownTheme()));
152
+ container.addChild(new Markdown(details.answer, 0, 0, markdownTheme(theme)));
157
153
  }
158
154
 
159
155
  if (details.warnings && details.warnings.length > 0) {
@@ -167,14 +163,5 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
167
163
  // ── Collapsed view ──
168
164
 
169
165
  function renderCollapsed(details: RlmDetails, theme: Theme): Text {
170
- const glyph = headlineStatusGlyph(details.status, theme);
171
- const header = `${glyph} ${theme.fg("toolTitle", theme.bold("RLM"))} · ${rootStats(details, theme)}`;
172
-
173
- let body = "";
174
- if (details.subcalls.length > 0) {
175
- body = `\n${renderCollapsedSubcallTree(details.subcalls, theme)}`;
176
- }
177
-
178
- const expandHint = details.status === "running" ? "" : `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
179
- return new Text(`${header}${body}${expandHint}`, 0, 0);
166
+ return renderCollapsedCard("RLM", details.status, rootStats(details, theme), details.subcalls, theme);
180
167
  }