drexler 0.2.9 → 0.2.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.11
4
+
5
+ - Moved the live Deal Desk chrome into the startup panel on normal launches.
6
+ - Kept a standalone Deal Desk header for fast and no-intro launches.
7
+ - Added coverage for embedded startup-panel Deal Desk rendering.
8
+
9
+ ## 0.2.10
10
+
11
+ - Closed transcript turn blocks with right-side borders and corners so user and Drexler cards align with the input frame.
12
+ - Aligned the status row with the main chat chrome.
13
+
3
14
  ## 0.2.9
4
15
 
5
16
  - Removed transcript card side labels like `incoming memo` and `response ledger`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "CLI chat with Drexler, a corporate-executive AI persona built on OpenRouter Gemma 4 31B.",
5
5
  "license": "MIT",
6
6
  "author": "showOS",
package/src/index.ts CHANGED
@@ -142,6 +142,7 @@ async function main(): Promise<void> {
142
142
  { exitOnCtrlC: false },
143
143
  );
144
144
  await intro.waitUntilExit();
145
+ intro.clear();
145
146
  intro.unmount();
146
147
  }
147
148
 
@@ -152,7 +153,13 @@ async function main(): Promise<void> {
152
153
  const { waitUntilExit } = render(
153
154
  React.createElement(ThemeProvider, {
154
155
  value: getActiveTheme(),
155
- children: React.createElement(App, { conversation, config, mood }),
156
+ children: React.createElement(App, {
157
+ conversation,
158
+ config,
159
+ mood,
160
+ greeting,
161
+ showIntroChrome: !skipIntro,
162
+ }),
156
163
  }),
157
164
  { exitOnCtrlC: false },
158
165
  );
package/src/ui/App.tsx CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  insertAtCursor,
35
35
  } from "./graphemes.ts";
36
36
  import { InputBox } from "./InputBox.tsx";
37
+ import { MascotDashboard } from "./MascotIntro.tsx";
37
38
  import { StreamingMessage } from "./Message.tsx";
38
39
  import { Spinner } from "./Spinner.tsx";
39
40
  import { StatusBar } from "./StatusBar.tsx";
@@ -101,9 +102,18 @@ interface AppProps {
101
102
  config: Config;
102
103
  mood?: string;
103
104
  fetchFn?: FetchFn;
105
+ greeting?: string;
106
+ showIntroChrome?: boolean;
104
107
  }
105
108
 
106
- export function App({ conversation, config, mood = "neutral", fetchFn }: AppProps) {
109
+ export function App({
110
+ conversation,
111
+ config,
112
+ mood = "neutral",
113
+ fetchFn,
114
+ greeting,
115
+ showIntroChrome = false,
116
+ }: AppProps) {
107
117
  const { exit } = useApp();
108
118
  const { stdout } = useStdout();
109
119
  const [activeTheme, setActiveThemeSnapshot] = useState(() => getActiveTheme());
@@ -124,11 +134,16 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
124
134
  const mode = useMemo(() => pickLayout(cols), [cols]);
125
135
  const inputWidth = useMemo(() => Math.max(1, cols), [cols]);
126
136
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
127
- const statusBarWidth = useMemo(() => Math.max(1, inputWidth - 2), [inputWidth]);
137
+ const statusBarWidth = inputWidth;
128
138
  const isCompact = mode === "very-narrow";
139
+ const integratedIntro = showIntroChrome && typeof greeting === "string";
129
140
  const maxTranscriptRows = useMemo(
130
- () => transcriptRowsForTerminalRows(rows),
131
- [rows],
141
+ () =>
142
+ Math.max(
143
+ 1,
144
+ transcriptRowsForTerminalRows(rows) - (integratedIntro ? 10 : 0),
145
+ ),
146
+ [integratedIntro, rows],
132
147
  );
133
148
 
134
149
  const [items, setItems] = useState<ChatItem[]>([]);
@@ -303,7 +318,7 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
303
318
  setWitticism(event.finalLine);
304
319
  addItem("system", event.transcriptLine);
305
320
  }
306
- }, 60);
321
+ }, 45);
307
322
  }, [addItem]);
308
323
 
309
324
  const runLLM = useCallback(async (instruction?: string) => {
@@ -701,6 +716,26 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
701
716
  const isBusy =
702
717
  requestInFlight || streaming !== null || thinking !== null || synergyEvent !== null;
703
718
  const headerStatus = isBusy ? "streaming" : deskStatus;
719
+ const embeddedDealDeskWidth =
720
+ chromeWidth >= 112
721
+ ? Math.min(92, Math.max(32, Math.floor(chromeWidth * 0.44)))
722
+ : Math.max(32, chromeWidth - 8);
723
+ const dealDeskHeader = (
724
+ <DealDeskHeader
725
+ model={model}
726
+ mood={mood}
727
+ messageCount={msgCount}
728
+ themeName={themeName}
729
+ approximateTokens={tokenCount}
730
+ latencyMs={lastLatencyMs}
731
+ fallbackModel={fallbackModel}
732
+ status={headerStatus}
733
+ compact={isCompact}
734
+ notice={!integratedIntro ? deskNotice ?? undefined : undefined}
735
+ maxWidth={integratedIntro ? embeddedDealDeskWidth : chromeWidth}
736
+ marginBottom={integratedIntro ? 0 : 1}
737
+ />
738
+ );
704
739
  const visibleTranscriptRows = synergyEvent
705
740
  ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
706
741
  : maxTranscriptRows;
@@ -708,19 +743,17 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
708
743
  return (
709
744
  <ThemeProvider value={activeTheme}>
710
745
  <Box flexDirection="column">
711
- <DealDeskHeader
712
- model={model}
713
- mood={mood}
714
- messageCount={msgCount}
715
- themeName={themeName}
716
- approximateTokens={tokenCount}
717
- latencyMs={lastLatencyMs}
718
- fallbackModel={fallbackModel}
719
- status={headerStatus}
720
- compact={isCompact}
721
- notice={deskNotice ?? undefined}
722
- maxWidth={chromeWidth}
723
- />
746
+ {integratedIntro ? (
747
+ <Box marginBottom={1}>
748
+ <MascotDashboard
749
+ greeting={greeting}
750
+ width={chromeWidth}
751
+ dealDesk={dealDeskHeader}
752
+ />
753
+ </Box>
754
+ ) : (
755
+ dealDeskHeader
756
+ )}
724
757
  <TranscriptViewport
725
758
  items={items}
726
759
  maxRows={visibleTranscriptRows}
@@ -776,7 +809,7 @@ export function App({ conversation, config, mood = "neutral", fetchFn }: AppProp
776
809
  width={inputWidth}
777
810
  />
778
811
  </Box>
779
- <Box paddingLeft={2}>
812
+ <Box>
780
813
  <StatusBar
781
814
  messageCount={msgCount}
782
815
  witticism={witticism}
@@ -16,6 +16,7 @@ export interface DealDeskHeaderProps {
16
16
  compact?: boolean;
17
17
  notice?: string;
18
18
  maxWidth?: number;
19
+ marginBottom?: number;
19
20
  }
20
21
 
21
22
  const DEFAULT_WIDTH = 80;
@@ -151,6 +152,7 @@ function DealDeskHeaderInner({
151
152
  compact = false,
152
153
  notice,
153
154
  maxWidth = DEFAULT_WIDTH,
155
+ marginBottom = 1,
154
156
  }: DealDeskHeaderProps) {
155
157
  const t = useTheme();
156
158
  const width = Math.max(MIN_WIDTH, Math.floor(maxWidth));
@@ -194,7 +196,7 @@ function DealDeskHeaderInner({
194
196
 
195
197
  if (width < FRAMED_MIN_WIDTH) {
196
198
  return (
197
- <Box width={width} marginBottom={1}>
199
+ <Box width={width} marginBottom={marginBottom}>
198
200
  <Text color={statusColor[status]} wrap="truncate">
199
201
  {tinyLine({ model, messageCount, status, width })}
200
202
  </Text>
@@ -203,7 +205,7 @@ function DealDeskHeaderInner({
203
205
  }
204
206
 
205
207
  return (
206
- <Box flexDirection="column" width={width} marginBottom={1}>
208
+ <Box flexDirection="column" width={width} marginBottom={marginBottom}>
207
209
  <Text color={t.primaryDim}>{lines[0]}</Text>
208
210
  <Text color={statusColor[status]}>{lines[1]}</Text>
209
211
  {lines.slice(2, -1).map((line, index) => (
@@ -1,5 +1,5 @@
1
1
  import { Box, Text, useApp, useInput, useStdout } from "ink";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useEffect, useRef, useState, type ReactNode } from "react";
3
3
  import { STARTUP_TIPS } from "../startupTips.ts";
4
4
  import {
5
5
  MascotFrame,
@@ -139,6 +139,16 @@ interface IntroProps {
139
139
  greeting: string;
140
140
  }
141
141
 
142
+ interface MascotDashboardProps {
143
+ greeting: string;
144
+ width: number;
145
+ state?: MascotState;
146
+ bar?: string;
147
+ barColor?: string;
148
+ mascotStatus?: string;
149
+ dealDesk?: ReactNode;
150
+ }
151
+
142
152
  function bootBar(frameIdx: number, total: number): string {
143
153
  const active = Math.max(
144
154
  1,
@@ -167,59 +177,21 @@ function TipsPanel({ width }: { width: number }) {
167
177
  );
168
178
  }
169
179
 
170
- export function MascotIntro({ greeting }: IntroProps) {
180
+ export function MascotDashboard({
181
+ greeting,
182
+ width,
183
+ state = FRAMES[FRAMES.length - 1]!,
184
+ bar = bootBar(FRAMES.length - 1, FRAMES.length),
185
+ barColor,
186
+ mascotStatus = `${INTRO_STATUS_PREFIX}${INTRO_BOOT_NOTES[INTRO_BOOT_NOTES.length - 1]}`,
187
+ dealDesk,
188
+ }: MascotDashboardProps) {
171
189
  const t = useTheme();
172
- const { exit } = useApp();
173
- const { stdout } = useStdout();
174
- const [cols, setCols] = useState(stdout?.columns ?? 80);
175
- const [frameIdx, setFrameIdx] = useState(0);
176
-
177
- useEffect(() => {
178
- if (!stdout) return;
179
- const handler = () => setCols(stdout.columns ?? 80);
180
- stdout.on("resize", handler);
181
- return () => {
182
- stdout.off("resize", handler);
183
- };
184
- }, [stdout]);
185
-
186
- useInput((_input, key) => {
187
- if (key.escape || key.return || (key.ctrl && _input === "c")) exit();
188
- });
189
-
190
- const mountedRef = useRef(true);
191
- useEffect(() => {
192
- return () => {
193
- mountedRef.current = false;
194
- };
195
- }, []);
196
-
197
- useEffect(() => {
198
- const compact = cols < 72;
199
- const total = compact ? COMPACT_NOTES.length : FRAMES.length;
200
- if (frameIdx >= total - 1) {
201
- const handle = setTimeout(() => {
202
- if (mountedRef.current) exit();
203
- }, SETTLE_HOLD_MS);
204
- return () => clearTimeout(handle);
205
- }
206
- const delay = compact
207
- ? COMPACT_DELAY_MS
208
- : (FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!).delayMs;
209
- const handle = setTimeout(() => {
210
- if (mountedRef.current) setFrameIdx((i) => i + 1);
211
- }, delay);
212
- return () => clearTimeout(handle);
213
- }, [cols, frameIdx, exit]);
214
-
215
- const state = FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!;
216
- // Below 21 cols, mascot (17) + gutter (4) overflow — render text-only.
217
- const tinyTerminal = cols < 21;
218
- const compact = cols < 72;
219
- const sideBySide = cols >= 112;
220
- const available = compact
221
- ? Math.max(1, cols - 1)
222
- : Math.max(28, cols);
190
+ const resolvedBarColor = barColor ?? t.primaryLight;
191
+ const tinyTerminal = width < 21;
192
+ const compact = width < 72;
193
+ const sideBySide = width >= 112;
194
+ const available = compact ? Math.max(1, width - 1) : Math.max(28, width);
223
195
  const innerWidth = compact
224
196
  ? available
225
197
  : Math.max(24, available - FRAME_CHROME_WIDTH);
@@ -239,29 +211,16 @@ export function MascotIntro({ greeting }: IntroProps) {
239
211
  : sideBySide
240
212
  ? Math.max(18, leftPanelWidth - MASCOT_WIDTH - GUTTER_WIDTH)
241
213
  : innerWidth;
242
- const bar = bootBar(
243
- Math.min(frameIdx, compact ? COMPACT_NOTES.length - 1 : FRAMES.length - 1),
244
- compact ? COMPACT_NOTES.length : FRAMES.length,
245
- );
246
- const barColor =
247
- frameIdx < (compact ? COMPACT_NOTES.length : FRAMES.length) / 3
248
- ? t.error
249
- : frameIdx < ((compact ? COMPACT_NOTES.length : FRAMES.length) * 2) / 3
250
- ? t.warning
251
- : t.primaryLight;
252
- const note = compact
253
- ? COMPACT_NOTES[Math.min(frameIdx, COMPACT_NOTES.length - 1)]!
254
- : state.note;
255
- const mascotStatus = `${INTRO_STATUS_PREFIX}${note}`;
256
214
 
257
215
  if (tinyTerminal) {
258
216
  return (
259
217
  <Box width={available} flexDirection="column">
260
- <Text color={barColor}>{mascotStatus}</Text>
218
+ <Text color={resolvedBarColor}>{mascotStatus}</Text>
261
219
  <Text bold color={t.primaryLight}>
262
220
  Drexler™
263
221
  </Text>
264
222
  <Text color={t.primaryLight}>{greeting}</Text>
223
+ {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
265
224
  </Box>
266
225
  );
267
226
  }
@@ -269,12 +228,13 @@ export function MascotIntro({ greeting }: IntroProps) {
269
228
  if (compact) {
270
229
  return (
271
230
  <Box marginLeft={1} width={available} flexDirection="column">
272
- <Text color={barColor}>{bar}</Text>
273
- <Text color={barColor}>{mascotStatus}</Text>
231
+ <Text color={resolvedBarColor}>{bar}</Text>
232
+ <Text color={resolvedBarColor}>{mascotStatus}</Text>
274
233
  <Text bold color={t.primaryLight}>
275
234
  Drexler International™
276
235
  </Text>
277
236
  <Text color={t.primaryLight}>{greeting}</Text>
237
+ {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
278
238
  </Box>
279
239
  );
280
240
  }
@@ -297,8 +257,8 @@ export function MascotIntro({ greeting }: IntroProps) {
297
257
  marginRight={sideBySide ? GUTTER_WIDTH : 0}
298
258
  >
299
259
  <MascotFrame {...state} />
300
- <Text color={barColor}>{bar}</Text>
301
- <Text color={barColor}>{mascotStatus}</Text>
260
+ <Text color={resolvedBarColor}>{bar}</Text>
261
+ <Text color={resolvedBarColor}>{mascotStatus}</Text>
302
262
  </Box>
303
263
  <Box
304
264
  flexDirection="column"
@@ -323,16 +283,96 @@ export function MascotIntro({ greeting }: IntroProps) {
323
283
  </Text>
324
284
  ))}
325
285
  </Box>
326
- <Box width={tipsWidth}>
327
- <TipsPanel width={tipsWidth} />
286
+ <Box
287
+ flexDirection="column"
288
+ width={tipsWidth}
289
+ paddingRight={1}
290
+ >
291
+ <TipsPanel width={Math.max(1, tipsWidth - 1)} />
292
+ {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
328
293
  </Box>
329
294
  </>
330
295
  ) : (
331
- <Box marginTop={1} width={tipsWidth}>
296
+ <Box marginTop={1} width={tipsWidth} flexDirection="column">
332
297
  <TipsPanel width={tipsWidth} />
298
+ {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
333
299
  </Box>
334
300
  )}
335
301
  </Box>
336
302
  </Box>
337
303
  );
338
304
  }
305
+
306
+ export function MascotIntro({ greeting }: IntroProps) {
307
+ const t = useTheme();
308
+ const { exit } = useApp();
309
+ const { stdout } = useStdout();
310
+ const [cols, setCols] = useState(stdout?.columns ?? 80);
311
+ const [frameIdx, setFrameIdx] = useState(0);
312
+
313
+ useEffect(() => {
314
+ if (!stdout) return;
315
+ const handler = () => setCols(stdout.columns ?? 80);
316
+ stdout.on("resize", handler);
317
+ return () => {
318
+ stdout.off("resize", handler);
319
+ };
320
+ }, [stdout]);
321
+
322
+ useInput((_input, key) => {
323
+ if (key.escape || key.return || (key.ctrl && _input === "c")) exit();
324
+ });
325
+
326
+ const mountedRef = useRef(true);
327
+ useEffect(() => {
328
+ return () => {
329
+ mountedRef.current = false;
330
+ };
331
+ }, []);
332
+
333
+ useEffect(() => {
334
+ const compact = cols < 72;
335
+ const total = compact ? COMPACT_NOTES.length : FRAMES.length;
336
+ if (frameIdx >= total - 1) {
337
+ const handle = setTimeout(() => {
338
+ if (mountedRef.current) exit();
339
+ }, SETTLE_HOLD_MS);
340
+ return () => clearTimeout(handle);
341
+ }
342
+ const delay = compact
343
+ ? COMPACT_DELAY_MS
344
+ : (FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!).delayMs;
345
+ const handle = setTimeout(() => {
346
+ if (mountedRef.current) setFrameIdx((i) => i + 1);
347
+ }, delay);
348
+ return () => clearTimeout(handle);
349
+ }, [cols, frameIdx, exit]);
350
+
351
+ const state = FRAMES[frameIdx] ?? FRAMES[FRAMES.length - 1]!;
352
+ const compact = cols < 72;
353
+ const bar = bootBar(
354
+ Math.min(frameIdx, compact ? COMPACT_NOTES.length - 1 : FRAMES.length - 1),
355
+ compact ? COMPACT_NOTES.length : FRAMES.length,
356
+ );
357
+ const barColor =
358
+ frameIdx < (compact ? COMPACT_NOTES.length : FRAMES.length) / 3
359
+ ? t.error
360
+ : frameIdx < ((compact ? COMPACT_NOTES.length : FRAMES.length) * 2) / 3
361
+ ? t.warning
362
+ : t.primaryLight;
363
+ const note = compact
364
+ ? COMPACT_NOTES[Math.min(frameIdx, COMPACT_NOTES.length - 1)]!
365
+ : state.note;
366
+ const mascotStatus = `${INTRO_STATUS_PREFIX}${note}`;
367
+
368
+ return (
369
+ <MascotDashboard
370
+ greeting={greeting}
371
+ width={cols}
372
+ state={state}
373
+ bar={bar}
374
+ barColor={barColor}
375
+ mascotStatus={mascotStatus}
376
+ />
377
+ );
378
+ }
@@ -1,5 +1,5 @@
1
1
  import { Box, Text } from "ink";
2
- import { memo } from "react";
2
+ import { memo, useMemo } from "react";
3
3
  import { displayWidth, fitDisplayText } from "./graphemes.ts";
4
4
  import { useTheme } from "./ThemeContext.tsx";
5
5
 
@@ -142,6 +142,17 @@ function frameProgress(frame: number): number {
142
142
  return Math.max(0, Math.min(1, frame / (SYNERGY_EVENT_FRAMES - 1)));
143
143
  }
144
144
 
145
+ // Cubic ease-out: fast reveal, soft settle. Feels less mechanical than linear.
146
+ function easeOutCubic(p: number): number {
147
+ const clamped = Math.max(0, Math.min(1, p));
148
+ const inv = 1 - clamped;
149
+ return 1 - inv * inv * inv;
150
+ }
151
+
152
+ function easedProgress(frame: number): number {
153
+ return easeOutCubic(frameProgress(frame));
154
+ }
155
+
145
156
  function bar(progress: number, width: number): string {
146
157
  const safeWidth = Math.max(1, width);
147
158
  const filled = Math.max(0, Math.min(safeWidth, Math.round(progress * safeWidth)));
@@ -149,7 +160,7 @@ function bar(progress: number, width: number): string {
149
160
  }
150
161
 
151
162
  function stageAt(event: SynergyEventDefinition, frame: number): string {
152
- const progress = frameProgress(frame);
163
+ const progress = easedProgress(frame);
153
164
  const idx = Math.min(
154
165
  event.stages.length - 1,
155
166
  Math.floor(progress * event.stages.length),
@@ -157,13 +168,10 @@ function stageAt(event: SynergyEventDefinition, frame: number): string {
157
168
  return event.stages[idx]!;
158
169
  }
159
170
 
160
- function visibleArt(event: SynergyEventDefinition, frame: number): readonly string[] {
161
- const progress = frameProgress(frame);
162
- const count = Math.max(
163
- 1,
164
- Math.ceil(progress * Math.min(event.art.length, FULL_EVENT_ART_ROWS)),
165
- );
166
- return event.art.slice(0, count);
171
+ function visibleArtCount(event: SynergyEventDefinition, frame: number): number {
172
+ const progress = easedProgress(frame);
173
+ const cap = Math.min(event.art.length, FULL_EVENT_ART_ROWS);
174
+ return Math.max(1, Math.ceil(progress * cap));
167
175
  }
168
176
 
169
177
  function kpiAt(event: SynergyEventDefinition, frame: number): string {
@@ -185,9 +193,18 @@ function SynergyEventInner({
185
193
  }: Props) {
186
194
  const t = useTheme();
187
195
  const safeWidth = Math.max(1, Math.floor(width));
188
- const progress = frameProgress(frame);
196
+ const progress = easedProgress(frame);
189
197
  const done = frame >= SYNERGY_EVENT_FRAMES - 1;
190
198
  const tiny = safeWidth < 38 || compact;
199
+ // Title pulse: alert-style flicker during early reveal, calm bright on done.
200
+ const earlyPulse = !done && frame < 6 && frame % 2 === 0;
201
+ const titleColor = done ? t.primaryLight : earlyPulse ? t.primary : t.warning;
202
+
203
+ const artFrames = useMemo(
204
+ () => event.art.slice(0, FULL_EVENT_ART_ROWS),
205
+ [event],
206
+ );
207
+ const revealedRows = visibleArtCount(event, frame);
191
208
 
192
209
  if (tiny) {
193
210
  const label = done ? event.finalLine : stageAt(event, frame);
@@ -195,7 +212,7 @@ function SynergyEventInner({
195
212
  const line = `SYNC ${bar(progress, miniBarWidth)} ${label}`;
196
213
  return (
197
214
  <Box width={safeWidth} flexShrink={1}>
198
- <Text color={done ? t.primaryLight : t.warning} bold wrap="truncate">
215
+ <Text color={titleColor} bold wrap="truncate">
199
216
  {fitDisplayText(line, safeWidth)}
200
217
  </Text>
201
218
  </Box>
@@ -223,7 +240,7 @@ function SynergyEventInner({
223
240
  flexShrink={1}
224
241
  >
225
242
  <Box>
226
- <Text color={t.warning} bold>
243
+ <Text color={titleColor} bold>
227
244
  SYNERGY EVENT
228
245
  </Text>
229
246
  <Text color={t.primaryDim}> ─ </Text>
@@ -232,8 +249,8 @@ function SynergyEventInner({
232
249
  </Text>
233
250
  </Box>
234
251
  <Box flexDirection="column">
235
- {event.art.slice(0, FULL_EVENT_ART_ROWS).map((line, idx) => {
236
- const revealed = idx < visibleArt(event, frame).length;
252
+ {artFrames.map((line, idx) => {
253
+ const revealed = idx < revealedRows;
237
254
  return (
238
255
  <Text
239
256
  key={`${event.id}-${idx}`}
@@ -103,8 +103,12 @@ function itemRows(
103
103
  cols: number,
104
104
  ): number {
105
105
  if (compact) return 1;
106
- const bodyPrefix = `${ROLE_MARKERS[item.role]} `;
107
- const contentWidth = Math.max(1, cols - displayWidth(bodyPrefix));
106
+ const bodyPrefix = "│ › ";
107
+ const bodySuffix = " │";
108
+ const contentWidth = Math.max(
109
+ 1,
110
+ cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
111
+ );
108
112
  return 2 + wrappedContentRows(item.content, contentWidth).length;
109
113
  }
110
114
 
@@ -162,31 +166,41 @@ function DefaultTranscriptItem({
162
166
  }
163
167
 
164
168
  const headerPrefix = `╭─ ${label} `;
165
- const headerRuleWidth = Math.max(0, cols - displayWidth(headerPrefix));
166
- const footerWidth = Math.max(1, cols - 1);
167
- const bodyPrefix = `${ROLE_MARKERS[item.role]} `;
168
- const contentWidth = Math.max(1, cols - displayWidth(bodyPrefix));
169
+ const headerRuleWidth = Math.max(0, cols - displayWidth(headerPrefix) - 1);
170
+ const footerWidth = Math.max(0, cols - 2);
171
+ const bodyPrefix = item.role === "user" ? "│ › " : "│ ";
172
+ const continuationPrefix = "│ ";
173
+ const bodySuffix = " │";
174
+ const contentWidth = Math.max(
175
+ 1,
176
+ cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
177
+ );
169
178
 
170
179
  return (
171
180
  <Box flexDirection="column" width={cols} flexShrink={1}>
172
181
  <Text color={accent} bold wrap="truncate">
173
182
  {fitDisplayText(
174
- `${headerPrefix}${rule("─", headerRuleWidth)}`,
183
+ `${headerPrefix}${rule("─", headerRuleWidth)}╮`,
175
184
  cols,
176
185
  )}
177
186
  </Text>
178
187
  {wrappedContentRows(item.content, contentWidth).map((line, index) => (
179
188
  <Box key={index} width={cols} flexShrink={1}>
180
189
  <Text color={accent} bold={item.role === "user"}>
181
- {index === 0 || item.role === "assistant" ? bodyPrefix : " "}
190
+ {index === 0 ? bodyPrefix : continuationPrefix}
182
191
  </Text>
183
192
  <Text color={roleBodyColor(item.role, t)}>
184
- {line}
193
+ {fitDisplayText(line, contentWidth)}
194
+ </Text>
195
+ <Text color={accent} bold={item.role === "user"}>
196
+ {`${" ".repeat(
197
+ Math.max(0, contentWidth - displayWidth(line)),
198
+ )}${bodySuffix}`}
185
199
  </Text>
186
200
  </Box>
187
201
  ))}
188
202
  <Text color={accent} bold={item.role === "user"} wrap="truncate">
189
- {fitDisplayText(`╰${rule("─", footerWidth)}`, cols)}
203
+ {fitDisplayText(`╰${rule("─", footerWidth)}╯`, cols)}
190
204
  </Text>
191
205
  </Box>
192
206
  );