drexler 0.1.1 → 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.
@@ -1,36 +1,130 @@
1
1
  import { Box, Text } from "ink";
2
- import { useMemo } from "react";
2
+ import { memo, useMemo } from "react";
3
3
  import type { SlashCommand } from "../commands.ts";
4
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
4
5
  import { useTheme } from "./ThemeContext.tsx";
5
6
 
6
7
  interface Props {
7
8
  items: ReadonlyArray<SlashCommand>;
8
9
  selectedIdx: number;
10
+ width?: number;
9
11
  }
10
12
 
11
- export function CommandPalette({ items, selectedIdx }: Props) {
13
+ const COMMAND_HINTS: Record<string, string> = {
14
+ "/help": "open directive list",
15
+ "/clear": "reset transcript",
16
+ "/exit": "close session",
17
+ "/synergy": "morale event",
18
+ "/model": "/model 26b",
19
+ "/theme": "/theme midnight",
20
+ "/startup": "/startup fast",
21
+ "/history": "show ledger stats",
22
+ "/regenerate": "retry last answer",
23
+ "/retry": "/retry terse",
24
+ "/expand": "print latest response",
25
+ "/quote": "quote latest response",
26
+ "/search": "/search covenant",
27
+ "/export": "/export html board-memo.html",
28
+ "/save": "/save deal-notes.md",
29
+ "/save-last": "/save-last last-response.md",
30
+ "/copy-last": "copy latest response",
31
+ };
32
+
33
+ function CommandPaletteInner({ items, selectedIdx, width = 80 }: Props) {
12
34
  const t = useTheme();
35
+ const safeWidth = Math.max(1, Math.floor(width));
36
+ const tiny = safeWidth < 26;
13
37
  const maxNameW = useMemo(
14
38
  () => items.reduce((m, i) => Math.max(m, i.name.length), 0),
15
39
  [items],
16
40
  );
17
41
  if (items.length === 0) return null;
42
+
43
+ if (tiny) {
44
+ return (
45
+ <Box flexDirection="column" width={safeWidth} flexShrink={1}>
46
+ {items.map((item, idx) => {
47
+ const sel = idx === selectedIdx;
48
+ const line = `${sel ? "› " : " "}${item.name}`;
49
+ return (
50
+ <Text
51
+ key={item.name}
52
+ color={sel ? t.primaryLight : t.primaryDim}
53
+ bold={sel}
54
+ wrap="truncate"
55
+ >
56
+ {fitDisplayText(line, safeWidth)}
57
+ </Text>
58
+ );
59
+ })}
60
+ </Box>
61
+ );
62
+ }
63
+
64
+ const innerWidth = Math.max(1, safeWidth - 4);
65
+ const descBudget = Math.max(8, Math.floor(innerWidth * 0.36));
66
+ const hintBudget = Math.max(
67
+ 0,
68
+ innerWidth - 4 - maxNameW - descBudget - 4,
69
+ );
70
+
18
71
  return (
19
- <Box flexDirection="column" paddingX={1} marginBottom={1}>
72
+ <Box
73
+ flexDirection="column"
74
+ borderStyle="round"
75
+ borderColor={t.primaryDim}
76
+ paddingX={1}
77
+ marginBottom={1}
78
+ width={safeWidth}
79
+ flexShrink={1}
80
+ >
81
+ <Box marginBottom={1}>
82
+ <Text color={t.primaryLight} bold>
83
+ DIRECTIVES
84
+ </Text>
85
+ <Text color={t.primaryDim}> ─ </Text>
86
+ <Text color={t.dim} wrap="truncate">
87
+ {fitDisplayText("tab/↑↓ select, enter execute", Math.max(1, innerWidth - 13))}
88
+ </Text>
89
+ </Box>
20
90
  {items.map((item, idx) => {
21
91
  const sel = idx === selectedIdx;
92
+ const hint = COMMAND_HINTS[item.name] ?? item.description;
93
+ const name = item.name.padEnd(maxNameW + 1);
94
+ const desc = fitDisplayText(item.description, descBudget);
95
+ const clippedHint =
96
+ hintBudget > 0 ? fitDisplayText(hint, hintBudget) : "";
97
+ const rowWidth =
98
+ 2 +
99
+ displayWidth(name) +
100
+ 1 +
101
+ displayWidth(desc) +
102
+ (clippedHint ? 2 + displayWidth(clippedHint) : 0);
22
103
  return (
23
- <Box key={item.name}>
24
- <Text color={sel ? t.primaryLight : t.primary} bold={sel}>
25
- {sel ? " " : " "}
104
+ <Box key={item.name} width={Math.min(innerWidth, rowWidth)} flexShrink={1}>
105
+ <Text color={sel ? t.primaryLight : t.primaryDim} bold={sel}>
106
+ {sel ? " " : " "}
26
107
  </Text>
27
108
  <Text color={sel ? t.primaryLight : t.primary} bold={sel}>
28
- {item.name.padEnd(maxNameW + 2)}
109
+ {name}
29
110
  </Text>
30
- <Text color={t.dim}>{item.description}</Text>
111
+ <Text color={t.primaryDim}> </Text>
112
+ <Text color={sel ? t.text : t.dim} wrap="truncate">
113
+ {desc}
114
+ </Text>
115
+ {clippedHint ? (
116
+ <>
117
+ <Text color={t.primaryDim}> </Text>
118
+ <Text color={sel ? t.primaryLight : t.primaryDim} wrap="truncate">
119
+ {clippedHint}
120
+ </Text>
121
+ </>
122
+ ) : null}
31
123
  </Box>
32
124
  );
33
125
  })}
34
126
  </Box>
35
127
  );
36
128
  }
129
+
130
+ export const CommandPalette = memo(CommandPaletteInner);
@@ -0,0 +1,219 @@
1
+ import { Box, Text } from "ink";
2
+ import { memo, useMemo } from "react";
3
+ import { useTheme } from "./ThemeContext.tsx";
4
+
5
+ export type DealDeskHeaderStatus = "idle" | "streaming" | "error";
6
+
7
+ export interface DealDeskHeaderProps {
8
+ model: string;
9
+ mood: string;
10
+ messageCount: number;
11
+ themeName?: string;
12
+ approximateTokens?: number;
13
+ latencyMs?: number | null;
14
+ fallbackModel?: string | null;
15
+ status?: DealDeskHeaderStatus;
16
+ compact?: boolean;
17
+ notice?: string;
18
+ maxWidth?: number;
19
+ }
20
+
21
+ const DEFAULT_WIDTH = 80;
22
+ const MIN_WIDTH = 1;
23
+ const FRAMED_MIN_WIDTH = 24;
24
+
25
+ const STATUS_LABEL: Record<DealDeskHeaderStatus, string> = {
26
+ idle: "READY",
27
+ streaming: "LIVE",
28
+ error: "ERROR",
29
+ };
30
+
31
+ function visibleLength(input: string): number {
32
+ return Array.from(input).length;
33
+ }
34
+
35
+ function clampText(input: string, max: number): string {
36
+ if (max <= 0) return "";
37
+ if (visibleLength(input) <= max) return input;
38
+ if (max === 1) return "…";
39
+ return `${Array.from(input).slice(0, max - 1).join("")}…`;
40
+ }
41
+
42
+ function padToWidth(input: string, width: number): string {
43
+ const len = visibleLength(input);
44
+ if (len >= width) return input;
45
+ return `${input}${" ".repeat(width - len)}`;
46
+ }
47
+
48
+ function shellLine(left: string, right: string, width: number): string {
49
+ const available = Math.max(0, width - visibleLength(left) - visibleLength(right));
50
+ return `${left}${"─".repeat(available)}${right}`;
51
+ }
52
+
53
+ function bodyLine(content: string, width: number): string {
54
+ const innerWidth = Math.max(0, width - 4);
55
+ return `│ ${padToWidth(clampText(content, innerWidth), innerWidth)} │`;
56
+ }
57
+
58
+ function countLabel(messageCount: number, compact: boolean): string {
59
+ if (compact) return `${messageCount} msg${messageCount === 1 ? "" : "s"}`;
60
+ return `${messageCount} message${messageCount === 1 ? "" : "s"}`;
61
+ }
62
+
63
+ function latencyLabel(latencyMs: number | null | undefined): string | null {
64
+ if (typeof latencyMs !== "number") return null;
65
+ if (latencyMs < 1000) return `${Math.max(0, Math.round(latencyMs))}ms`;
66
+ return `${(latencyMs / 1000).toFixed(1)}s`;
67
+ }
68
+
69
+ function tinyLine({
70
+ model,
71
+ messageCount,
72
+ status,
73
+ width,
74
+ }: {
75
+ model: string;
76
+ messageCount: number;
77
+ status: DealDeskHeaderStatus;
78
+ width: number;
79
+ }): string {
80
+ return clampText(
81
+ `${STATUS_LABEL[status]} ${countLabel(messageCount, true)} ${model}`,
82
+ width,
83
+ );
84
+ }
85
+
86
+ function buildHeaderLines({
87
+ model,
88
+ mood,
89
+ messageCount,
90
+ themeName,
91
+ approximateTokens,
92
+ latencyMs,
93
+ fallbackModel,
94
+ status,
95
+ compact,
96
+ notice,
97
+ width,
98
+ }: {
99
+ model: string;
100
+ mood: string;
101
+ messageCount: number;
102
+ themeName?: string;
103
+ approximateTokens?: number;
104
+ latencyMs?: number | null;
105
+ fallbackModel?: string | null;
106
+ status: DealDeskHeaderStatus;
107
+ compact: boolean;
108
+ notice?: string;
109
+ width: number;
110
+ }): string[] {
111
+ const statusLabel = STATUS_LABEL[status];
112
+ const latency = latencyLabel(latencyMs);
113
+ const top = compact
114
+ ? shellLine("┌ Drexler ", "┐", width)
115
+ : shellLine("┌ Drexler Deal Desk ", "┐", width);
116
+
117
+ const summary = compact
118
+ ? `● ${statusLabel} ${model} ${countLabel(messageCount, true)}${
119
+ latency ? ` ${latency}` : ""
120
+ }`
121
+ : `● ${statusLabel} │ ${countLabel(
122
+ messageCount,
123
+ false,
124
+ )} │ ~${approximateTokens ?? 0} tok │ ${latency ?? "no run yet"}`;
125
+ const detail = `model ${model} │ mood ${mood} │ theme ${
126
+ themeName ?? "apollo"
127
+ }${fallbackModel ? ` │ fallback ${fallbackModel}` : ""}`;
128
+ const lines = [top, bodyLine(summary, width)];
129
+
130
+ if (!compact) {
131
+ lines.push(bodyLine(detail, width));
132
+ }
133
+
134
+ if (!compact && notice && notice.trim().length > 0) {
135
+ lines.push(bodyLine(`notice ${notice.trim()}`, width));
136
+ }
137
+
138
+ lines.push(shellLine("└", "┘", width));
139
+ return lines;
140
+ }
141
+
142
+ function DealDeskHeaderInner({
143
+ model,
144
+ mood,
145
+ messageCount,
146
+ themeName,
147
+ approximateTokens,
148
+ latencyMs,
149
+ fallbackModel,
150
+ status = "idle",
151
+ compact = false,
152
+ notice,
153
+ maxWidth = DEFAULT_WIDTH,
154
+ }: DealDeskHeaderProps) {
155
+ const t = useTheme();
156
+ const width = Math.max(MIN_WIDTH, Math.floor(maxWidth));
157
+ const statusColor: Record<DealDeskHeaderStatus, string> = useMemo(
158
+ () => ({
159
+ idle: t.primaryLight,
160
+ streaming: t.warning,
161
+ error: t.error,
162
+ }),
163
+ [t.error, t.primaryLight, t.warning],
164
+ );
165
+ const lines = useMemo(
166
+ () =>
167
+ buildHeaderLines({
168
+ model,
169
+ mood,
170
+ messageCount,
171
+ themeName,
172
+ approximateTokens,
173
+ latencyMs,
174
+ fallbackModel,
175
+ status,
176
+ compact,
177
+ notice,
178
+ width,
179
+ }),
180
+ [
181
+ approximateTokens,
182
+ compact,
183
+ fallbackModel,
184
+ latencyMs,
185
+ messageCount,
186
+ model,
187
+ mood,
188
+ notice,
189
+ status,
190
+ themeName,
191
+ width,
192
+ ],
193
+ );
194
+
195
+ if (width < FRAMED_MIN_WIDTH) {
196
+ return (
197
+ <Box width={width} marginBottom={1}>
198
+ <Text color={statusColor[status]} wrap="truncate">
199
+ {tinyLine({ model, messageCount, status, width })}
200
+ </Text>
201
+ </Box>
202
+ );
203
+ }
204
+
205
+ return (
206
+ <Box flexDirection="column" width={width} marginBottom={1}>
207
+ <Text color={t.primaryDim}>{lines[0]}</Text>
208
+ <Text color={statusColor[status]}>{lines[1]}</Text>
209
+ {lines.slice(2, -1).map((line, index) => (
210
+ <Text key={index} color={index === 0 ? t.primaryLight : t.dim}>
211
+ {line}
212
+ </Text>
213
+ ))}
214
+ <Text color={t.primaryDim}>{lines[lines.length - 1]}</Text>
215
+ </Box>
216
+ );
217
+ }
218
+
219
+ export const DealDeskHeader = memo(DealDeskHeaderInner);
@@ -1,4 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
+ import { memo } from "react";
3
+ import { clampCursor, displayWidth, splitGraphemes } from "./graphemes.ts";
2
4
  import { useTheme } from "./ThemeContext.tsx";
3
5
 
4
6
  interface Props {
@@ -8,32 +10,135 @@ interface Props {
8
10
  width: number;
9
11
  }
10
12
 
11
- export function InputBox({ value, cursor, disabled, width }: Props) {
13
+ const PROMPT_WIDTH = 2;
14
+ const BOX_CHROME_WIDTH = 4;
15
+ const FRAMED_MIN_WIDTH = 8;
16
+
17
+ function clamp(n: number, min: number, max: number): number {
18
+ return Math.max(min, Math.min(n, max));
19
+ }
20
+
21
+ function fitWindow(
22
+ chars: string[],
23
+ cursor: number,
24
+ maxWidth: number,
25
+ ): { start: number; end: number; leftOverflow: boolean; rightOverflow: boolean } {
26
+ let markerReserve = 0;
27
+ let result = { start: cursor, end: cursor, leftOverflow: false, rightOverflow: false };
28
+
29
+ for (let pass = 0; pass < 3; pass++) {
30
+ const available = Math.max(1, maxWidth - markerReserve);
31
+ let start = cursor;
32
+ let end = cursor < chars.length ? cursor + 1 : cursor;
33
+ let used = cursor < chars.length ? displayWidth(chars[cursor] ?? "") : 1;
34
+
35
+ while (start > 0) {
36
+ const nextWidth = displayWidth(chars[start - 1] ?? "");
37
+ if (used + nextWidth > available) break;
38
+ start -= 1;
39
+ used += nextWidth;
40
+ }
41
+
42
+ while (end < chars.length) {
43
+ const nextWidth = displayWidth(chars[end] ?? "");
44
+ if (used + nextWidth > available) break;
45
+ end += 1;
46
+ used += nextWidth;
47
+ }
48
+
49
+ const leftOverflow = start > 0;
50
+ const rightOverflow = end < chars.length;
51
+ const nextReserve = (leftOverflow ? 1 : 0) + (rightOverflow ? 1 : 0);
52
+ result = { start, end, leftOverflow, rightOverflow };
53
+ if (nextReserve === markerReserve) break;
54
+ markerReserve = nextReserve;
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ function fitPlainText(chars: string[], cursor: number, maxWidth: number): string {
61
+ const available = Math.max(1, maxWidth);
62
+ const window = fitWindow(chars, cursor, available);
63
+ const visible = chars.slice(window.start, window.end);
64
+ const visibleCursor = clamp(cursor - window.start, 0, visible.length);
65
+ const parts = [
66
+ window.leftOverflow ? "…" : "",
67
+ ...visible.slice(0, visibleCursor),
68
+ visible[visibleCursor] && displayWidth(visible[visibleCursor]!) <= available
69
+ ? visible[visibleCursor]!
70
+ : " ",
71
+ ...visible.slice(visibleCursor + 1),
72
+ window.rightOverflow ? "…" : "",
73
+ ];
74
+ let out = "";
75
+ for (const part of parts) {
76
+ if (!part) continue;
77
+ if (displayWidth(out + part) > available) break;
78
+ out += part;
79
+ }
80
+ return out || " ";
81
+ }
82
+
83
+ function InputBoxInner({ value, cursor, disabled, width }: Props) {
12
84
  const t = useTheme();
13
- // Grapheme-aware splitting so emoji / multi-byte chars don't render as
14
- // broken surrogate pairs when the cursor lands mid-codepoint.
15
- const chars = Array.from(value);
16
- const safeCursor = Math.max(0, Math.min(cursor, chars.length));
17
- const before = chars.slice(0, safeCursor).join("");
18
- const at = chars[safeCursor] ?? " ";
19
- const after = chars.slice(safeCursor + 1).join("");
85
+ const chars = splitGraphemes(value);
86
+ const safeCursor = clampCursor(value, cursor);
87
+ const boxWidth = Math.max(1, width);
88
+ const inputBudget = Math.max(1, boxWidth - BOX_CHROME_WIDTH - PROMPT_WIDTH);
89
+ const disabledText = "(Drexler thinking... ESC to cancel)";
90
+ const window = fitWindow(chars, safeCursor, inputBudget);
91
+ const visible = chars.slice(window.start, window.end);
92
+ const visibleCursor = clamp(safeCursor - window.start, 0, visible.length);
93
+ const before = visible.slice(0, visibleCursor).join("");
94
+ const at = visible[visibleCursor] ?? " ";
95
+ const after = visible.slice(visibleCursor + 1).join("");
96
+
97
+ if (boxWidth < FRAMED_MIN_WIDTH) {
98
+ const plainBudget = Math.max(1, boxWidth - PROMPT_WIDTH);
99
+ const plain = disabled
100
+ ? disabledText.slice(0, plainBudget)
101
+ : fitPlainText(chars, safeCursor, plainBudget);
102
+ return (
103
+ <Box width={boxWidth} flexShrink={1}>
104
+ <Text color={t.primaryLight} bold wrap="truncate">
105
+ ❯{" "}
106
+ </Text>
107
+ <Text color={disabled ? t.dim : t.text} wrap="truncate">
108
+ {plain}
109
+ </Text>
110
+ </Box>
111
+ );
112
+ }
20
113
 
21
114
  return (
22
- <Box borderStyle="round" borderColor={t.primary} paddingX={1} width={width}>
115
+ <Box
116
+ borderStyle="round"
117
+ borderColor={disabled ? t.primaryDim : t.primary}
118
+ paddingX={1}
119
+ width={boxWidth}
120
+ flexShrink={1}
121
+ >
23
122
  <Text color={t.primaryLight} bold>
24
123
  ❯{" "}
25
124
  </Text>
26
125
  {disabled ? (
27
- <Text color={t.dim}>(Drexler thinking… ESC to cancel)</Text>
126
+ <Text color={t.dim} wrap="truncate">
127
+ {disabledText}
128
+ </Text>
28
129
  ) : (
29
130
  <>
131
+ {window.leftOverflow ? <Text color={t.primaryDim}>…</Text> : null}
30
132
  <Text color={t.text}>{before}</Text>
31
133
  <Text inverse color={t.text}>
32
134
  {at}
33
135
  </Text>
34
136
  <Text color={t.text}>{after}</Text>
137
+ {window.rightOverflow ? <Text color={t.primaryDim}>…</Text> : null}
35
138
  </>
36
139
  )}
37
140
  </Box>
38
141
  );
39
142
  }
143
+
144
+ export const InputBox = memo(InputBoxInner);
@@ -1,6 +1,7 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { memo, useMemo } from "react";
3
3
  import { renderMarkdown } from "../renderer.ts";
4
+ import { fitDisplayText } from "./graphemes.ts";
4
5
  import { useTheme } from "./ThemeContext.tsx";
5
6
 
6
7
  interface MessageItem {
@@ -8,48 +9,91 @@ interface MessageItem {
8
9
  content: string;
9
10
  }
10
11
 
12
+ const SEPARATOR_WIDTH = 44;
13
+
14
+ const ROLE_LABELS: Record<MessageItem["role"], string> = {
15
+ user: "YOU",
16
+ assistant: "DREXLER",
17
+ system: "SYSTEM",
18
+ };
19
+
11
20
  function Separator() {
12
21
  const t = useTheme();
13
22
  return (
14
- <Box paddingX={1} marginBottom={1}>
15
- <Text color={t.primaryDim}>{"".repeat(40)}</Text>
23
+ <Box paddingX={1} marginBottom={1} flexShrink={1}>
24
+ <Text color={t.primaryDim} wrap="truncate">
25
+ {"─".repeat(SEPARATOR_WIDTH)}
26
+ </Text>
16
27
  </Box>
17
28
  );
18
29
  }
19
30
 
20
31
  function MessageInner({ role, content }: MessageItem) {
21
32
  const t = useTheme();
33
+ const assistantLines = useMemo(
34
+ () =>
35
+ role === "assistant"
36
+ ? renderMarkdown(content).trimEnd().split("\n")
37
+ : [],
38
+ [content, role],
39
+ );
40
+
22
41
  if (role === "user") {
23
42
  return (
24
43
  <>
25
- <Box paddingX={1} marginBottom={1}>
26
- <Text color={t.dim}>❯ </Text>
27
- <Text color={t.text}>{content}</Text>
44
+ <Box paddingX={1} marginBottom={1} flexDirection="column">
45
+ <Box>
46
+ <Text color={t.primaryLight} bold>
47
+ {ROLE_LABELS.user}
48
+ </Text>
49
+ <Text color={t.primaryDim}> ─ </Text>
50
+ <Text color={t.dim}>incoming memo</Text>
51
+ </Box>
52
+ <Box paddingLeft={1}>
53
+ <Text color={t.primary}>› </Text>
54
+ <Text color={t.text} wrap="wrap">
55
+ {content}
56
+ </Text>
57
+ </Box>
28
58
  </Box>
29
59
  </>
30
60
  );
31
61
  }
32
62
  if (role === "system") {
33
63
  return (
34
- <Box paddingX={1} marginBottom={1}>
35
- <Text color={t.dim} italic>
36
- {content}
37
- </Text>
64
+ <Box paddingX={1} marginBottom={1} flexDirection="column">
65
+ <Box>
66
+ <Text color={t.warning} bold>
67
+ {ROLE_LABELS.system}
68
+ </Text>
69
+ <Text color={t.primaryDim}> ─ </Text>
70
+ <Text color={t.dim}>notice</Text>
71
+ </Box>
72
+ <Box paddingLeft={1}>
73
+ <Text color={t.dim} italic wrap="wrap">
74
+ {content}
75
+ </Text>
76
+ </Box>
38
77
  </Box>
39
78
  );
40
79
  }
41
- // assistant: left accent bar + markdown rendering, separator below
42
- const lines = useMemo(
43
- () => renderMarkdown(content).trimEnd().split("\n"),
44
- [content],
45
- );
80
+
46
81
  return (
47
82
  <>
48
- <Box flexDirection="column" marginBottom={1}>
49
- {lines.map((ln, i) => (
50
- <Box key={i}>
51
- <Text color={t.primary}>│ </Text>
52
- <Text>{ln}</Text>
83
+ <Box flexDirection="column" marginBottom={1} paddingX={1}>
84
+ <Box>
85
+ <Text color={t.primaryLight} bold>
86
+ {ROLE_LABELS.assistant}
87
+ </Text>
88
+ <Text color={t.primaryDim}> ─ </Text>
89
+ <Text color={t.dim}>response ledger</Text>
90
+ </Box>
91
+ {assistantLines.map((ln, i) => (
92
+ <Box key={i} paddingLeft={1}>
93
+ <Text color={i === 0 ? t.primary : t.primaryDim}>│ </Text>
94
+ <Text color={t.text} wrap="wrap">
95
+ {ln}
96
+ </Text>
53
97
  </Box>
54
98
  ))}
55
99
  </Box>
@@ -62,17 +106,43 @@ export const Message = memo(MessageInner);
62
106
 
63
107
  interface StreamingProps {
64
108
  content: string;
109
+ width?: number;
65
110
  }
66
111
 
67
- function StreamingMessageInner({ content }: StreamingProps) {
112
+ function StreamingMessageInner({ content, width = 80 }: StreamingProps) {
68
113
  const t = useTheme();
69
114
  const lines = useMemo(() => content.split("\n"), [content]);
115
+ const safeWidth = Math.max(1, Math.floor(width));
116
+ const contentWidth = Math.max(1, safeWidth - 3);
117
+
118
+ if (safeWidth < 18) {
119
+ const compactLine = fitDisplayText(content.replace(/\s+/g, " "), safeWidth);
120
+ return (
121
+ <Box width={safeWidth} flexShrink={1}>
122
+ <Text color={t.primaryLight} wrap="truncate">
123
+ {compactLine}
124
+ </Text>
125
+ </Box>
126
+ );
127
+ }
128
+
70
129
  return (
71
- <Box flexDirection="column">
130
+ <Box flexDirection="column" paddingX={1} width={safeWidth} flexShrink={1}>
131
+ <Box>
132
+ <Text color={t.primaryLight} bold>
133
+ {ROLE_LABELS.assistant}
134
+ </Text>
135
+ <Text color={t.primaryDim}> ─ </Text>
136
+ <Text color={t.dim}>drafting live</Text>
137
+ </Box>
72
138
  {lines.map((ln, i) => (
73
- <Box key={i}>
74
- <Text color={t.primary}>│ </Text>
75
- <Text color={t.text}>{ln}</Text>
139
+ <Box key={i} paddingLeft={1}>
140
+ <Text color={i === lines.length - 1 ? t.primaryLight : t.primary}>
141
+ │{" "}
142
+ </Text>
143
+ <Text color={t.text} wrap="truncate">
144
+ {fitDisplayText(ln, contentWidth)}
145
+ </Text>
76
146
  </Box>
77
147
  ))}
78
148
  </Box>