@llm4ts/runner 2.8.0 → 2.9.1

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/src/Terminal.ts CHANGED
@@ -85,6 +85,11 @@ export const terminalSafe = (value: string): string => {
85
85
 
86
86
  const Ansi = Object.freeze({
87
87
  reset: "\u001b[0m",
88
+ cyan: "\u001b[36m",
89
+ blue: "\u001b[34m",
90
+ magenta: "\u001b[35m",
91
+ yellow: "\u001b[33m",
92
+ greenBold: "\u001b[1;32m",
88
93
  boldMagenta: "\u001b[1;35m",
89
94
  green: "\u001b[32m",
90
95
  red: "\u001b[31m",
@@ -102,8 +107,12 @@ export interface TerminalPalette {
102
107
  readonly assistant: (text: string) => string
103
108
  readonly toolCall: (tool: string, args: string) => string
104
109
  readonly status: (frame: string, label: string) => string
110
+ /** A lane's tag, in the lane's own colour (`index` picks it). */
111
+ readonly lane: (index: number, text: string) => string
105
112
  }
106
113
 
114
+ const laneColours = [Ansi.cyan, Ansi.yellow, Ansi.blue, Ansi.greenBold, Ansi.magenta]
115
+
107
116
  export const makeTerminalPalette = (enabled: boolean): TerminalPalette => {
108
117
  const paint = (code: string, text: string): string =>
109
118
  enabled ? `${code}${text}${Ansi.reset}` : text
@@ -119,7 +128,8 @@ export const makeTerminalPalette = (enabled: boolean): TerminalPalette => {
119
128
  const head = paint(Ansi.yellowBold, `● ${tool}`)
120
129
  return args.length === 0 ? head : `${head} ${paint(Ansi.darkGray, `(${args})`)}`
121
130
  },
122
- status: (frame, label) => `${paint(Ansi.boldMagenta, frame)} ${label}`
131
+ status: (frame, label) => `${paint(Ansi.boldMagenta, frame)} ${label}`,
132
+ lane: (index, text) => paint(laneColours[index % laneColours.length] ?? Ansi.cyan, text)
123
133
  }
124
134
  }
125
135
 
@@ -222,9 +232,16 @@ const clearLine = "\r\u001b[2K"
222
232
 
223
233
  export interface TerminalOutput {
224
234
  readonly isTTY?: boolean
235
+ readonly columns?: number
225
236
  readonly write: (text: string) => unknown
226
237
  }
227
238
 
239
+ /** Cuts a plain status line to the terminal's width, so a wrapped line never breaks the redraw. */
240
+ export const fitToWidth = (line: string, columns: number | undefined): string =>
241
+ columns === undefined || columns < 8 || line.length <= columns - 2
242
+ ? line
243
+ : `${line.slice(0, columns - 3)}…`
244
+
228
245
  export const terminalSupportsColor = (
229
246
  output: TerminalOutput,
230
247
  environment: Readonly<Record<string, string | undefined>>
@@ -235,13 +252,20 @@ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLi
235
252
  process.stdout.write(text)
236
253
  },
237
254
  palette: TerminalPalette = makeTerminalPalette(true),
238
- tick: Duration.Input = "100 millis"
255
+ tick: Duration.Input = "100 millis",
256
+ columns: () => number | undefined = () => process.stdout.columns
239
257
  ): Effect.fn.Return<TerminalSurface, never, Scope.Scope> {
240
258
  const lock = yield* Semaphore.make(1)
241
259
  const status = yield* Ref.make<string | undefined>(undefined)
242
260
  const frame = yield* Ref.make(0)
243
261
  const suspended = yield* Ref.make(false)
262
+ // How many lines the status block took when last drawn: a multi-line
263
+ // block (one row per concurrent story) is cleared line by line upwards.
264
+ const drawn = yield* Ref.make(0)
244
265
  const emit = (text: string): Effect.Effect<void> => Effect.sync(() => write(text))
266
+ const clearStatus = Effect.flatMap(Ref.getAndSet(drawn, 0), (lines) =>
267
+ emit(lines <= 1 ? clearLine : `${clearLine}${`\u001b[1A${clearLine}`.repeat(lines - 1)}`)
268
+ )
245
269
  const drawStatus = Effect.gen(function* () {
246
270
  if (yield* Ref.get(suspended)) {
247
271
  return
@@ -249,24 +273,34 @@ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLi
249
273
  const label = yield* Ref.get(status)
250
274
  if (label !== undefined) {
251
275
  const currentFrame = yield* Ref.get(frame)
276
+ const width = columns()
277
+ const rows = label.split("\n")
252
278
  yield* emit(
253
- `${clearLine}${palette.status(spinnerFrames[currentFrame % spinnerFrames.length], label)}`
279
+ rows
280
+ .map((row, index) =>
281
+ palette.status(
282
+ index === 0 ? (spinnerFrames[currentFrame % spinnerFrames.length] ?? "·") : " ",
283
+ fitToWidth(row, width === undefined ? undefined : width - 2)
284
+ )
285
+ )
286
+ .join("\n")
254
287
  )
288
+ yield* Ref.set(drawn, rows.length)
255
289
  }
256
290
  })
257
291
  const surface: TerminalSurface = {
258
292
  palette,
259
293
  log: (line) =>
260
294
  lock.withPermit(
261
- emit(clearLine).pipe(Effect.andThen(emit(`${line}\n`)), Effect.andThen(drawStatus))
295
+ clearStatus.pipe(Effect.andThen(emit(`${line}\n`)), Effect.andThen(drawStatus))
262
296
  ),
263
297
  setStatus: (label) =>
264
298
  lock.withPermit(
265
- Ref.set(status, label).pipe(Effect.andThen(emit(clearLine)), Effect.andThen(drawStatus))
299
+ clearStatus.pipe(Effect.andThen(Ref.set(status, label)), Effect.andThen(drawStatus))
266
300
  ),
267
301
  suspend: (effect) =>
268
302
  lock
269
- .withPermit(Ref.set(suspended, true).pipe(Effect.andThen(emit(clearLine))))
303
+ .withPermit(Ref.set(suspended, true).pipe(Effect.andThen(clearStatus)))
270
304
  .pipe(
271
305
  Effect.andThen(effect),
272
306
  Effect.ensuring(
@@ -276,11 +310,11 @@ export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLi
276
310
  }
277
311
  yield* Effect.sleep(tick).pipe(
278
312
  Effect.andThen(Ref.update(frame, (current) => current + 1)),
279
- Effect.andThen(lock.withPermit(drawStatus)),
313
+ Effect.andThen(lock.withPermit(clearStatus.pipe(Effect.andThen(drawStatus)))),
280
314
  Effect.forever,
281
315
  Effect.forkScoped
282
316
  )
283
- yield* Effect.addFinalizer(() => lock.withPermit(emit(clearLine)))
317
+ yield* Effect.addFinalizer(() => lock.withPermit(clearStatus))
284
318
  return surface
285
319
  })
286
320
 
@@ -292,7 +326,7 @@ export const makeTerminalSurface = (
292
326
  output.write(text)
293
327
  }
294
328
  return terminalSupportsColor(output, environment)
295
- ? makeLiveTerminalSurface(write)
329
+ ? makeLiveTerminalSurface(write, makeTerminalPalette(true), "100 millis", () => output.columns)
296
330
  : Effect.succeed(makePlainTerminalSurface((line) => write(`${line}\n`)))
297
331
  }
298
332
 
@@ -316,6 +350,75 @@ interface OpenStage {
316
350
  readonly startedAt: number
317
351
  }
318
352
 
353
+ /** The lane an event belongs to (a concurrent story), if it carries one. */
354
+ export const laneOfEvent = (
355
+ event: FlowEvent
356
+ ): { readonly lane: string; readonly executor?: string } | undefined => {
357
+ switch (event._tag) {
358
+ case "StageStarted":
359
+ case "StageCompleted":
360
+ case "StageFailed":
361
+ case "Info":
362
+ case "ToolUse":
363
+ case "AssistantMessage":
364
+ case "TokensUsed":
365
+ return event.lane === undefined
366
+ ? undefined
367
+ : {
368
+ lane: event.lane,
369
+ ...(event.executor === undefined ? {} : { executor: event.executor })
370
+ }
371
+ default:
372
+ return undefined
373
+ }
374
+ }
375
+
376
+ /** `bonifici-list · lemonade-deepseek` — how a lane is named on screen. */
377
+ export const laneLabel = (lane: string, executor: string | undefined): string =>
378
+ executor === undefined ? lane : `${lane} · ${executor}`
379
+
380
+ /**
381
+ * Whether a laned event is printed as its own line. With several stories
382
+ * running, tool calls are counted on each story's status row instead;
383
+ * `Verbose` prints them all, tagged.
384
+ */
385
+ export const rendersLanedEvent = (verbosity: Verbosity, event: FlowEvent): boolean =>
386
+ event._tag === "ToolUse"
387
+ ? verbosity === "Verbose" || verbosity === "Debug"
388
+ : rendersEvent(verbosity, event)
389
+
390
+ interface LaneState {
391
+ readonly index: number
392
+ depth: number
393
+ stages: ReadonlyArray<OpenStage>
394
+ tools: number
395
+ executor: string | undefined
396
+ readonly startedAt: number
397
+ }
398
+
399
+ /** One status row per active lane, the run's own stage (if any) first. */
400
+ export const statusBlock = (
401
+ globalStage: string | undefined,
402
+ lanes: ReadonlyArray<{
403
+ readonly lane: string
404
+ readonly executor: string | undefined
405
+ readonly stage: string | undefined
406
+ readonly elapsedMs: number
407
+ readonly tools: number
408
+ }>
409
+ ): string | undefined => {
410
+ const rows = lanes
411
+ .filter((lane) => lane.stage !== undefined)
412
+ .map(
413
+ (lane) =>
414
+ `${laneLabel(lane.lane, lane.executor)} · ${lane.stage ?? ""} · ${formatDurationMs(lane.elapsedMs)}${
415
+ lane.tools === 0 ? "" : ` · ${lane.tools} tool call${lane.tools === 1 ? "" : "s"}`
416
+ }`
417
+ )
418
+ const block = [...(globalStage === undefined ? [] : [globalStage]), ...rows]
419
+ return block.length === 0 ? undefined : block.join("\n")
420
+ }
421
+
319
422
  export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume")(function* (
320
423
  events: FlowEventHub,
321
424
  surface: TerminalSurface,
@@ -324,6 +427,10 @@ export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume"
324
427
  ): Effect.fn.Return<TerminalConsumer, never, Scope.Scope> {
325
428
  const depth = yield* Ref.make(0)
326
429
  const stages = yield* Ref.make<ReadonlyArray<OpenStage>>([])
430
+ // Concurrent stories keep their own stage stacks: one shared stack
431
+ // interleaves their stages, closes the wrong one, and shows whichever
432
+ // story started last as the only thing running.
433
+ const lanes = new Map<string, LaneState>()
327
434
  const consumed = yield* Ref.make(0)
328
435
  const statsRef = yield* Ref.make<TerminalRunStats>({ stagesCompleted: 0, stagesFailed: 0 })
329
436
  const subscription = yield* events.subscribe
@@ -334,9 +441,97 @@ export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume"
334
441
  Effect.map((now) => `${palette.dim(new Date(now).toISOString().slice(11, 19))} `)
335
442
  )
336
443
  : Effect.succeed("")
444
+
445
+ const refreshStatus = Effect.gen(function* () {
446
+ const now = yield* Clock.currentTimeMillis
447
+ const global = (yield* Ref.get(stages)).at(-1)?.name
448
+ yield* surface.setStatus(
449
+ statusBlock(
450
+ global,
451
+ [...lanes.entries()].map(([lane, state]) => ({
452
+ lane,
453
+ executor: state.executor,
454
+ stage: state.stages.at(-1)?.name,
455
+ elapsedMs: now - state.startedAt,
456
+ tools: state.tools
457
+ }))
458
+ )
459
+ )
460
+ })
461
+
462
+ const countStage = (event: FlowEvent): Effect.Effect<void> =>
463
+ event._tag === "StageCompleted"
464
+ ? Ref.update(statsRef, (current) => ({
465
+ ...current,
466
+ stagesCompleted: current.stagesCompleted + 1
467
+ }))
468
+ : event._tag === "StageFailed"
469
+ ? Ref.update(statsRef, (current) => ({
470
+ ...current,
471
+ stagesFailed: current.stagesFailed + 1
472
+ }))
473
+ : Effect.void
474
+
475
+ const withDuration = (event: FlowEvent, line: string, closed: OpenStage | undefined) =>
476
+ Effect.gen(function* () {
477
+ if (
478
+ closed === undefined ||
479
+ (event._tag !== "StageCompleted" && event._tag !== "StageFailed")
480
+ ) {
481
+ return line
482
+ }
483
+ const now = yield* Clock.currentTimeMillis
484
+ return `${line} ${palette.dim(`(${formatDurationMs(now - closed.startedAt)})`)}`
485
+ })
486
+
337
487
  yield* Stream.fromSubscription(subscription).pipe(
338
488
  Stream.runForEach((event) =>
339
489
  Effect.gen(function* () {
490
+ const tagged = laneOfEvent(event)
491
+ if (tagged !== undefined) {
492
+ const now = yield* Clock.currentTimeMillis
493
+ const state =
494
+ lanes.get(tagged.lane) ??
495
+ ({
496
+ index: lanes.size,
497
+ depth: 0,
498
+ stages: [],
499
+ tools: 0,
500
+ executor: undefined,
501
+ startedAt: now
502
+ } satisfies LaneState)
503
+ lanes.set(tagged.lane, state)
504
+ if (tagged.executor !== undefined) {
505
+ state.executor = tagged.executor
506
+ }
507
+ let closed: OpenStage | undefined
508
+ if (closesChild(event)) {
509
+ state.depth = Math.max(0, state.depth - 1)
510
+ }
511
+ const lineDepth = state.depth
512
+ if (event._tag === "StageStarted") {
513
+ state.depth += 1
514
+ state.stages = [...state.stages, { name: terminalSafe(event.stage), startedAt: now }]
515
+ } else if (closesChild(event)) {
516
+ closed = state.stages.at(-1)
517
+ state.stages = state.stages.slice(0, -1)
518
+ yield* countStage(event)
519
+ } else if (event._tag === "ToolUse") {
520
+ state.tools += 1
521
+ }
522
+ if (state.stages.length === 0 && closesChild(event)) {
523
+ lanes.delete(tagged.lane)
524
+ }
525
+ if (rendersLanedEvent(verbosity, event)) {
526
+ const tag = palette.lane(state.index, `[${laneLabel(tagged.lane, state.executor)}]`)
527
+ const line = yield* withDuration(event, terminalLine(event, palette), closed)
528
+ const prefix = yield* timestampPrefix
529
+ yield* surface.log(`${prefix}${tag} ${indentBlock(lineDepth, line)}`)
530
+ }
531
+ yield* refreshStatus
532
+ yield* Ref.update(consumed, (count) => count + 1)
533
+ return
534
+ }
340
535
  const currentDepth = closesChild(event)
341
536
  ? yield* Ref.updateAndGet(depth, (value) => Math.max(0, value - 1))
342
537
  : yield* Ref.get(depth)
@@ -346,32 +541,15 @@ export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume"
346
541
  const active = terminalSafe(event.stage)
347
542
  const startedAt = yield* Clock.currentTimeMillis
348
543
  yield* Ref.update(stages, (current) => [...current, { name: active, startedAt }])
349
- yield* surface.setStatus(active)
544
+ yield* refreshStatus
350
545
  } else if (closesChild(event)) {
351
546
  closedStage = (yield* Ref.get(stages)).at(-1)
352
- const remaining = yield* Ref.updateAndGet(stages, (current) => current.slice(0, -1))
353
- yield* surface.setStatus(remaining.at(-1)?.name)
354
- if (event._tag === "StageCompleted") {
355
- yield* Ref.update(statsRef, (current) => ({
356
- ...current,
357
- stagesCompleted: current.stagesCompleted + 1
358
- }))
359
- } else if (event._tag === "StageFailed") {
360
- yield* Ref.update(statsRef, (current) => ({
361
- ...current,
362
- stagesFailed: current.stagesFailed + 1
363
- }))
364
- }
547
+ yield* Ref.update(stages, (current) => current.slice(0, -1))
548
+ yield* refreshStatus
549
+ yield* countStage(event)
365
550
  }
366
551
  if (rendersEvent(verbosity, event)) {
367
- let line = terminalLine(event, palette)
368
- if (
369
- closedStage !== undefined &&
370
- (event._tag === "StageCompleted" || event._tag === "StageFailed")
371
- ) {
372
- const now = yield* Clock.currentTimeMillis
373
- line = `${line} ${palette.dim(`(${formatDurationMs(now - closedStage.startedAt)})`)}`
374
- }
552
+ const line = yield* withDuration(event, terminalLine(event, palette), closedStage)
375
553
  const prefix = yield* timestampPrefix
376
554
  yield* surface.log(`${prefix}${indentBlock(currentDepth, line)}`)
377
555
  }