@aryasaatvik/motel 0.3.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.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,43 @@
1
+ import { Effect, Layer, Context } from "effect"
2
+ import type { AiCallDetail, SpanItem, TraceItem, TraceSummaryItem } from "../domain.js"
3
+ import { TelemetryStore } from "./TelemetryStore.js"
4
+
5
+ /**
6
+ * Compatibility adapter for consumers importing the historical trace query module.
7
+ * New internal callers should use TelemetryStoreReadonly directly.
8
+ */
9
+ export class TraceQueryService extends Context.Service<
10
+ TraceQueryService,
11
+ {
12
+ readonly listServices: Effect.Effect<readonly string[], Error>
13
+ readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceItem[], Error>
14
+ readonly listTraceSummaries: (serviceName: string | null, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceSummaryItem[], Error>
15
+ readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly aiText?: string | null; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceSummaryItem[], Error>
16
+ readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly key?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
17
+ readonly searchTraces: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceItem[], Error>
18
+ readonly traceStats: (input: { readonly groupBy: string; readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"; readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
19
+ readonly getTrace: (traceId: string) => Effect.Effect<TraceItem | null, Error>
20
+ readonly getSpan: (spanId: string) => Effect.Effect<SpanItem | null, Error>
21
+ readonly getAiCall: (spanId: string) => Effect.Effect<AiCallDetail | null, Error>
22
+ readonly listTraceSpans: (traceId: string) => Effect.Effect<readonly SpanItem[], Error>
23
+ readonly searchSpans: (input: { readonly serviceName?: string | null; readonly traceId?: string | null; readonly operation?: string | null; readonly parentOperation?: string | null; readonly status?: "ok" | "error" | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly SpanItem[], Error>
24
+ }
25
+ >()("motel/TraceQueryService") {}
26
+
27
+ export const TraceQueryServiceLive = Layer.effect(
28
+ TraceQueryService,
29
+ Effect.map(TelemetryStore, (store) => TraceQueryService.of({
30
+ listServices: store.listServices,
31
+ listRecentTraces: store.listRecentTraces,
32
+ listTraceSummaries: store.listTraceSummaries,
33
+ searchTraceSummaries: store.searchTraceSummaries,
34
+ listFacets: store.listFacets,
35
+ searchTraces: store.searchTraces,
36
+ traceStats: store.traceStats,
37
+ getTrace: store.getTrace,
38
+ getSpan: store.getSpan,
39
+ getAiCall: store.getAiCall,
40
+ listTraceSpans: store.listTraceSpans,
41
+ searchSpans: store.searchSpans,
42
+ })),
43
+ )
@@ -0,0 +1,39 @@
1
+ /**
2
+ * RPC contract for OTLP ingest. Lives in its own file so both the
3
+ * main thread (client) and the telemetry worker (server) can import
4
+ * the schema without pulling in each other's runtime code.
5
+ *
6
+ * Only ingestTraces and ingestLogs run through RPC — those are the
7
+ * methods whose SQLite writes used to block the main event loop for
8
+ * seconds at a time. Read-only TelemetryStore methods use the separate
9
+ * query RPC worker so no synchronous SQLite call runs on the HTTP thread.
10
+ *
11
+ * Payloads are typed as Schema.Unknown because OTLP's protobuf-JSON
12
+ * shape is enormous and nested — the store validates structurally
13
+ * during the actual insert loop, and serialising a schema through
14
+ * the worker boundary would add overhead that beats the purpose of
15
+ * the offload. If a payload is malformed we surface it as an
16
+ * IngestError rather than a RpcSchemaError, which keeps the failure
17
+ * mode consistent with the old direct-call behaviour.
18
+ */
19
+
20
+ import { Schema } from "effect"
21
+ import * as Rpc from "effect/unstable/rpc/Rpc"
22
+ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
23
+
24
+ export class IngestError extends Schema.TaggedErrorClass<IngestError>()("IngestError", {
25
+ message: Schema.String,
26
+ }) {}
27
+
28
+ export const IngestRpcs = RpcGroup.make(
29
+ Rpc.make("ingestTraces", {
30
+ payload: { payload: Schema.Unknown },
31
+ success: Schema.Struct({ insertedSpans: Schema.Number }),
32
+ error: IngestError,
33
+ }),
34
+ Rpc.make("ingestLogs", {
35
+ payload: { payload: Schema.Unknown },
36
+ success: Schema.Struct({ insertedLogs: Schema.Number }),
37
+ error: IngestError,
38
+ }),
39
+ )
@@ -0,0 +1,15 @@
1
+ import { Schema } from "effect"
2
+ import * as Rpc from "effect/unstable/rpc/Rpc"
3
+ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
4
+
5
+ export class QueryError extends Schema.TaggedErrorClass<QueryError>()("QueryError", {
6
+ message: Schema.String,
7
+ }) {}
8
+
9
+ export const QueryRpcs = RpcGroup.make(
10
+ Rpc.make("query", {
11
+ payload: { method: Schema.String, args: Schema.Array(Schema.Unknown) },
12
+ success: Schema.Any,
13
+ error: QueryError,
14
+ }),
15
+ )
@@ -0,0 +1,32 @@
1
+ import { BunRuntime } from "@effect/platform-bun"
2
+ import * as BunWorkerRunner from "@effect/platform-bun/BunWorkerRunner"
3
+ import { Effect, Layer } from "effect"
4
+ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
5
+ import * as RpcServer from "effect/unstable/rpc/RpcServer"
6
+ import { TelemetryStoreQueryWorkerLive, TelemetryStoreReadonly, type TelemetryStoreReader } from "./TelemetryStore.js"
7
+ import { QueryError, QueryRpcs } from "./queryRpc.js"
8
+
9
+ type QueryMethod = keyof TelemetryStoreReader
10
+
11
+ const QueryHandlers = QueryRpcs.toLayer(Effect.gen(function*() {
12
+ const store = yield* TelemetryStoreReadonly
13
+ return {
14
+ query: ({ method, args }) => {
15
+ const member = Reflect.get(store, method as QueryMethod) as unknown
16
+ const result = typeof member === "function" ? Reflect.apply(member, store, args) : member
17
+ return (result as Effect.Effect<unknown, Error>).pipe(
18
+ Effect.mapError((error) => new QueryError({ message: String(error) })),
19
+ )
20
+ },
21
+ }
22
+ }))
23
+
24
+ const WorkerLive = RpcServer.layer(QueryRpcs).pipe(
25
+ Layer.provide(QueryHandlers),
26
+ Layer.provide(TelemetryStoreQueryWorkerLive),
27
+ Layer.provide(RpcServer.layerProtocolWorkerRunner),
28
+ Layer.provide(RpcSerialization.layerMsgPack),
29
+ Layer.provide(BunWorkerRunner.layer),
30
+ )
31
+
32
+ Layer.launch(WorkerLive).pipe(BunRuntime.runMain)
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Worker-thread entry point for OTLP ingest.
3
+ *
4
+ * Spawned by the main process via `new Worker(new URL("./telemetryWorker.ts", import.meta.url))`.
5
+ * This file runs inside a Bun Worker, so anything it imports is
6
+ * evaluated in a FRESH module graph on the worker side. In particular
7
+ * `TelemetryStoreWorkerLive` opens its own SQLite connection here — the main
8
+ * thread's store connection is unrelated. SQLite's WAL journal mode
9
+ * lets writer and query-worker connections coexist against the same
10
+ * `.sqlite` file without blocking the HTTP event loop.
11
+ *
12
+ * The worker exposes only `ingestTraces` / `ingestLogs` (see ingestRpc.ts)
13
+ * and owns writer maintenance. Read queries run in telemetryQueryWorker.ts;
14
+ * neither path can block the HTTP event loop.
15
+ */
16
+
17
+ import { BunRuntime } from "@effect/platform-bun"
18
+ import * as BunWorkerRunner from "@effect/platform-bun/BunWorkerRunner"
19
+ import { Effect, Layer } from "effect"
20
+ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
21
+ import * as RpcServer from "effect/unstable/rpc/RpcServer"
22
+ import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "../otlp.ts"
23
+ import { IngestError, IngestRpcs } from "./ingestRpc.ts"
24
+ import { TelemetryStore, TelemetryStoreWorkerLive } from "./TelemetryStore.ts"
25
+
26
+ // Wire the two RPC methods to the existing TelemetryStore service.
27
+ // The store's ingest methods already carry their own Effect.fn spans,
28
+ // so the worker-side traces show up correctly attributed — the RPC
29
+ // framework also auto-spans each incoming request with method +
30
+ // payload-size attributes, giving us visibility into how ingest is
31
+ // splitting its time across the queue / wire / SQL stages.
32
+ const IngestHandlers = IngestRpcs.toLayer(
33
+ Effect.gen(function*() {
34
+ const store = yield* TelemetryStore
35
+ return {
36
+ ingestTraces: ({ payload }) =>
37
+ store.ingestTraces(payload as OtlpTraceExportRequest).pipe(
38
+ Effect.mapError((cause) => new IngestError({ message: String(cause) })),
39
+ ),
40
+ ingestLogs: ({ payload }) =>
41
+ store.ingestLogs(payload as OtlpLogExportRequest).pipe(
42
+ Effect.mapError((cause) => new IngestError({ message: String(cause) })),
43
+ ),
44
+ }
45
+ }),
46
+ )
47
+
48
+ const WorkerLive = RpcServer.layer(IngestRpcs).pipe(
49
+ Layer.provide(IngestHandlers),
50
+ Layer.provide(TelemetryStoreWorkerLive),
51
+ Layer.provide(RpcServer.layerProtocolWorkerRunner),
52
+ Layer.provide(RpcSerialization.layerMsgPack),
53
+ Layer.provide(BunWorkerRunner.layer),
54
+ )
55
+
56
+ // BunRuntime.runMain installs signal handlers so the scope closes
57
+ // cleanly on termination; the BunHttpServer layer pattern from the
58
+ // main server carries over here.
59
+ Layer.launch(WorkerLive).pipe(BunRuntime.runMain)
@@ -0,0 +1,19 @@
1
+ const EPOCH_KEY = "__motelStartupEpoch"
2
+
3
+ type GlobalWithStartupEpoch = typeof globalThis & {
4
+ [EPOCH_KEY]?: number
5
+ }
6
+
7
+ const globalWithEpoch = globalThis as GlobalWithStartupEpoch
8
+
9
+ if (globalWithEpoch[EPOCH_KEY] === undefined) {
10
+ globalWithEpoch[EPOCH_KEY] = performance.now()
11
+ }
12
+
13
+ export const startupBenchEnabled = process.env.MOTEL_BENCH_STARTUP_PHASES === "1"
14
+
15
+ export const startupBenchMark = (phase: string) => {
16
+ if (!startupBenchEnabled) return
17
+ const elapsedMs = performance.now() - (globalWithEpoch[EPOCH_KEY] ?? performance.now())
18
+ process.stderr.write(`[motel-startup] ${phase} ${elapsedMs.toFixed(3)}ms\n`)
19
+ }
@@ -0,0 +1,57 @@
1
+ import { Effect } from "effect"
2
+ import { createDaemonManager, type DaemonStatus } from "./daemon.js"
3
+ import { createLaunchAgentManager, createMotelLifecycle, usesLaunchAgentRuntime, type LaunchAgentManager, type MotelLifecycle } from "./launchAgent.js"
4
+ import { MOTEL_SERVICE_ID } from "./registry.js"
5
+
6
+ export type ConflictStatus = DaemonStatus & {
7
+ readonly service: typeof MOTEL_SERVICE_ID
8
+ readonly pid: number
9
+ readonly workdir: string
10
+ readonly reason: string
11
+ }
12
+
13
+ export const startDaemon = (lifecycle: MotelLifecycle = createMotelLifecycle()) => Effect.runPromise(lifecycle.start)
14
+
15
+ const parsePort = (url: string) => {
16
+ try {
17
+ const port = Number(new URL(url).port)
18
+ return Number.isFinite(port) && port > 0 ? port : undefined
19
+ } catch {
20
+ return undefined
21
+ }
22
+ }
23
+
24
+ export const stopConflictingDaemon = async (
25
+ status: ConflictStatus,
26
+ options: {
27
+ readonly service?: LaunchAgentManager
28
+ readonly createManager?: typeof createDaemonManager
29
+ } = {},
30
+ ) => {
31
+ const service = options.service ?? createLaunchAgentManager()
32
+ if (usesLaunchAgentRuntime() && service.available) {
33
+ const serviceStatus = await Effect.runPromise(service.status).catch((error) => {
34
+ throw new Error(`Refusing detached recovery until Motel LaunchAgent ownership can be inspected: ${error instanceof Error ? error.message : String(error)}`)
35
+ })
36
+ if (serviceStatus?.manager === "loaded" && serviceStatus.health.pid === status.pid) {
37
+ await Effect.runPromise(service.stop)
38
+ return
39
+ }
40
+ if (serviceStatus?.manager === "loaded") {
41
+ throw new Error("Refusing detached recovery while Motel LaunchAgent is loaded for a different process.")
42
+ }
43
+ if (serviceStatus?.manager === "unknown") {
44
+ throw new Error("Refusing detached recovery until Motel LaunchAgent ownership can be inspected reliably.")
45
+ }
46
+ if (serviceStatus?.configuration !== "missing") {
47
+ throw new Error("Refusing detached recovery while a Motel LaunchAgent definition is present.")
48
+ }
49
+ }
50
+ const port = parsePort(status.url)
51
+ const manager = (options.createManager ?? createDaemonManager)({
52
+ workdir: status.workdir ?? undefined,
53
+ databasePath: status.databasePath,
54
+ port,
55
+ })
56
+ await Effect.runPromise(manager.stop)
57
+ }
@@ -0,0 +1,244 @@
1
+ // Storybook-style preview for AiChatView. Renders the component
2
+ // against a menu of fixtures so we can iterate on the rendering
3
+ // without needing real LLM traffic captured.
4
+ //
5
+ // Run it: `bun run story:chat`
6
+ // Keys:
7
+ // 1..N switch fixture
8
+ // j / k scroll down / up (1 line)
9
+ // ctrl-d/u half-page
10
+ // gg / G jump to top / bottom
11
+ // r force re-render with a fresh date (to sanity-check headers)
12
+ // q / ^c quit
13
+
14
+ import { RegistryProvider } from "@effect/atom-react"
15
+ import { RGBA, TextAttributes, createCliRenderer } from "@opentui/core"
16
+ import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
17
+ import { useEffect, useMemo, useRef, useState } from "react"
18
+ import { buildChunks, type Chunk } from "../ui/aiChatModel.ts"
19
+ import { AiChatView } from "../ui/AiChatView.tsx"
20
+ import { Divider, TextLine } from "../ui/primitives.tsx"
21
+ import type { AiCallDetailState } from "../ui/aiState.ts"
22
+ import { applyTheme, colors, SEPARATOR } from "../ui/theme.ts"
23
+ import type { ChatFixture } from "./fixtures/index.ts"
24
+ import { errorFixture } from "./fixtures/errorState.ts"
25
+ import { imagePasteFixture } from "./fixtures/imagePaste.ts"
26
+ import { kitchenSinkFixture } from "./fixtures/kitchenSink.ts"
27
+ import { rawPromptFixture } from "./fixtures/rawPrompt.ts"
28
+ import { shortFixture } from "./fixtures/short.ts"
29
+ import { toolHeavyFixture } from "./fixtures/toolHeavy.ts"
30
+
31
+ // Kitchen-sink first so launching the story lands on something that
32
+ // shows every rendering branch at once. The other fixtures exercise
33
+ // one case at a time for regression testing.
34
+ const FIXTURES: readonly ChatFixture[] = [
35
+ kitchenSinkFixture,
36
+ shortFixture,
37
+ toolHeavyFixture,
38
+ imagePasteFixture,
39
+ rawPromptFixture,
40
+ errorFixture,
41
+ ]
42
+
43
+ const HEADER_ROWS = 2
44
+ const FOOTER_ROWS = 1
45
+
46
+ const StoryApp = () => {
47
+ applyTheme("motel-default")
48
+ const renderer = useRenderer()
49
+ const { width, height } = useTerminalDimensions()
50
+ const w = width ?? 120
51
+ const h = height ?? 32
52
+ const [fixtureIdx, setFixtureIdx] = useState(0)
53
+ const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null)
54
+ const [detailChunkId, setDetailChunkId] = useState<string | null>(null)
55
+ const [detailScrollOffset, setDetailScrollOffset] = useState(0)
56
+ const pendingGRef = useRef(false)
57
+ const quittingRef = useRef(false)
58
+
59
+ const fixture = FIXTURES[fixtureIdx] ?? FIXTURES[0]!
60
+
61
+ const detailState: AiCallDetailState = useMemo(() => ({
62
+ status: "ready",
63
+ spanId: fixture.span.spanId,
64
+ data: fixture.detail,
65
+ error: null,
66
+ }), [fixture])
67
+
68
+ const chunks = useMemo<readonly Chunk[]>(() => buildChunks(fixture.detail), [fixture])
69
+
70
+ // Reset selection + expansion whenever fixture changes.
71
+ useEffect(() => {
72
+ setSelectedChunkId(chunks[0]?.id ?? null)
73
+ setDetailChunkId(null)
74
+ setDetailScrollOffset(0)
75
+ }, [fixtureIdx, chunks])
76
+
77
+ const move = (delta: number) => {
78
+ if (chunks.length === 0) return
79
+ const idx = selectedChunkId ? chunks.findIndex((c) => c.id === selectedChunkId) : 0
80
+ const next = chunks[Math.max(0, Math.min(idx + delta, chunks.length - 1))]
81
+ if (next) setSelectedChunkId(next.id)
82
+ }
83
+
84
+ useKeyboard((key) => {
85
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
86
+ // renderer.destroy() runs the opentui teardown (disables
87
+ // mouse tracking / kitty keyboard / bracketed paste / alt
88
+ // screen) before onDestroy exits the process. Bypassing it
89
+ // with a raw process.exit leaks those escape sequences into
90
+ // the host shell.
91
+ if (quittingRef.current) return
92
+ quittingRef.current = true
93
+ renderer.destroy()
94
+ return
95
+ }
96
+ if (/^[1-9]$/.test(key.name) && !key.ctrl && !key.meta) {
97
+ const idx = parseInt(key.name, 10) - 1
98
+ if (idx < FIXTURES.length) setFixtureIdx(idx)
99
+ return
100
+ }
101
+ if (key.name === "j" || key.name === "down") {
102
+ if (detailChunkId) setDetailScrollOffset((current) => current + 1)
103
+ else move(1)
104
+ return
105
+ }
106
+ if (key.name === "k" || key.name === "up") {
107
+ if (detailChunkId) setDetailScrollOffset((current) => Math.max(0, current - 1))
108
+ else move(-1)
109
+ return
110
+ }
111
+ if (key.name === "pagedown" || (key.ctrl && key.name === "d")) {
112
+ if (detailChunkId) setDetailScrollOffset((current) => current + Math.max(1, Math.floor(bodyLines / 2)))
113
+ else move(Math.max(1, Math.floor(chunks.length / 4)))
114
+ return
115
+ }
116
+ if (key.name === "pageup" || (key.ctrl && key.name === "u")) {
117
+ if (detailChunkId) setDetailScrollOffset((current) => Math.max(0, current - Math.max(1, Math.floor(bodyLines / 2))))
118
+ else move(-Math.max(1, Math.floor(chunks.length / 4)))
119
+ return
120
+ }
121
+ if (key.name === "g" && !key.shift) {
122
+ if (detailChunkId) {
123
+ if (pendingGRef.current) {
124
+ setDetailScrollOffset(0)
125
+ pendingGRef.current = false
126
+ } else {
127
+ pendingGRef.current = true
128
+ setTimeout(() => { pendingGRef.current = false }, 500)
129
+ }
130
+ return
131
+ }
132
+ if (pendingGRef.current) {
133
+ if (chunks[0]) setSelectedChunkId(chunks[0].id)
134
+ pendingGRef.current = false
135
+ } else {
136
+ pendingGRef.current = true
137
+ setTimeout(() => { pendingGRef.current = false }, 500)
138
+ }
139
+ return
140
+ }
141
+ if (key.name === "g" && key.shift) {
142
+ if (detailChunkId) setDetailScrollOffset(999999)
143
+ else {
144
+ const last = chunks[chunks.length - 1]
145
+ if (last) setSelectedChunkId(last.id)
146
+ }
147
+ return
148
+ }
149
+ if (key.name === "escape") {
150
+ setDetailChunkId(null)
151
+ setDetailScrollOffset(0)
152
+ return
153
+ }
154
+ if (key.name === "return" || key.name === "enter") {
155
+ const chunk = chunks.find((c) => c.id === selectedChunkId)
156
+ if (chunk) {
157
+ setDetailChunkId(chunk.id)
158
+ setDetailScrollOffset(0)
159
+ }
160
+ return
161
+ }
162
+ })
163
+
164
+ const bodyLines = Math.max(4, h - HEADER_ROWS - FOOTER_ROWS - 4 /* AI_CHAT_HEADER_ROWS */)
165
+
166
+ // Short compact labels — a single TextLine truncates with "..." if
167
+ // it overflows the padded content width, so we keep labels tight and
168
+ // use a single separator between items.
169
+ const fixtureList = FIXTURES.map((f, i) => (
170
+ <span key={f.id}>
171
+ <span fg={i === fixtureIdx ? colors.accent : colors.muted} attributes={i === fixtureIdx ? TextAttributes.BOLD : undefined}>
172
+ {`${i + 1} ${f.label}`}
173
+ </span>
174
+ {i < FIXTURES.length - 1 ? <span fg={colors.separator}>{" \u00b7 "}</span> : null}
175
+ </span>
176
+ ))
177
+
178
+ // Header + divider live inside a paddingLeft/Right={1} box, so the
179
+ // real content width is `w - 2`. Divider must match or we get a
180
+ // mid-line "..." truncation.
181
+ const contentWidth = Math.max(8, w - 2)
182
+
183
+ return (
184
+ <box width={w} height={h} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
185
+ <box paddingLeft={1} paddingRight={1} height={HEADER_ROWS} flexDirection="column">
186
+ <TextLine>
187
+ <span fg={colors.muted} attributes={TextAttributes.BOLD}>AI CHAT</span>
188
+ <span fg={colors.separator}>{" \u00b7 "}</span>
189
+ {fixtureList}
190
+ </TextLine>
191
+ <Divider width={contentWidth} />
192
+ </box>
193
+ <AiChatView
194
+ span={fixture.span}
195
+ detailState={detailState}
196
+ chunks={chunks}
197
+ selectedChunkId={selectedChunkId}
198
+ onSelectChunk={(chunkId) => setSelectedChunkId(chunkId)}
199
+ detailChunkId={detailChunkId}
200
+ onOpenDetail={(chunkId) => {
201
+ setSelectedChunkId(chunkId)
202
+ setDetailChunkId(chunkId)
203
+ setDetailScrollOffset(0)
204
+ }}
205
+ onCloseDetail={() => {
206
+ setDetailChunkId(null)
207
+ setDetailScrollOffset(0)
208
+ }}
209
+ detailScrollOffset={detailScrollOffset}
210
+ onSetDetailScrollOffset={(updater) => setDetailScrollOffset(updater)}
211
+ contentWidth={Math.max(24, w - 2)}
212
+ bodyLines={bodyLines}
213
+ paneWidth={w}
214
+ />
215
+ <Divider width={contentWidth} />
216
+ <box paddingLeft={1} paddingRight={1} height={FOOTER_ROWS}>
217
+ <TextLine>
218
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>1-9</span>
219
+ <span fg={colors.muted}>{" fixture "}</span>
220
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>j/k</span>
221
+ <span fg={colors.muted}>{" move "}</span>
222
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>enter</span>
223
+ <span fg={colors.muted}>{" detail "}</span>
224
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>gg/G</span>
225
+ <span fg={colors.muted}>{" top/bottom "}</span>
226
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>q</span>
227
+ <span fg={colors.muted}>{" quit"}</span>
228
+ </TextLine>
229
+ </box>
230
+ </box>
231
+ )
232
+ }
233
+
234
+ const renderer = await createCliRenderer({
235
+ exitOnCtrlC: false,
236
+ screenMode: "alternate-screen",
237
+ onDestroy: () => { process.exit(0) },
238
+ })
239
+
240
+ createRoot(renderer).render(
241
+ <RegistryProvider>
242
+ <StoryApp />
243
+ </RegistryProvider>,
244
+ )
@@ -0,0 +1,44 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // A tool-call that errored mid-stream: finishReason === "error" and a
4
+ // tool-result whose output shape is irregular (object without the
5
+ // expected `type: "text"` wrapper). Covers graceful-degrade rendering.
6
+ export const errorFixture: ChatFixture = {
7
+ id: "error",
8
+ label: "error",
9
+ span: makeSpan({ status: "error", durationMs: 820 }),
10
+ detail: makeDetail({
11
+ status: "error",
12
+ finishReason: "error",
13
+ durationMs: 820,
14
+ promptMessages: {
15
+ messages: [
16
+ { role: "user", content: [{ type: "text", text: "Grab the latest commits from main." }] },
17
+ {
18
+ role: "assistant",
19
+ content: [
20
+ { type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git fetch origin main && git log --oneline -5 origin/main" } },
21
+ ],
22
+ },
23
+ {
24
+ role: "tool",
25
+ content: [
26
+ {
27
+ type: "tool-result",
28
+ toolCallId: "tc-1",
29
+ toolName: "bash",
30
+ output: { error: "HTTP 429 rate limited", code: 429, retryable: true },
31
+ },
32
+ ],
33
+ },
34
+ {
35
+ role: "assistant",
36
+ content: [
37
+ { type: "text", text: "The request was rate-limited. I'll retry after a delay — or you can rerun manually." },
38
+ ],
39
+ },
40
+ ],
41
+ },
42
+ responseText: null,
43
+ }),
44
+ }
@@ -0,0 +1,34 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // User pastes an image as a data URL alongside text. The renderer
4
+ // should replace the base64 blob with a compact `[data:image/png
5
+ // base64 NNk]` marker and leave the prose readable. Regression case
6
+ // for the "screen filled with base64" bug.
7
+ const base64Chunk = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAD".repeat(1000)
8
+
9
+ export const imagePasteFixture: ChatFixture = {
10
+ id: "image-paste",
11
+ label: "image",
12
+ span: makeSpan(),
13
+ detail: makeDetail({
14
+ promptMessages: {
15
+ messages: [
16
+ { role: "system", content: "You are a code review assistant." },
17
+ {
18
+ role: "user",
19
+ content: [
20
+ { type: "text", text: `I captured this screenshot of the bug: data:image/png;base64,${base64Chunk}\n\nCan you tell me what's off about the formatting here?` },
21
+ ],
22
+ },
23
+ {
24
+ role: "assistant",
25
+ content: [
26
+ { type: "text", text: "Based on the image, the top padding looks inconsistent between rows. Let me open the component to check." },
27
+ { type: "tool-call", toolCallId: "tc-1", toolName: "read", input: { filePath: "/src/ui/Row.tsx" } },
28
+ ],
29
+ },
30
+ ],
31
+ },
32
+ responseText: "The padding comes from two different tokens; we should unify them.",
33
+ }),
34
+ }
@@ -0,0 +1,62 @@
1
+ // Shared fixture type for the AI-chat storybook. Each fixture is a
2
+ // label + a synthetic span + a synthetic AI call detail, in the same
3
+ // shape the real server returns. Prefer hand-crafted minimal examples
4
+ // over real captures — they exercise renderer cases deliberately and
5
+ // don't leak session content into the repo.
6
+
7
+ import type { AiCallDetail, TraceSpanItem } from "../../domain.ts"
8
+
9
+ export interface ChatFixture {
10
+ readonly id: string
11
+ readonly label: string
12
+ readonly span: TraceSpanItem
13
+ readonly detail: AiCallDetail
14
+ }
15
+
16
+ export const makeSpan = (overrides: Partial<TraceSpanItem> = {}): TraceSpanItem => ({
17
+ spanId: "fixture-span-0000",
18
+ parentSpanId: null,
19
+ operationName: "ai.streamText",
20
+ serviceName: "storybook",
21
+ scopeName: "ai",
22
+ kind: "internal",
23
+ status: "ok",
24
+ startTime: new Date(),
25
+ durationMs: 2400,
26
+ isRunning: false,
27
+ depth: 0,
28
+ tags: {
29
+ "ai.operationId": "ai.streamText",
30
+ "ai.model.id": "claude-opus-4-7",
31
+ "ai.model.provider": "anthropic",
32
+ "ai.prompt.messages": "[]",
33
+ },
34
+ warnings: [],
35
+ events: [],
36
+ ...overrides,
37
+ })
38
+
39
+ export const makeDetail = (overrides: Partial<AiCallDetail> = {}): AiCallDetail => ({
40
+ traceId: "fixture-trace-0000",
41
+ spanId: "fixture-span-0000",
42
+ operation: "streamText",
43
+ service: "storybook",
44
+ functionId: "story.demo",
45
+ provider: "anthropic",
46
+ model: "claude-opus-4-7",
47
+ status: "ok",
48
+ startedAt: new Date().toISOString(),
49
+ durationMs: 2400,
50
+ sessionId: "ses_fixture",
51
+ userId: "kit",
52
+ finishReason: "stop",
53
+ promptMessages: null,
54
+ responseText: null,
55
+ toolCalls: [],
56
+ toolsAvailable: null,
57
+ providerMetadata: null,
58
+ usage: { inputTokens: 1234, outputTokens: 321, totalTokens: 1555, cachedInputTokens: 0, reasoningTokens: null },
59
+ timing: { msToFirstChunk: null, msToFinish: null, avgOutputTokensPerSecond: null },
60
+ logs: [],
61
+ ...overrides,
62
+ })