drexler 0.2.13 → 0.2.14

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.14
4
+
5
+ - Added a startup Mood panel with a stable boot gauge, percentage-only loading row, and rotating mood-specific posture/detail copy.
6
+ - Anchored the wide startup dashboard so wrapped greeting copy no longer pushes the Mood and Deal Desk boxes down or adds stray rows.
7
+ - Reworked the embedded Deal Desk into satirical mood-shaped product chrome instead of model/context telemetry.
8
+ - Improved Drexler transcript rendering with complete bordered cards, a diamond response marker, cleaned markdown/code fence display, and Dracula-inspired code syntax colors.
9
+ - Updated documentation to match current startup chrome, command palette behavior, keyboard controls, source setup, and layout invariants.
10
+
3
11
  ## 0.2.13
4
12
 
5
13
  - Hardened startup panel layout across narrow, standard, and wide terminals.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![license](https://img.shields.io/npm/l/drexler.svg)](./LICENSE)
5
5
  [![bun](https://img.shields.io/badge/runtime-bun%20%E2%89%A5%201.1-black)](https://bun.sh)
6
6
 
7
- CLI chat with **Drexler**, a corporate-executive AI persona who speaks in broken third-person and treats every conversation like a hostile takeover. Built with Bun + TypeScript. Talks to OpenRouter's Gemma 4 31B model (paid).
7
+ CLI chat with **Drexler**, a corporate-executive AI persona who speaks in broken third-person and treats every conversation like a hostile takeover. Built with Bun + TypeScript, Ink, and OpenRouter-compatible Gemma models.
8
8
 
9
9
  > "Drexler usually charge consulting fee for this. Today, pro bono. You welcome."
10
10
 
@@ -13,7 +13,7 @@ CLI chat with **Drexler**, a corporate-executive AI persona who speaks in broken
13
13
  ## Quickstart
14
14
 
15
15
  ```bash
16
- bun add -g drexler
16
+ bun install -g drexler@latest
17
17
  drexler
18
18
  ```
19
19
 
@@ -39,7 +39,7 @@ Verify: `bun --version` → should print `1.1.0` or higher.
39
39
  ### 2. Install Drexler globally
40
40
 
41
41
  ```bash
42
- bun add -g drexler
42
+ bun install -g drexler@latest
43
43
  ```
44
44
 
45
45
  This installs the `drexler` command into `~/.bun/bin/drexler`. Make sure `~/.bun/bin` is on your `$PATH` (Bun's installer does this automatically; if not, add `export PATH="$HOME/.bun/bin:$PATH"` to your shell rc).
@@ -65,9 +65,11 @@ Paste the key, hit return. Drexler saves it to `~/.config/drexler/config.json` (
65
65
  ## Update
66
66
 
67
67
  ```bash
68
- bun update -g drexler
68
+ bun install -g drexler@latest
69
69
  ```
70
70
 
71
+ Global installs replace the existing `drexler` package in Bun's global install location; they do not keep stacking duplicate app copies.
72
+
71
73
  ## Uninstall
72
74
 
73
75
  ```bash
@@ -79,6 +81,33 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
79
81
 
80
82
  ## Usage
81
83
 
84
+ ### Interactive UI
85
+
86
+ Drexler runs as an Ink terminal UI when both stdin and stdout are TTYs. The normal launch shows one integrated startup panel with the mascot, tips, a **Mood** readout, and the **Drexler Deal Desk**. Short terminals automatically suppress oversized startup chrome so the chat stays usable.
87
+
88
+ The startup panel is designed to stay stable while it boots: the mascot loading bar and Mood gauge animate without changing width, greeting copy is held in a fixed slot, and the Mood and Deal Desk boxes stay aligned when the greeting wraps. After boot, Mood resolves into a rotating Drexler-flavored posture with a short satirical subtext line.
89
+
90
+ The Deal Desk is intentionally not a frontier-model telemetry panel. It shows mood-shaped corporate nonsense like boardroom status, memo count, mandate, risk, fees, and counsel posture. Values rotate by mood and session so repeated moods still feel alive.
91
+
92
+ Conversation turns render as bordered cards aligned to the chat input width. User and Drexler responses use separate accents, wrapped text, and fixed-width borders so long responses stay inside the terminal instead of clipping at the right edge. Drexler responses use a diamond body marker. Markdown/code fence labels are cleaned up for display, and code blocks render with Dracula-inspired terminal syntax colors.
93
+
94
+ Typing `/` opens the directive palette. Use `Tab`, `Enter`, or `↑`/`↓` to select. Commands with fixed arguments open smoother option choosers:
95
+
96
+ - `/theme` previews all themes with descriptions.
97
+ - `/startup` offers `fast`, `no-intro`, and `normal`.
98
+ - `/retry` offers `terse` and `brutal`.
99
+ - `/export` offers `md`, `txt`, `json`, and `html`.
100
+ - `/model` offers `31b` and `26b`.
101
+
102
+ `/synergy` runs a rotating animated corporate event in the live UI, then returns control to the chat when the animation completes.
103
+
104
+ Keyboard notes:
105
+
106
+ - `Tab`, `Enter`, and `↑`/`↓` operate the directive palette.
107
+ - `PageUp`/`PageDown` scroll transcript history when it exceeds the visible viewport.
108
+ - `Esc` cancels an in-flight model response without quitting.
109
+ - `Ctrl+C` exits gracefully with an in-character farewell.
110
+
82
111
  ### Flags
83
112
 
84
113
  | flag | what |
@@ -93,12 +122,12 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
93
122
 
94
123
  ### Slash commands (inside the REPL)
95
124
 
96
- | cmd | what it do |
125
+ | cmd | what it does |
97
126
  | -------------- | ------------------------------------------------ |
98
127
  | `/help` | list directives |
99
128
  | `/clear` | shred conversation history (system prompt pinned) |
100
129
  | `/exit` | meeting adjourned |
101
- | `/synergy` | SYNERGY! |
130
+ | `/synergy` | run a rotating animated morale event |
102
131
  | `/model` | show current model, or `/model 26b` to switch |
103
132
  | `/theme` | show/switch theme; append `save` to persist, e.g. `/theme midnight save` |
104
133
  | `/startup fast\|no-intro\|normal` | persist startup behavior for future launches |
@@ -114,8 +143,6 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
114
143
  | `/save-last [path]` | save Drexler's last response only |
115
144
  | `/copy-last` | copy Drexler's latest response to the clipboard |
116
145
 
117
- `Ctrl+C` exits gracefully with an in-character farewell.
118
-
119
146
  ---
120
147
 
121
148
  ## Configuration
@@ -126,6 +153,8 @@ Drexler reads config in this priority (later wins):
126
153
  2. Environment variables
127
154
  3. CLI flags
128
155
 
156
+ If the current config file does not exist, Drexler also checks the legacy `~/.drexlerrc` path.
157
+
129
158
  ### Environment variables
130
159
 
131
160
  | var | purpose |
@@ -159,13 +188,26 @@ Default `maxHistory`: 50 messages.
159
188
  Available launch/config themes: `apollo`, `amber`, `mono`, `terminal`, `dealroom`, `midnight`, `paper`, and `plasma`.
160
189
  `NO_COLOR` always forces `mono`.
161
190
 
191
+ Theme notes:
192
+
193
+ | theme | character |
194
+ | ---------- | ------------------------------------------ |
195
+ | `apollo` | signature Drexler green, the default |
196
+ | `amber` | warm amber deal glow |
197
+ | `mono` | plain high-contrast ANSI colors |
198
+ | `terminal` | classic green/cyan terminal |
199
+ | `dealroom` | restrained teal boardroom palette |
200
+ | `midnight` | cool blue late-session desk |
201
+ | `paper` | clean document-style contrast |
202
+ | `plasma` | high-energy magenta trading-floor accent |
203
+
162
204
  ---
163
205
 
164
206
  ## Models
165
207
 
166
208
  | alias | id | notes |
167
209
  | ------ | ----------------------------------- | ------------------------------ |
168
- | `31b` | `google/gemma-4-31b-it` | primary (paid) |
210
+ | `31b` | `google/gemma-4-31b-it` | primary default |
169
211
  | `26b` | `google/gemma-4-26b-a4b-it` | fallback, auto-retry on 429 |
170
212
 
171
213
  Pass `--model vendor/name:tag` for any other OpenRouter-compatible model.
@@ -178,7 +220,7 @@ Pass `--model vendor/name:tag` for any other OpenRouter-compatible model.
178
220
  git clone https://github.com/showOS/Drexler.git
179
221
  cd Drexler
180
222
  bun install
181
- cp .env.example .env # then edit, paste key into OPENROUTER_API_KEY=
223
+ export OPENROUTER_API_KEY=sk-or-v1-your-key
182
224
  bun run start
183
225
  ```
184
226
 
@@ -192,11 +234,12 @@ bun run typecheck
192
234
  ### Releasing a new version
193
235
 
194
236
  ```bash
195
- npm version <patch|minor> # bumps package.json, commits, tags
196
- git push --follow-tags # CI publishes to npm automatically
237
+ bun run prepublishOnly
238
+ npm version <patch|minor> # bumps package.json, commits, and tags
239
+ git push origin main --follow-tags
197
240
  ```
198
241
 
199
- The `.github/workflows/publish.yml` workflow runs typecheck + tests + `npm publish --provenance` on every `v*` tag push.
242
+ The `.github/workflows/publish.yml` workflow runs install, tag/package version verification, typecheck, tests, and `npm publish --provenance` on every `v*` tag push.
200
243
 
201
244
  ---
202
245
 
@@ -210,6 +253,8 @@ The `.github/workflows/publish.yml` workflow runs typecheck + tests + `npm publi
210
253
  | Garbled box-drawing characters | Use a UTF-8 terminal with a Nerd Font (e.g. iTerm2, Alacritty, WezTerm) |
211
254
  | Want to switch themes mid-session | Use `/theme midnight`, `/theme dealroom`, `/theme amber`, or any listed theme inside the REPL |
212
255
  | Want a faster launch | Use `drexler --fast` or set `"fast": true` in config |
256
+ | Startup panel looks cramped | Enlarge the terminal, or use `/startup no-intro` or `/startup fast` |
257
+ | Slash command options are not visible | Type `/`, `/theme`, `/startup`, `/retry`, `/export`, or `/model`; exact fixed-argument commands open their chooser automatically |
213
258
 
214
259
  ---
215
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
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
@@ -133,7 +133,7 @@ async function main(): Promise<void> {
133
133
  }
134
134
 
135
135
  console.log("");
136
- console.log(" " + infoLine() + " · mood: " + mood);
136
+ console.log(" " + infoLine());
137
137
  console.log("");
138
138
 
139
139
  const { waitUntilExit } = render(
package/src/ui/App.tsx CHANGED
@@ -34,7 +34,11 @@ import {
34
34
  insertAtCursor,
35
35
  } from "./graphemes.ts";
36
36
  import { InputBox } from "./InputBox.tsx";
37
- import { MascotDashboard } from "./MascotIntro.tsx";
37
+ import {
38
+ introPhaseColor,
39
+ MascotDashboard,
40
+ useIntroAnimation,
41
+ } from "./MascotIntro.tsx";
38
42
  import { StreamingMessage } from "./Message.tsx";
39
43
  import { Spinner } from "./Spinner.tsx";
40
44
  import { StatusBar } from "./StatusBar.tsx";
@@ -204,6 +208,7 @@ export function App({
204
208
  const [historyIdx, setHistoryIdx] = useState<number | null>(null);
205
209
  const [paletteIdx, setPaletteIdx] = useState(0);
206
210
  const [scrollOffset, setScrollOffset] = useState(0);
211
+ const intro = useIntroAnimation(chromeWidth, integratedIntro);
207
212
 
208
213
  const paletteItems = useMemo(() => filterPaletteByPrefix(input), [input]);
209
214
  const paletteOpen = paletteItems.length > 0;
@@ -731,7 +736,7 @@ export function App({
731
736
  status={headerStatus}
732
737
  compact={isCompact}
733
738
  notice={!integratedIntro ? deskNotice ?? undefined : undefined}
734
- maxWidth={integratedIntro ? Math.min(72, Math.max(1, width)) : width}
739
+ maxWidth={Math.max(1, width)}
735
740
  marginBottom={integratedIntro ? 0 : 1}
736
741
  />
737
742
  );
@@ -739,6 +744,7 @@ export function App({
739
744
  const visibleTranscriptRows = synergyEvent
740
745
  ? Math.max(1, maxTranscriptRows - synergyEventRows(chromeWidth, isCompact))
741
746
  : maxTranscriptRows;
747
+ const introBarColor = introPhaseColor(intro.colorPhase, t);
742
748
 
743
749
  return (
744
750
  <ThemeProvider value={activeTheme}>
@@ -748,6 +754,12 @@ export function App({
748
754
  <MascotDashboard
749
755
  greeting={greeting}
750
756
  width={chromeWidth}
757
+ mood={mood}
758
+ bootProgress={intro.progress}
759
+ state={intro.state}
760
+ bar={intro.bar}
761
+ barColor={introBarColor}
762
+ mascotStatus={intro.status}
751
763
  dealDesk={renderDealDeskHeader}
752
764
  />
753
765
  </Box>
@@ -809,11 +821,11 @@ export function App({
809
821
  width={inputWidth}
810
822
  />
811
823
  </Box>
812
- <Box>
824
+ <Box paddingLeft={2}>
813
825
  <StatusBar
814
826
  messageCount={msgCount}
815
827
  witticism={witticism}
816
- maxWidth={statusBarWidth}
828
+ maxWidth={Math.max(1, statusBarWidth - 2)}
817
829
  status={isBusy ? "streaming" : deskStatus}
818
830
  compact={isCompact}
819
831
  scrollHint={scrollHint}
@@ -24,10 +24,81 @@ const DEFAULT_WIDTH = 80;
24
24
  const MIN_WIDTH = 1;
25
25
  const FRAMED_MIN_WIDTH = 24;
26
26
 
27
- const STATUS_LABEL: Record<DealDeskHeaderStatus, string> = {
28
- idle: "READY",
29
- streaming: "LIVE",
30
- error: "ERROR",
27
+ const BOARDROOM_STATUS: Record<DealDeskHeaderStatus, string> = {
28
+ idle: "BOARDROOM OPEN",
29
+ streaming: "MEMO LIVE",
30
+ error: "COUNSEL PANIC",
31
+ };
32
+
33
+ type DealDeskPoolKey =
34
+ | "fees"
35
+ | "mandate"
36
+ | "risk"
37
+ | "counsel"
38
+ | "morale"
39
+ | "synergy";
40
+
41
+ const DEFAULT_POOL: Record<DealDeskPoolKey, readonly string[]> = {
42
+ fees: ["accruing", "sacred", "non-refundable", "already billed"],
43
+ mandate: ["self-awarded", "board-adjacent", "strategic-ish", "lightly authorized"],
44
+ risk: ["theatrical", "outsourced", "priced in", "someone else's"],
45
+ counsel: ["circling", "evasive", "comfortable", "redlining lunch"],
46
+ morale: ["impaired", "marked down", "technically solvent", "under review"],
47
+ synergy: ["alleged", "unverifiable", "already billed", "pending lawsuit"],
48
+ };
49
+
50
+ const MOOD_POOLS: Record<
51
+ string,
52
+ Partial<Record<DealDeskPoolKey, readonly string[]>>
53
+ > = {
54
+ angry: {
55
+ fees: ["weaponized", "escalating", "aggressively earned", "non-refundable"],
56
+ mandate: ["hostile", "loudly implied", "board-threatening", "self-ratified"],
57
+ risk: ["acceptable", "transferred", "career-limiting", "somebody else's"],
58
+ counsel: ["circling", "sweating", "denying knowledge", "overruled"],
59
+ morale: ["terminated", "impaired", "written off", "reassigned"],
60
+ synergy: ["forced", "mandatory", "hostile", "already billed"],
61
+ },
62
+ exhausted: {
63
+ fees: ["still accruing", "quietly sacred", "tired but billable", "unquestioned"],
64
+ mandate: ["unclear", "half-approved", "forgotten", "pending coffee"],
65
+ risk: ["deferred", "sleepy", "filed tomorrow", "emotionally hedged"],
66
+ counsel: ["unavailable", "out of office", "blinking slowly", "circling"],
67
+ morale: ["written off", "napping", "below guidance", "technically awake"],
68
+ synergy: ["alleged", "too tired to verify", "softly promised", "unfunded"],
69
+ },
70
+ paranoid: {
71
+ fees: ["traced", "escrowed twice", "suspiciously round", "under seal"],
72
+ mandate: ["encrypted", "deniable", "need-to-know", "redacted"],
73
+ risk: ["everywhere", "listening", "unhedged", "wearing a wire"],
74
+ counsel: ["whispering", "triple-checking", "using burner phones", "redacting"],
75
+ morale: ["surveilled", "compartmentalized", "need-to-know", "encrypted"],
76
+ synergy: ["classified", "denied", "redacted", "not in minutes"],
77
+ },
78
+ generous: {
79
+ fees: ["shared emotionally", "still ours", "politely accruing", "gift-wrapped"],
80
+ mandate: ["benevolent", "magnanimous", "soft hostile", "board-blessed"],
81
+ risk: ["forgiven", "socialized", "gently transferred", "nicely hedged"],
82
+ counsel: ["agreeable", "smiling carefully", "comfortable", "charitable"],
83
+ morale: ["briefly up", "subsidized", "pleasantly marked", "gifted options"],
84
+ synergy: ["donated", "mutual-ish", "kindly alleged", "complimentary"],
85
+ },
86
+ ruthless: {
87
+ fees: ["sacred", "extractive", "fully captured", "compounding"],
88
+ mandate: ["hostile", "absolute", "self-awarded", "non-appealable"],
89
+ risk: ["outsourced", "priced in", "assigned to interns", "deleted"],
90
+ counsel: ["overpaid", "comfortable", "aggressively calm", "circling"],
91
+ morale: ["impaired", "irrelevant", "restructured", "sold separately"],
92
+ synergy: ["mandatory", "already billed", "non-consensual", "accretive"],
93
+ },
94
+ victorious: {
95
+ fees: ["captured", "celebrated", "fully earned", "ringing bell"],
96
+ mandate: ["ratified", "triumphant", "board-crowned", "unopposed"],
97
+ risk: ["conquered", "renamed upside", "priced in", "defeated"],
98
+ counsel: ["applauding", "comfortable", "drafting trophies", "filing confetti"],
99
+ morale: ["temporarily high", "marked up", "wearing medals", "overstated"],
100
+ synergy: ["declared", "victorious", "already billed", "banner-ready"],
101
+ },
31
102
  };
32
103
 
33
104
  function clampText(input: string, max: number): string {
@@ -51,98 +122,194 @@ function bodyLine(content: string, width: number): string {
51
122
  return `│ ${padToWidth(clampText(content, innerWidth), innerWidth)} │`;
52
123
  }
53
124
 
54
- function countLabel(messageCount: number, compact: boolean): string {
55
- if (compact) return `${messageCount} msg${messageCount === 1 ? "" : "s"}`;
56
- return `${messageCount} message${messageCount === 1 ? "" : "s"}`;
57
- }
58
-
59
- function latencyLabel(latencyMs: number | null | undefined): string | null {
60
- if (typeof latencyMs !== "number") return null;
61
- if (latencyMs < 1000) return `${Math.max(0, Math.round(latencyMs))}ms`;
62
- return `${(latencyMs / 1000).toFixed(1)}s`;
125
+ function memoLabel(messageCount: number): string {
126
+ const noun = "memo";
127
+ return `${messageCount} ${noun}${messageCount === 1 ? "" : "s"}`;
63
128
  }
64
129
 
65
130
  function tinyLine({
66
- model,
67
131
  messageCount,
68
132
  status,
69
133
  width,
70
134
  }: {
71
- model: string;
72
135
  messageCount: number;
73
136
  status: DealDeskHeaderStatus;
74
137
  width: number;
75
138
  }): string {
76
139
  return clampText(
77
- `${STATUS_LABEL[status]} ${countLabel(messageCount, true)} ${model}`,
140
+ `${BOARDROOM_STATUS[status]} ${memoLabel(messageCount)}`,
78
141
  width,
79
142
  );
80
143
  }
81
144
 
145
+ function pickFromMoodPool({
146
+ key,
147
+ mood,
148
+ salt,
149
+ }: {
150
+ key: DealDeskPoolKey;
151
+ mood: string;
152
+ salt: number;
153
+ }): string {
154
+ const pool = MOOD_POOLS[mood.toLowerCase()]?.[key] ?? DEFAULT_POOL[key];
155
+ return pool[Math.abs(salt) % pool.length] ?? DEFAULT_POOL[key][0];
156
+ }
157
+
158
+ function hashDealDesk(input: string): number {
159
+ let hash = 2166136261;
160
+ for (let idx = 0; idx < input.length; idx++) {
161
+ hash ^= input.charCodeAt(idx);
162
+ hash = Math.imul(hash, 16777619);
163
+ }
164
+ return hash >>> 0;
165
+ }
166
+
167
+ function formatCells(cells: string[], width: number): string {
168
+ const separator = " │ ";
169
+ const available = Math.max(
170
+ 1,
171
+ width - displayWidth(separator) * Math.max(0, cells.length - 1),
172
+ );
173
+ const base = Math.max(1, Math.floor(available / cells.length));
174
+ const remainder = Math.max(0, available - base * cells.length);
175
+ return cells
176
+ .map((cell, idx) => {
177
+ const cellWidth = base + (idx < remainder ? 1 : 0);
178
+ return padToWidth(clampText(cell, cellWidth), cellWidth);
179
+ })
180
+ .join(separator);
181
+ }
182
+
82
183
  function buildHeaderLines({
83
- model,
84
184
  mood,
85
185
  messageCount,
86
- themeName,
87
- approximateTokens,
88
- latencyMs,
89
- fallbackModel,
90
186
  status,
91
187
  compact,
92
188
  notice,
93
189
  width,
190
+ seed,
94
191
  }: {
95
- model: string;
96
192
  mood: string;
97
193
  messageCount: number;
98
- themeName?: string;
99
- approximateTokens?: number;
100
- latencyMs?: number | null;
101
- fallbackModel?: string | null;
102
194
  status: DealDeskHeaderStatus;
103
195
  compact: boolean;
104
196
  notice?: string;
105
197
  width: number;
198
+ seed: number;
106
199
  }): string[] {
107
- const statusLabel = STATUS_LABEL[status];
108
- const latency = latencyLabel(latencyMs);
109
- const top = compact
110
- ? shellLine("┌ Drexler ", "┐", width)
111
- : shellLine("┌ Drexler Deal Desk ", "┐", width);
112
-
200
+ const innerWidth = Math.max(1, width - 4);
201
+ const baseHash = hashDealDesk(`${mood}:${messageCount}:${status}:${seed}`);
202
+ const pick = (key: DealDeskPoolKey, offset: number) =>
203
+ pickFromMoodPool({ key, mood, salt: baseHash + offset * 7919 });
204
+ const statusLabel = BOARDROOM_STATUS[status];
113
205
  const summary = compact
114
- ? `● ${statusLabel} ${model} ${countLabel(messageCount, true)}${
115
- latency ? ` ${latency}` : ""
116
- }`
117
- : `● ${statusLabel} │ ${countLabel(
118
- messageCount,
119
- false,
120
- )} │ ~${approximateTokens ?? 0} tok │ ${latency ?? "no run yet"}`;
121
- const detail = `model ${model} │ mood ${mood} │ theme ${
122
- themeName ?? "apollo"
123
- }${fallbackModel ? ` │ fallback ${fallbackModel}` : ""}`;
124
- const lines = [top, bodyLine(summary, width)];
125
-
126
- if (!compact) {
127
- lines.push(bodyLine(detail, width));
128
- }
206
+ ? formatCells(
207
+ [`● ${statusLabel}`, `mood ${mood}`, `fees ${pick("fees", 1)}`],
208
+ innerWidth,
209
+ )
210
+ : formatCells(
211
+ [
212
+ `● ${statusLabel}`,
213
+ memoLabel(messageCount),
214
+ `fees ${pick("fees", 1)}`,
215
+ ],
216
+ innerWidth,
217
+ );
218
+ const readout = compact
219
+ ? formatCells(
220
+ [`risk ${pick("risk", 2)}`, `counsel ${pick("counsel", 3)}`],
221
+ innerWidth,
222
+ )
223
+ : formatCells(
224
+ [
225
+ `mandate ${pick("mandate", 4)}`,
226
+ `risk ${pick("risk", 5)}`,
227
+ `counsel ${pick("counsel", 6)}`,
228
+ ],
229
+ innerWidth,
230
+ );
231
+ const lines = [bodyLine(summary, width), bodyLine(readout, width)];
129
232
 
130
233
  if (!compact && notice && notice.trim().length > 0) {
131
- lines.push(bodyLine(`notice ${notice.trim()}`, width));
234
+ const memo = formatCells(
235
+ [
236
+ `memo ${notice.trim()}`,
237
+ `morale ${pick("morale", 7)}`,
238
+ `synergy ${pick("synergy", 8)}`,
239
+ ],
240
+ innerWidth,
241
+ );
242
+ lines.push(bodyLine(memo, width));
132
243
  }
133
244
 
134
- lines.push(shellLine("", "", width));
245
+ lines.push(shellLine("", "", width));
135
246
  return lines;
136
247
  }
137
248
 
249
+ function titleLabel(compact: boolean): string {
250
+ return compact ? "Drexler" : "Drexler Deal Desk";
251
+ }
252
+
253
+ function FramedTitleText({
254
+ compact,
255
+ borderColor,
256
+ titleColor,
257
+ width,
258
+ }: {
259
+ compact: boolean;
260
+ borderColor: string;
261
+ titleColor: string;
262
+ width: number;
263
+ }) {
264
+ const title = titleLabel(compact);
265
+ const prefix = "╭─ ";
266
+ const titleSuffix = " ";
267
+ const suffix = "╮";
268
+ const ruleWidth = Math.max(
269
+ 0,
270
+ width -
271
+ displayWidth(prefix) -
272
+ displayWidth(title) -
273
+ displayWidth(titleSuffix) -
274
+ displayWidth(suffix),
275
+ );
276
+ return (
277
+ <Text>
278
+ <Text color={borderColor}>{prefix}</Text>
279
+ <Text bold color={titleColor}>
280
+ {title}
281
+ </Text>
282
+ <Text color={borderColor}>
283
+ {titleSuffix}
284
+ {"─".repeat(ruleWidth)}
285
+ {suffix}
286
+ </Text>
287
+ </Text>
288
+ );
289
+ }
290
+
291
+ function FramedBodyText({
292
+ line,
293
+ borderColor,
294
+ contentColor,
295
+ }: {
296
+ line: string;
297
+ borderColor: string;
298
+ contentColor: string;
299
+ }) {
300
+ const content = line.length >= 4 ? line.slice(2, -2) : line;
301
+ return (
302
+ <Text>
303
+ <Text color={borderColor}>│ </Text>
304
+ <Text color={contentColor}>{content}</Text>
305
+ <Text color={borderColor}> │</Text>
306
+ </Text>
307
+ );
308
+ }
309
+
138
310
  function DealDeskHeaderInner({
139
- model,
140
311
  mood,
141
312
  messageCount,
142
- themeName,
143
- approximateTokens,
144
- latencyMs,
145
- fallbackModel,
146
313
  status = "idle",
147
314
  compact = false,
148
315
  notice,
@@ -151,6 +318,7 @@ function DealDeskHeaderInner({
151
318
  }: DealDeskHeaderProps) {
152
319
  const t = useTheme();
153
320
  const width = Math.max(MIN_WIDTH, Math.floor(maxWidth));
321
+ const randomSeed = useMemo(() => Math.floor(Math.random() * 1_000_000_000), []);
154
322
  const statusColor: Record<DealDeskHeaderStatus, string> = useMemo(
155
323
  () => ({
156
324
  idle: t.primaryLight,
@@ -159,32 +327,25 @@ function DealDeskHeaderInner({
159
327
  }),
160
328
  [t.error, t.primaryLight, t.warning],
161
329
  );
330
+ const summaryColor = status === "idle" ? t.text : statusColor[status];
162
331
  const lines = useMemo(
163
332
  () =>
164
333
  buildHeaderLines({
165
- model,
166
334
  mood,
167
335
  messageCount,
168
- themeName,
169
- approximateTokens,
170
- latencyMs,
171
- fallbackModel,
172
336
  status,
173
337
  compact,
174
338
  notice,
175
339
  width,
340
+ seed: randomSeed,
176
341
  }),
177
342
  [
178
- approximateTokens,
179
343
  compact,
180
- fallbackModel,
181
- latencyMs,
182
344
  messageCount,
183
- model,
184
345
  mood,
185
346
  notice,
347
+ randomSeed,
186
348
  status,
187
- themeName,
188
349
  width,
189
350
  ],
190
351
  );
@@ -193,7 +354,7 @@ function DealDeskHeaderInner({
193
354
  return (
194
355
  <Box width={width} marginBottom={marginBottom}>
195
356
  <Text color={statusColor[status]} wrap="truncate">
196
- {tinyLine({ model, messageCount, status, width })}
357
+ {tinyLine({ messageCount, status, width })}
197
358
  </Text>
198
359
  </Box>
199
360
  );
@@ -201,14 +362,26 @@ function DealDeskHeaderInner({
201
362
 
202
363
  return (
203
364
  <Box flexDirection="column" width={width} marginBottom={marginBottom}>
204
- <Text color={t.primaryDim}>{lines[0]}</Text>
205
- <Text color={statusColor[status]}>{lines[1]}</Text>
206
- {lines.slice(2, -1).map((line, index) => (
207
- <Text key={index} color={index === 0 ? t.primaryLight : t.dim}>
208
- {line}
209
- </Text>
365
+ <FramedTitleText
366
+ compact={compact}
367
+ borderColor={t.primary}
368
+ titleColor={t.primaryLight}
369
+ width={width}
370
+ />
371
+ <FramedBodyText
372
+ line={lines[0] ?? ""}
373
+ borderColor={t.primary}
374
+ contentColor={summaryColor}
375
+ />
376
+ {lines.slice(1, -1).map((line, index) => (
377
+ <FramedBodyText
378
+ key={index}
379
+ line={line}
380
+ borderColor={t.primary}
381
+ contentColor={index === 0 ? t.text : t.dim}
382
+ />
210
383
  ))}
211
- <Text color={t.primaryDim}>{lines[lines.length - 1]}</Text>
384
+ <Text color={t.primary}>{lines[lines.length - 1]}</Text>
212
385
  </Box>
213
386
  );
214
387
  }