drexler 0.2.12 → 0.2.13

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.13
4
+
5
+ - Hardened startup panel layout across narrow, standard, and wide terminals.
6
+ - Clamped the embedded Deal Desk to its actual startup-panel column.
7
+ - Improved display-width clipping for Deal Desk, command palette, spinner, status bar, and transcript row budgeting.
8
+ - Added regression coverage for duplicate startup chrome, wide glyphs, long command rows, and short-terminal startup suppression.
9
+
3
10
  ## 0.2.12
4
11
 
5
12
  - Removed the duplicate startup card render so normal launches show one startup panel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
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/ui/App.tsx CHANGED
@@ -136,14 +136,17 @@ export function App({
136
136
  const chromeWidth = useMemo(() => Math.max(1, cols), [cols]);
137
137
  const statusBarWidth = inputWidth;
138
138
  const isCompact = mode === "very-narrow";
139
- const integratedIntro = showIntroChrome && typeof greeting === "string";
139
+ const integratedIntro =
140
+ showIntroChrome && typeof greeting === "string" && rows >= 32;
141
+ const introRowBudget =
142
+ integratedIntro ? (chromeWidth >= 112 ? 14 : chromeWidth >= 72 ? 26 : 6) : 0;
140
143
  const maxTranscriptRows = useMemo(
141
144
  () =>
142
145
  Math.max(
143
146
  1,
144
- transcriptRowsForTerminalRows(rows) - (integratedIntro ? 10 : 0),
147
+ transcriptRowsForTerminalRows(rows) - introRowBudget,
145
148
  ),
146
- [integratedIntro, rows],
149
+ [introRowBudget, rows],
147
150
  );
148
151
 
149
152
  const [items, setItems] = useState<ChatItem[]>([]);
@@ -716,11 +719,7 @@ export function App({
716
719
  const isBusy =
717
720
  requestInFlight || streaming !== null || thinking !== null || synergyEvent !== null;
718
721
  const headerStatus = isBusy ? "streaming" : deskStatus;
719
- const embeddedDealDeskWidth =
720
- chromeWidth >= 112
721
- ? Math.min(72, Math.max(42, Math.floor(chromeWidth * 0.34)))
722
- : Math.max(32, chromeWidth - 8);
723
- const dealDeskHeader = (
722
+ const renderDealDeskHeader = (width: number) => (
724
723
  <DealDeskHeader
725
724
  model={model}
726
725
  mood={mood}
@@ -732,10 +731,11 @@ export function App({
732
731
  status={headerStatus}
733
732
  compact={isCompact}
734
733
  notice={!integratedIntro ? deskNotice ?? undefined : undefined}
735
- maxWidth={integratedIntro ? embeddedDealDeskWidth : chromeWidth}
734
+ maxWidth={integratedIntro ? Math.min(72, Math.max(1, width)) : width}
736
735
  marginBottom={integratedIntro ? 0 : 1}
737
736
  />
738
737
  );
738
+ const dealDeskHeader = renderDealDeskHeader(chromeWidth);
739
739
  const visibleTranscriptRows = synergyEvent
740
740
  ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
741
741
  : maxTranscriptRows;
@@ -748,7 +748,7 @@ export function App({
748
748
  <MascotDashboard
749
749
  greeting={greeting}
750
750
  width={chromeWidth}
751
- dealDesk={dealDeskHeader}
751
+ dealDesk={renderDealDeskHeader}
752
752
  />
753
753
  </Box>
754
754
  ) : (
@@ -769,7 +769,7 @@ export function App({
769
769
  </Box>
770
770
  )}
771
771
  {thinking !== null && streaming === null && (
772
- <Box paddingX={1} marginBottom={1}>
772
+ <Box marginBottom={1}>
773
773
  <Spinner label={thinking} width={chromeWidth} />
774
774
  </Box>
775
775
  )}
@@ -54,13 +54,18 @@ function paletteHeading(items: ReadonlyArray<SlashCommand>): {
54
54
  };
55
55
  }
56
56
 
57
+ function padDisplayText(input: string, width: number): string {
58
+ const clipped = fitDisplayText(input, width);
59
+ return `${clipped}${" ".repeat(Math.max(0, width - displayWidth(clipped)))}`;
60
+ }
61
+
57
62
  function CommandPaletteInner({ items, selectedIdx, width = 80 }: Props) {
58
63
  const t = useTheme();
59
64
  const safeWidth = Math.max(1, Math.floor(width));
60
65
  const tiny = safeWidth < 26;
61
66
  const heading = useMemo(() => paletteHeading(items), [items]);
62
67
  const maxNameW = useMemo(
63
- () => items.reduce((m, i) => Math.max(m, i.name.length), 0),
68
+ () => items.reduce((m, i) => Math.max(m, displayWidth(i.name)), 0),
64
69
  [items],
65
70
  );
66
71
  if (items.length === 0) return null;
@@ -87,10 +92,21 @@ function CommandPaletteInner({ items, selectedIdx, width = 80 }: Props) {
87
92
  }
88
93
 
89
94
  const innerWidth = Math.max(1, safeWidth - 4);
90
- const descBudget = Math.max(8, Math.floor(innerWidth * 0.36));
95
+ const markerWidth = 2;
96
+ const nameBudget = Math.max(
97
+ 6,
98
+ Math.min(maxNameW + 1, Math.floor(innerWidth * 0.44)),
99
+ );
100
+ const descBudget = Math.max(
101
+ 6,
102
+ Math.min(
103
+ Math.floor(innerWidth * 0.34),
104
+ innerWidth - markerWidth - nameBudget - 1,
105
+ ),
106
+ );
91
107
  const hintBudget = Math.max(
92
108
  0,
93
- innerWidth - 4 - maxNameW - descBudget - 4,
109
+ innerWidth - markerWidth - nameBudget - 1 - descBudget - 2,
94
110
  );
95
111
 
96
112
  return (
@@ -121,18 +137,12 @@ function CommandPaletteInner({ items, selectedIdx, width = 80 }: Props) {
121
137
  const hint =
122
138
  item.hint ??
123
139
  (isArgumentSuggestion ? "" : COMMAND_HINTS[item.name] ?? item.description);
124
- const name = item.name.padEnd(maxNameW + 1);
125
- const desc = fitDisplayText(item.description, descBudget);
140
+ const name = padDisplayText(item.name, nameBudget);
141
+ const desc = padDisplayText(item.description, descBudget);
126
142
  const clippedHint =
127
143
  hintBudget > 0 ? fitDisplayText(hint, hintBudget) : "";
128
- const rowWidth =
129
- 2 +
130
- displayWidth(name) +
131
- 1 +
132
- displayWidth(desc) +
133
- (clippedHint ? 2 + displayWidth(clippedHint) : 0);
134
144
  return (
135
- <Box key={item.name} width={Math.min(innerWidth, rowWidth)} flexShrink={1}>
145
+ <Box key={item.name} width={innerWidth} flexShrink={1}>
136
146
  <Text color={sel ? t.primaryLight : t.primaryDim} bold={sel}>
137
147
  {sel ? "› " : " "}
138
148
  </Text>
@@ -140,7 +150,7 @@ function CommandPaletteInner({ items, selectedIdx, width = 80 }: Props) {
140
150
  {name}
141
151
  </Text>
142
152
  <Text color={t.primaryDim}> </Text>
143
- <Text color={sel ? t.text : t.dim} wrap="truncate">
153
+ <Text color={sel ? t.text : t.dim}>
144
154
  {desc}
145
155
  </Text>
146
156
  {clippedHint ? (
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { memo, useMemo } from "react";
3
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
3
4
  import { useTheme } from "./ThemeContext.tsx";
4
5
 
5
6
  export type DealDeskHeaderStatus = "idle" | "streaming" | "error";
@@ -29,25 +30,19 @@ const STATUS_LABEL: Record<DealDeskHeaderStatus, string> = {
29
30
  error: "ERROR",
30
31
  };
31
32
 
32
- function visibleLength(input: string): number {
33
- return Array.from(input).length;
34
- }
35
-
36
33
  function clampText(input: string, max: number): string {
37
34
  if (max <= 0) return "";
38
- if (visibleLength(input) <= max) return input;
39
- if (max === 1) return "…";
40
- return `${Array.from(input).slice(0, max - 1).join("")}…`;
35
+ return fitDisplayText(input, max);
41
36
  }
42
37
 
43
38
  function padToWidth(input: string, width: number): string {
44
- const len = visibleLength(input);
39
+ const len = displayWidth(input);
45
40
  if (len >= width) return input;
46
41
  return `${input}${" ".repeat(width - len)}`;
47
42
  }
48
43
 
49
44
  function shellLine(left: string, right: string, width: number): string {
50
- const available = Math.max(0, width - visibleLength(left) - visibleLength(right));
45
+ const available = Math.max(0, width - displayWidth(left) - displayWidth(right));
51
46
  return `${left}${"─".repeat(available)}${right}`;
52
47
  }
53
48
 
@@ -127,13 +127,8 @@ const COMPACT_DELAY_MS = 850;
127
127
  const SETTLE_HOLD_MS = 1200;
128
128
  const FRAME_CHROME_WIDTH = 4;
129
129
  const GUTTER_WIDTH = 4;
130
- const SPLIT_DIVIDER_WIDTH = 3;
131
130
  const BOOT_BAR_WIDTH = MASCOT_WIDTH - 1;
132
- const SPLIT_DIVIDER_HEIGHT = 9;
133
- const SPLIT_DIVIDER_ROWS: number[] = Array.from(
134
- { length: SPLIT_DIVIDER_HEIGHT },
135
- (_, i) => i,
136
- );
131
+ const RIGHT_COLUMN_BORDER_WIDTH = 2;
137
132
 
138
133
  interface IntroProps {
139
134
  greeting: string;
@@ -146,7 +141,7 @@ interface MascotDashboardProps {
146
141
  bar?: string;
147
142
  barColor?: string;
148
143
  mascotStatus?: string;
149
- dealDesk?: ReactNode;
144
+ dealDesk?: (width: number) => ReactNode;
150
145
  }
151
146
 
152
147
  function bootBar(frameIdx: number, total: number): string {
@@ -191,7 +186,7 @@ export function MascotDashboard({
191
186
  const tinyTerminal = width < 21;
192
187
  const compact = width < 72;
193
188
  const sideBySide = width >= 112;
194
- const available = compact ? Math.max(1, width - 1) : Math.max(28, width);
189
+ const available = compact ? Math.max(1, width - 1) : Math.max(28, width - 1);
195
190
  const innerWidth = compact
196
191
  ? available
197
192
  : Math.max(24, available - FRAME_CHROME_WIDTH);
@@ -200,11 +195,17 @@ export function MascotDashboard({
200
195
  : sideBySide
201
196
  ? Math.max(
202
197
  MASCOT_WIDTH + GUTTER_WIDTH + 24,
203
- Math.floor((innerWidth - SPLIT_DIVIDER_WIDTH) / 2),
198
+ Math.floor((innerWidth - RIGHT_COLUMN_BORDER_WIDTH) / 2),
204
199
  )
205
200
  : innerWidth;
201
+ const rightColumnWidth = sideBySide
202
+ ? Math.max(20, innerWidth - leftPanelWidth)
203
+ : innerWidth;
204
+ const rightInnerWidth = sideBySide
205
+ ? Math.max(1, rightColumnWidth - RIGHT_COLUMN_BORDER_WIDTH)
206
+ : rightColumnWidth;
206
207
  const tipsWidth = sideBySide
207
- ? Math.max(20, innerWidth - leftPanelWidth - SPLIT_DIVIDER_WIDTH)
208
+ ? rightInnerWidth
208
209
  : innerWidth;
209
210
  const copyWidth = compact
210
211
  ? available
@@ -220,7 +221,7 @@ export function MascotDashboard({
220
221
  Drexler™
221
222
  </Text>
222
223
  <Text color={t.primaryLight}>{greeting}</Text>
223
- {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
224
+ {dealDesk ? <Box marginTop={1}>{dealDesk(Math.max(1, available))}</Box> : null}
224
225
  </Box>
225
226
  );
226
227
  }
@@ -234,7 +235,7 @@ export function MascotDashboard({
234
235
  Drexler International™
235
236
  </Text>
236
237
  <Text color={t.primaryLight}>{greeting}</Text>
237
- {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
238
+ {dealDesk ? <Box marginTop={1}>{dealDesk(Math.max(1, available))}</Box> : null}
238
239
  </Box>
239
240
  );
240
241
  }
@@ -275,27 +276,20 @@ export function MascotDashboard({
275
276
  </Box>
276
277
  </Box>
277
278
  {sideBySide ? (
278
- <>
279
- <Box flexDirection="column" width={SPLIT_DIVIDER_WIDTH}>
280
- {SPLIT_DIVIDER_ROWS.map((idx) => (
281
- <Text key={idx} color={t.primaryDim}>
282
- {" │ "}
283
- </Text>
284
- ))}
285
- </Box>
286
279
  <Box
287
280
  flexDirection="column"
288
- width={tipsWidth}
289
- paddingRight={1}
281
+ width={rightColumnWidth}
282
+ borderLeft
283
+ borderColor={t.primaryDim}
284
+ paddingLeft={1}
290
285
  >
291
- <TipsPanel width={Math.max(1, tipsWidth - 1)} />
292
- {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
286
+ <TipsPanel width={rightInnerWidth} />
287
+ {dealDesk ? <Box marginTop={1}>{dealDesk(rightInnerWidth)}</Box> : null}
293
288
  </Box>
294
- </>
295
289
  ) : (
296
290
  <Box marginTop={1} width={tipsWidth} flexDirection="column">
297
291
  <TipsPanel width={tipsWidth} />
298
- {dealDesk ? <Box marginTop={1}>{dealDesk}</Box> : null}
292
+ {dealDesk ? <Box marginTop={1}>{dealDesk(tipsWidth)}</Box> : null}
299
293
  </Box>
300
294
  )}
301
295
  </Box>
@@ -1,6 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
- import { fitDisplayText } from "./graphemes.ts";
3
+ import { displayWidth, fitDisplayText } from "./graphemes.ts";
4
4
  import { useTheme } from "./ThemeContext.tsx";
5
5
 
6
6
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -47,8 +47,15 @@ export function Spinner({ label, width = 80 }: Props) {
47
47
  );
48
48
  }
49
49
 
50
- const labelBudget = Math.max(1, safeWidth - 22);
50
+ const innerWidth = Math.max(1, safeWidth - 4);
51
51
  const showStage = safeWidth >= 42;
52
+ const stageLabel = showStage ? ` · ${stage}` : "";
53
+ const secondsLabel = seconds > 0 && safeWidth >= 34 ? ` · ${seconds}s` : "";
54
+ const fixedWidth =
55
+ displayWidth(`${FRAMES[i]} WORKING ─ `) +
56
+ displayWidth(stageLabel) +
57
+ displayWidth(secondsLabel);
58
+ const labelBudget = Math.max(1, innerWidth - fixedWidth);
52
59
 
53
60
  return (
54
61
  <Box
@@ -66,13 +73,8 @@ export function Spinner({ label, width = 80 }: Props) {
66
73
  <Text color={t.text} wrap="truncate">
67
74
  {fitDisplayText(label, labelBudget)}
68
75
  </Text>
69
- {showStage ? <Text color={t.dim}> · {stage}</Text> : null}
70
- {seconds > 0 && safeWidth >= 34 ? (
71
- <>
72
- <Text color={t.primaryDim}> · </Text>
73
- <Text color={t.dim}>{seconds}s</Text>
74
- </>
75
- ) : null}
76
+ {stageLabel ? <Text color={t.dim}>{stageLabel}</Text> : null}
77
+ {secondsLabel ? <Text color={t.dim}>{secondsLabel}</Text> : null}
76
78
  </Box>
77
79
  );
78
80
  }
@@ -16,13 +16,6 @@ interface Props {
16
16
 
17
17
  const MAX_WITTICISM_LEN = 60;
18
18
 
19
- function clampText(input: string, max: number): string {
20
- if (input.length <= max) return input;
21
- if (max <= 0) return "";
22
- if (max === 1) return "…";
23
- return input.slice(0, max - 1) + "…";
24
- }
25
-
26
19
  function StatusBarInner({
27
20
  messageCount,
28
21
  witticism,
@@ -40,52 +33,27 @@ function StatusBarInner({
40
33
  }),
41
34
  [t.primaryLight, t.warning, t.error],
42
35
  );
36
+ const safeWidth =
37
+ typeof maxWidth === "number" ? Math.max(1, Math.floor(maxWidth)) : undefined;
43
38
  const countLabel = `${messageCount} message${messageCount === 1 ? "" : "s"}`;
44
- const hintLabel = scrollHint ? `${scrollHint}` : "";
45
- const quoteWidth =
46
- typeof maxWidth === "number"
47
- ? Math.max(
48
- 0,
49
- maxWidth -
50
- "● ".length -
51
- countLabel.length -
52
- hintLabel.length -
53
- " │ ".length -
54
- 2,
55
- )
56
- : MAX_WITTICISM_LEN;
57
- const safe = clampText(witticism, Math.min(MAX_WITTICISM_LEN, quoteWidth));
39
+ const quote = `"${fitDisplayText(witticism, MAX_WITTICISM_LEN)}"`;
40
+ const line = compact
41
+ ? `${countLabel}${scrollHint ? ` │ ${scrollHint}` : ""}`
42
+ : `${countLabel}${scrollHint ? ` │ ${scrollHint}` : ""} │ ${quote}`;
43
+ const body = fitDisplayText(line, Math.max(1, (safeWidth ?? 80) - 2));
58
44
  const box = compact ? (
59
45
  <Box>
60
46
  <Text color={dotColor[status]}>● </Text>
61
- <Text color={t.dim}>{countLabel}</Text>
62
- {scrollHint ? (
63
- <>
64
- <Text color={t.primaryDim}>{" │ "}</Text>
65
- <Text color={t.primaryLight}>
66
- {fitDisplayText(scrollHint, Math.max(1, maxWidth ?? 24))}
67
- </Text>
68
- </>
69
- ) : null}
47
+ <Text color={t.dim} wrap="truncate">{body}</Text>
70
48
  </Box>
71
49
  ) : (
72
50
  <Box>
73
51
  <Text color={dotColor[status]}>● </Text>
74
- <Text color={t.dim}>{countLabel}</Text>
75
- {scrollHint ? (
76
- <>
77
- <Text color={t.primaryDim}>{" │ "}</Text>
78
- <Text color={t.primaryLight}>{scrollHint}</Text>
79
- </>
80
- ) : null}
81
- <Text color={t.primaryDim}>{" │ "}</Text>
82
- <Text color={t.dim} italic>
83
- "{safe}"
84
- </Text>
52
+ <Text color={t.dim} italic wrap="truncate">{body}</Text>
85
53
  </Box>
86
54
  );
87
- if (typeof maxWidth === "number") {
88
- return <Box width={maxWidth}>{box}</Box>;
55
+ if (typeof safeWidth === "number") {
56
+ return <Box width={safeWidth}>{box}</Box>;
89
57
  }
90
58
  return box;
91
59
  }
@@ -275,11 +275,19 @@ function selectWindow(
275
275
  reserveTop = nextReserveTop;
276
276
  }
277
277
 
278
- return {
279
- visible: entries.slice(start, end),
280
- hiddenBefore: start,
281
- hiddenAfter: entries.length - end,
282
- };
278
+ const visible = entries.slice(start, end);
279
+ const visibleRows = visible.reduce(
280
+ (sum, entry) => sum + Math.max(1, entry.estimatedRows),
281
+ 0,
282
+ );
283
+ let hiddenBefore = start;
284
+ let hiddenAfter = entries.length - end;
285
+ if (visibleRows + (hiddenBefore > 0 ? 1 : 0) + (hiddenAfter > 0 ? 1 : 0) > safeRows) {
286
+ if (hiddenBefore > 0) hiddenBefore = 0;
287
+ if (visibleRows + (hiddenAfter > 0 ? 1 : 0) > safeRows) hiddenAfter = 0;
288
+ }
289
+
290
+ return { visible, hiddenBefore, hiddenAfter };
283
291
  }
284
292
 
285
293
  function ScrollIndicator({