@sayknow-cli/coding-agent 0.6.5 → 0.6.7

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 (69) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/keybindings.d.ts +5 -0
  3. package/dist/types/config/settings-schema.d.ts +79 -0
  4. package/dist/types/i18n/messages/en.d.ts +6 -29
  5. package/dist/types/modes/components/welcome.d.ts +34 -40
  6. package/dist/types/modes/interactive-mode.d.ts +7 -4
  7. package/dist/types/modes/types.d.ts +4 -0
  8. package/dist/types/sdk/broker/broker.d.ts +22 -0
  9. package/dist/types/sdk/broker/process-guard.d.ts +71 -0
  10. package/dist/types/sdk/broker/transport.d.ts +2 -0
  11. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  12. package/dist/types/session/agent-session.d.ts +9 -0
  13. package/dist/types/session/auth-storage-discovery.d.ts +15 -0
  14. package/dist/types/session/auto-fallback.d.ts +27 -0
  15. package/dist/types/session/fallback-chain-controller.d.ts +5 -0
  16. package/dist/types/session/response-language.d.ts +25 -0
  17. package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
  18. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  19. package/dist/types/tools/debug.d.ts +2 -2
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/locate-core.d.ts +97 -0
  22. package/dist/types/tools/locate.d.ts +38 -0
  23. package/package.json +7 -7
  24. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  25. package/src/cli/setup-cli.ts +7 -4
  26. package/src/commands/sdk.ts +3 -0
  27. package/src/commands/setup.ts +4 -1
  28. package/src/config/keybindings.ts +7 -0
  29. package/src/config/settings-schema.ts +82 -0
  30. package/src/decisions/typesafe-backend.ts +38 -4
  31. package/src/i18n/messages/de.settings.ts +26 -0
  32. package/src/i18n/messages/de.ts +4 -27
  33. package/src/i18n/messages/en.ts +6 -29
  34. package/src/i18n/messages/es.settings.ts +26 -0
  35. package/src/i18n/messages/es.ts +4 -27
  36. package/src/i18n/messages/fr.settings.ts +26 -0
  37. package/src/i18n/messages/fr.ts +4 -27
  38. package/src/i18n/messages/ja.settings.ts +26 -0
  39. package/src/i18n/messages/ja.ts +4 -27
  40. package/src/i18n/messages/ko.settings.ts +25 -0
  41. package/src/i18n/messages/ko.ts +5 -28
  42. package/src/i18n/messages/zh.settings.ts +23 -0
  43. package/src/i18n/messages/zh.ts +4 -27
  44. package/src/internal-urls/docs-index.generated.ts +8 -7
  45. package/src/modes/action-registry.ts +1 -0
  46. package/src/modes/components/welcome.ts +385 -387
  47. package/src/modes/controllers/input-controller.ts +15 -0
  48. package/src/modes/controllers/selector-controller.ts +13 -0
  49. package/src/modes/interactive-mode.ts +160 -98
  50. package/src/modes/types.ts +4 -0
  51. package/src/prompts/system/system-prompt.md +7 -2
  52. package/src/prompts/tools/locate.md +12 -0
  53. package/src/sdk/broker/broker.ts +75 -1
  54. package/src/sdk/broker/process-guard.ts +160 -0
  55. package/src/sdk/broker/transport.ts +15 -1
  56. package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
  57. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  58. package/src/sdk/session.ts +4 -1
  59. package/src/session/agent-session.ts +118 -2
  60. package/src/session/auth-storage-discovery.ts +22 -7
  61. package/src/session/auto-fallback.ts +59 -0
  62. package/src/session/fallback-chain-controller.ts +5 -0
  63. package/src/session/response-language.ts +71 -0
  64. package/src/setup/model-onboarding-guidance.ts +29 -14
  65. package/src/setup/provider-onboarding.ts +5 -0
  66. package/src/slash-commands/builtin-registry.ts +112 -2
  67. package/src/tools/index.ts +3 -0
  68. package/src/tools/locate-core.ts +720 -0
  69. package/src/tools/locate.ts +197 -0
@@ -1,5 +1,13 @@
1
1
  import type { ThinkingLevel } from "@sayknow-cli/agent-core";
2
- import { type Component, padding, truncateToWidth, visibleWidth } from "@sayknow-cli/tui";
2
+ import {
3
+ type Component,
4
+ type PetSkinId,
5
+ padding,
6
+ renderPetHalfBlocks,
7
+ type SayknowPixelFrameName,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ } from "@sayknow-cli/tui";
3
11
  import { formatBuildLabel } from "../../build-metadata";
4
12
  import { formatKeyHint, type KeyDisplayContext } from "../../config/keybindings";
5
13
  import { type MsgKey, t } from "../../i18n";
@@ -8,23 +16,13 @@ import { theme } from "../../modes/theme/theme";
8
16
  export interface RecentSession {
9
17
  name: string;
10
18
  timeAgo: string;
11
- }
12
-
13
- export interface LspServerInfo {
14
- name: string;
15
- status: "idle" | "ready" | "error" | "connecting";
16
- fileTypes: string[];
17
- }
18
-
19
- export interface WelcomeRoleBinding {
20
- role: string;
21
- model: string;
19
+ /** Session file; present when the row can be opened from the card. */
20
+ path?: string;
22
21
  }
23
22
 
24
23
  /**
25
- * Workspace and runtime facts shown in the launch ledger. Every field is
26
- * optional: the ledger renders what is known and fills the rest in as the
27
- * asynchronous probes (git status, context files, MCP) settle.
24
+ * Workspace facts on the launch card. Every field is optional: the card renders
25
+ * what is known and fills in git state once the asynchronous probe settles.
28
26
  */
29
27
  export interface WelcomeSnapshot {
30
28
  /** Display path of the project directory (already home-shortened). */
@@ -32,16 +30,7 @@ export interface WelcomeSnapshot {
32
30
  /** Current branch, `"detached"`, or `null` outside a git repository. */
33
31
  branch?: string | null;
34
32
  gitChanges?: { staged: number; unstaged: number; untracked: number } | null;
35
- /** Latest commits as `<short-sha> <subject>` onelines, newest first. */
36
- recentCommits?: readonly string[];
37
33
  thinkingLevel?: ThinkingLevel | string;
38
- /** Display name of the active model preset. */
39
- profile?: string;
40
- roles?: readonly WelcomeRoleBinding[];
41
- mcp?: { connected: number; total: number };
42
- skills?: number;
43
- /** Display names of the loaded project instruction files (AGENTS.md, …). */
44
- contextFiles?: readonly string[];
45
34
  }
46
35
 
47
36
  export type WelcomeLogoMode = "unicode" | "square" | "ascii";
@@ -50,77 +39,87 @@ export interface WelcomeComponentOptions {
50
39
  getReservedBottomRows?: (termWidth: number) => number;
51
40
  changelogMarkdown?: string;
52
41
  rightGutterWidth?: number;
42
+ /** Show only the version on the release line, without its first note. */
53
43
  collapseChangelog?: boolean;
54
44
  buildLabel?: string;
55
45
  keyDisplayContext?: KeyDisplayContext;
56
46
  skipLogoAnimation?: boolean;
57
47
  snapshot?: WelcomeSnapshot;
58
- /** Key bound to the resume picker (`app.session.resume`); the sessions heading names it. */
48
+ /** Key bound to the resume picker (`app.session.resume`); the card names it. */
59
49
  resumeKey?: string;
50
+ /** Key bound to `app.session.continue`, which resumes the first session on the card. */
51
+ continueKey?: string;
52
+ /** Which Sayknow pet stands on the card (default red). */
53
+ petSkin?: PetSkinId;
54
+ /** Called when a session row is opened by click or Enter. */
55
+ onOpenSession?: (session: RecentSession) => void;
60
56
  }
61
57
 
62
- /** Below this width the ledger and the activity column stack instead of sitting side by side. */
63
- const TWO_COLUMN_MIN_WIDTH = 100;
64
- const COLUMN_GAP = 4;
65
- const MIN_RIGHT_COLUMN = 36;
66
- const WHATS_NEW_ROWS = 3;
67
- /** Recent sessions on the launch screen; the resume picker has the rest. */
58
+ /** Left margin of the card, and the widest the card grows on wide terminals. */
59
+ const MARGIN = 2;
60
+ const MAX_CARD_WIDTH = 76;
61
+ /** Gap between the pet and the wordmark. */
62
+ const MARK_GAP = 3;
63
+ /** Recent sessions on the card; the resume picker has the rest. */
68
64
  const SESSION_ROWS = 3;
69
- const MAX_LSP_ROWS = 3;
70
- const MAX_COMMIT_ROWS = 3;
71
65
 
72
- /** Stagger between two ledger sections appearing during the launch reveal. */
73
- const SECTION_STAGGER_MS = 55;
74
- /** How long the whole ledger stays in dim ink before the first section takes its colors. */
75
- const SECTION_SETTLE_MS = 90;
66
+ /**
67
+ * The wordmark: SAYKNOW in the brand's box-drawing letters, then CLI. Each row is the
68
+ * SAYKNOW part and the CLI part, drawn in different colors.
69
+ */
70
+ // biome-ignore format: preserve letter layout
71
+ const WORDMARK: ReadonlyArray<readonly [string, string]> = [
72
+ ["╔═╗╔═╗╦ ╦╦╔═╔╗╔╔═╗╦ ╦", "╔═╗╦ ╦"],
73
+ ["╚═╗╠═╣╚╦╝╠╩╗║║║║ ║║║║", "║ ║ ║"],
74
+ ["╚═╝╩ ╩ ╩ ╩ ╩╝╚╝╚═╝╚╩╝", "╚═╝╩═╝╩"],
75
+ ];
76
+ const WORDMARK_GAP = 2;
77
+ const WORDMARK_WIDTH = 21 + WORDMARK_GAP + 7;
76
78
 
77
- function flowKeyItems(context: KeyDisplayContext): ReadonlyArray<{ key: string; label: string }> {
78
- const newlineKey = context.platform === "win32" ? "alt+enter" : "ctrl+j";
79
- return [
80
- { key: "/", label: "commands" },
81
- { key: "#", label: "actions" },
82
- { key: "!", label: "shell" },
83
- { key: "$", label: "python" },
84
- { key: "?", label: "keymap" },
85
- { key: "ctrl+l", label: "model" },
86
- { key: "shift+tab", label: "reasoning" },
87
- { key: "tab", label: "complete" },
88
- { key: newlineKey, label: "newline" },
89
- { key: "ctrl+c", label: "clear" },
90
- ];
91
- }
79
+ /** ASCII-safe stand-in for the pet when the banner must avoid block glyphs. */
80
+ const MARK_ASCII = [" .--. ", "( oo )", " ) ( ", "/\\/\\/\\"] as const;
81
+ const MARK_ASCII_WAVE = "\\/\\/\\/";
92
82
 
93
- const WORKFLOWS: ReadonlyArray<{ command: string; key: MsgKey }> = [
94
- { command: "/deep-interview", key: "welcome.wf.deepInterview" },
95
- { command: "/ralplan", key: "welcome.wf.ralplan" },
96
- { command: "/ultragoal", key: "welcome.wf.ultragoal" },
97
- { command: "/team", key: "welcome.wf.team" },
98
- ];
83
+ /**
84
+ * Intro: the pet does a short para-para (tentacles sway left, right, left) and
85
+ * lands on its resting pose while color spreads down the card.
86
+ */
87
+ const INTRO_POSES: readonly SayknowPixelFrameName[] = ["danceL", "danceR", "danceL", "base"];
88
+ const SECTION_STAGGER_MS = 55;
89
+ const SECTION_SETTLE_MS = 90;
90
+ const WAVE_STEP_MS = 140;
91
+ const INTRO_MS = INTRO_POSES.length * WAVE_STEP_MS;
99
92
 
100
- /** A block of rows revealed together during the launch intro. */
93
+ /** A block of rows that takes its colors together during the intro. */
101
94
  interface Section {
102
95
  lines: string[];
96
+ /** When the viewport is short, the section with the highest rank is dropped first. */
97
+ dropRank: number;
98
+ /** Session index per line, for rows that open a session. */
99
+ sessionRows?: Array<number | undefined>;
103
100
  }
104
101
 
105
102
  /**
106
- * Sayknow-CLI launch surface: an open, borderless ledger. The left column is
107
- * the state of this workspace — path, branch, model, reasoning, preset, role
108
- * agents, tooling — so the first screen answers "what am I about to run with".
109
- * The right column carries activity: what changed, recent sessions, workflows
110
- * and keys. No enclosing box and no hero wordmark: the octopus mark and the
111
- * facts carry the identity.
103
+ * Sayknow-CLI launch card: the wordmark with the pet beside it, who and where you are
104
+ * (version, model and reasoning, project and branch), the release line after an update,
105
+ * the three most recent sessions — which can be opened from the card with a click or
106
+ * ↓ then Enter — and a single row of keys.
112
107
  */
113
108
  export class WelcomeComponent implements Component {
114
109
  #animStart: number | null = null;
115
110
  #animTimer: NodeJS.Timeout | null = null;
116
111
  #snapshot: WelcomeSnapshot;
112
+ /** Highlighted session row while picking with the keyboard; undefined when not picking. */
113
+ #selected: number | undefined;
114
+ /** Session index per rendered line, from the last frame. */
115
+ #lineSessions: Array<number | undefined> = [];
116
+ #interactive = true;
117
117
 
118
118
  constructor(
119
119
  private readonly version: string,
120
120
  private modelName: string,
121
121
  private providerName: string,
122
122
  private recentSessions: RecentSession[] = [],
123
- private lspServers: LspServerInfo[] = [],
124
123
  private readonly logoMode: WelcomeLogoMode = "unicode",
125
124
  private readonly options: WelcomeComponentOptions = {},
126
125
  ) {
@@ -130,10 +129,8 @@ export class WelcomeComponent implements Component {
130
129
  invalidate(): void {}
131
130
 
132
131
  /**
133
- * Play a short one-shot reveal: sections appear top to bottom a few frames
134
- * apart, each settling from dim into its colors. Launch happens once per
135
- * session, so a sub-second reveal is affordable; it never blocks input.
136
- * Safe to call multiple times — subsequent calls reset and replay.
132
+ * Play the short launch intro. Content is fully readable from the first frame;
133
+ * only color and the tentacles move. Safe to call again — it restarts.
137
134
  */
138
135
  playIntro(requestRender: () => void): void {
139
136
  this.#stopAnimation();
@@ -145,9 +142,7 @@ export class WelcomeComponent implements Component {
145
142
  requestRender();
146
143
  this.#animTimer = setInterval(() => {
147
144
  const elapsed = performance.now() - (this.#animStart ?? 0);
148
- if (elapsed >= INTRO_MS) {
149
- this.#stopAnimation();
150
- }
145
+ if (elapsed >= INTRO_MS) this.#stopAnimation();
151
146
  requestRender();
152
147
  }, INTRO_TICK_MS);
153
148
  this.#animTimer.unref?.();
@@ -172,186 +167,256 @@ export class WelcomeComponent implements Component {
172
167
 
173
168
  setRecentSessions(sessions: RecentSession[]): void {
174
169
  this.recentSessions = sessions;
170
+ if (this.#selected !== undefined && this.#selected >= this.#openable().length) this.#selected = undefined;
175
171
  }
176
172
 
177
- setLspServers(servers: LspServerInfo[]): void {
178
- this.lspServers = servers;
179
- }
180
-
181
- /** Merge newly probed workspace facts into the ledger. */
173
+ /** Merge newly probed workspace facts into the card. */
182
174
  setSnapshot(patch: WelcomeSnapshot): void {
183
175
  this.#snapshot = { ...this.#snapshot, ...patch };
184
176
  }
185
177
 
178
+ // ── Picking a session ───────────────────────────────────────────────────
179
+
180
+ /** Sessions on the card that can be opened (they have a file). */
181
+ #openable(): RecentSession[] {
182
+ return this.recentSessions.slice(0, SESSION_ROWS).filter(session => session.path);
183
+ }
184
+
185
+ /** Rows that can be opened right now. Zero once the card stops taking input. */
186
+ get openableCount(): number {
187
+ return this.#interactive ? this.#openable().length : 0;
188
+ }
189
+
190
+ get selectedIndex(): number | undefined {
191
+ return this.#selected;
192
+ }
193
+
194
+ /** Stop offering the session rows (after the first prompt or a session switch). */
195
+ endInteraction(): void {
196
+ this.#interactive = false;
197
+ this.#selected = undefined;
198
+ }
199
+
200
+ get interactive(): boolean {
201
+ return this.#interactive;
202
+ }
203
+
204
+ /** Highlight a row, or clear the highlight with undefined. Returns false when there is nothing to select. */
205
+ select(index: number | undefined): boolean {
206
+ if (index === undefined) {
207
+ this.#selected = undefined;
208
+ return true;
209
+ }
210
+ const count = this.openableCount;
211
+ if (count === 0) return false;
212
+ this.#selected = Math.max(0, Math.min(count - 1, index));
213
+ return true;
214
+ }
215
+
216
+ /** Move the highlight; returns false when it would leave the list upward (the caller hands focus back). */
217
+ moveSelection(delta: number): boolean {
218
+ if (this.#selected === undefined) return this.select(0);
219
+ const next = this.#selected + delta;
220
+ if (next < 0) {
221
+ this.#selected = undefined;
222
+ return false;
223
+ }
224
+ this.#selected = Math.min(this.openableCount - 1, next);
225
+ return true;
226
+ }
227
+
228
+ /** Open the highlighted row. */
229
+ openSelected(): boolean {
230
+ const session = this.#selected === undefined ? undefined : this.#openable()[this.#selected];
231
+ if (!session) return false;
232
+ this.options.onOpenSession?.(session);
233
+ return true;
234
+ }
235
+
236
+ handleClick(line: number): boolean {
237
+ if (!this.#interactive) return false;
238
+ const index = this.#lineSessions[line];
239
+ const session = index === undefined ? undefined : this.#openable()[index];
240
+ if (!session) return false;
241
+ this.#selected = index;
242
+ this.options.onOpenSession?.(session);
243
+ return true;
244
+ }
245
+
246
+ // ── Layout ──────────────────────────────────────────────────────────────
247
+
186
248
  render(termWidth: number): string[] {
249
+ this.#lineSessions = [];
187
250
  const gutterWidth = this.#rightGutterWidth(termWidth);
188
251
  const width = Math.max(0, termWidth - gutterWidth);
189
252
  if (width < 4) return [];
190
253
 
191
254
  const targetRows = this.#targetRows(termWidth);
192
255
  if (targetRows !== undefined && targetRows <= 0) return [];
256
+ if (targetRows === 1) return this.#withRightGutter([this.#fit(this.#titleLine(true), width)], gutterWidth);
257
+
258
+ const cardWidth = Math.max(1, Math.min(MAX_CARD_WIDTH, width - MARGIN));
259
+ const sections = this.#sections(cardWidth);
260
+ const { lines, sessionRows } = this.#fitRows(sections, targetRows);
261
+ const margin = padding(MARGIN);
262
+ const selectedLine = sessionRows.findIndex(index => index !== undefined && index === this.#selected);
263
+ const out = lines.map((line, row) => {
264
+ if (row === selectedLine) return this.#highlightRow(line, cardWidth, width);
265
+ return this.#fit(line ? margin + line : "", width);
266
+ });
267
+ if (targetRows !== undefined) while (out.length < targetRows) out.push(padding(width));
268
+ this.#lineSessions = sessionRows;
269
+ return this.#withRightGutter(out, gutterWidth);
270
+ }
271
+
272
+ /** The picked row: a selection band across the card width, held after every reset inside the row. */
273
+ #highlightRow(line: string, cardWidth: number, width: number): string {
274
+ const bg = theme.getBgAnsi("selectedBg");
275
+ const body = this.#fit(line, cardWidth).replace(/\x1b\[(?:0)?m|\x1b\[49m/g, match => `${match}${bg}`);
276
+ return this.#fit(`${padding(MARGIN)}${bg}${body}\x1b[0m`, width);
277
+ }
193
278
 
194
- const header = this.#fitToWidth(this.#headerLine(width), width);
195
- if (targetRows === 1) return this.#withRightGutter([header], gutterWidth);
196
-
197
- const rule = theme.fg("borderMuted", this.#ruleGlyph().repeat(width));
198
- const bodyRows = targetRows === undefined ? undefined : Math.max(0, targetRows - 2);
199
-
200
- const twoColumn = width >= TWO_COLUMN_MIN_WIDTH;
201
- const leftWidth = twoColumn
202
- ? Math.min(Math.max(44, Math.floor(width * 0.52)), width - COLUMN_GAP - MIN_RIGHT_COLUMN)
203
- : width;
204
- const rightWidth = twoColumn ? width - leftWidth - COLUMN_GAP : width;
205
-
206
- const ledger = this.#ledgerSections(leftWidth);
207
- const activity = this.#activitySections(rightWidth);
208
-
209
- const reveal = this.#revealState(ledger.length + activity.length);
210
- const leftLines = this.#revealLines(ledger, 0, reveal);
211
- const rightLines = this.#revealLines(activity, ledger.length, reveal);
212
-
213
- const body: string[] = [];
214
- if (twoColumn) {
215
- const rows = bodyRows ?? Math.max(leftLines.length, rightLines.length);
216
- const left = this.#clip(leftLines, rows);
217
- const right = this.#clip(rightLines, rows);
218
- const gap = padding(COLUMN_GAP);
219
- for (let i = 0; i < rows; i++) {
220
- body.push(this.#fitToWidth(left[i] ?? "", leftWidth) + gap + this.#fitToWidth(right[i] ?? "", rightWidth));
279
+ /**
280
+ * Lay the sections out with a blank row above and between them. When the
281
+ * viewport is short, whole sections go by rank — keys, then the release
282
+ * note, then sessions — and the identity block is clipped last.
283
+ */
284
+ #fitRows(
285
+ sections: { identity: string[]; others: Section[] },
286
+ targetRows: number | undefined,
287
+ ): { lines: string[]; sessionRows: Array<number | undefined> } {
288
+ const colored = this.#colorReveal(1 + sections.others.length);
289
+ const identity = this.#inkSection(sections.identity, 0, colored);
290
+ let others = sections.others.map((section, index) => ({
291
+ ...section,
292
+ lines: this.#inkSection(section.lines, index + 1, colored),
293
+ }));
294
+ const assemble = (): { lines: string[]; sessionRows: Array<number | undefined> } => {
295
+ const lines = ["", ...identity];
296
+ const sessionRows: Array<number | undefined> = lines.map(() => undefined);
297
+ for (const section of others) {
298
+ lines.push("", ...section.lines);
299
+ sessionRows.push(undefined, ...(section.sessionRows ?? section.lines.map(() => undefined)));
221
300
  }
222
- } else {
223
- const stacked = [...leftLines, "", ...rightLines];
224
- const rows = bodyRows ?? stacked.length;
225
- for (const line of this.#clip(stacked, rows)) body.push(this.#fitToWidth(line, width));
226
- while (body.length < rows) body.push(padding(width));
301
+ return { lines, sessionRows };
302
+ };
303
+ let result = assemble();
304
+ if (targetRows === undefined) return result;
305
+ while (result.lines.length > targetRows && others.length > 0) {
306
+ const drop = others.reduce((worst, section) => (section.dropRank > worst.dropRank ? section : worst));
307
+ others = others.filter(section => section !== drop);
308
+ result = assemble();
227
309
  }
310
+ return { lines: result.lines.slice(0, targetRows), sessionRows: result.sessionRows.slice(0, targetRows) };
311
+ }
312
+
313
+ #sections(cardWidth: number): { identity: string[]; others: Section[] } {
314
+ const others: Section[] = [];
315
+ const note = this.#releaseNoteLine(cardWidth);
316
+ if (note) others.push({ lines: [note], dropRank: 2 });
317
+ const sessions = this.#sessionSection(cardWidth);
318
+ others.push({ ...sessions, dropRank: 1 });
319
+ others.push({ lines: [this.#keysLine(cardWidth)], dropRank: 3 });
320
+ return { identity: this.#identityLines(cardWidth), others };
321
+ }
322
+
323
+ // ── Identity: pet + wordmark, then who and where ────────────────────────
324
+
325
+ #identityLines(cardWidth: number): string[] {
326
+ const mark = this.#markRows();
327
+ const markWidth = Math.max(0, ...mark.map(row => visibleWidth(row)));
328
+ const withMark = cardWidth >= markWidth + MARK_GAP + WORDMARK_WIDTH;
329
+ const withWordmark = this.logoMode !== "ascii" && cardWidth >= WORDMARK_WIDTH;
330
+ const indent = withMark ? markWidth + MARK_GAP : 0;
331
+ const infoWidth = Math.max(1, cardWidth - indent);
332
+
333
+ const head: string[] = withWordmark
334
+ ? WORDMARK.map(
335
+ ([word, suffix]) =>
336
+ `${theme.bold(theme.fg("accent", word))}${padding(WORDMARK_GAP)}${theme.fg("text", suffix)}`,
337
+ )
338
+ : [this.#truncate(this.#titleLine(!withMark), infoWidth)];
339
+ const versionLine = this.#spread(
340
+ theme.fg("muted", t("welcome.tagline")),
341
+ theme.fg("dim", this.#versionLabel()),
342
+ infoWidth,
343
+ );
344
+ const info = [...head, withWordmark ? versionLine : theme.fg("muted", t("welcome.tagline"))];
228
345
 
229
- return this.#withRightGutter([header, rule, ...body], gutterWidth);
346
+ const rows: string[] = [];
347
+ if (withMark) {
348
+ const count = Math.max(mark.length, info.length);
349
+ for (let row = 0; row < count; row++) {
350
+ const art = mark[row] ?? padding(markWidth);
351
+ const text = info[row] ?? "";
352
+ rows.push(text ? `${art}${padding(MARK_GAP)}${text}` : art);
353
+ }
354
+ } else rows.push(...info);
355
+ // Model and place sit under the wordmark, in the same column.
356
+ for (const line of this.#statusLines(infoWidth)) rows.push(`${padding(indent)}${line}`);
357
+ return rows;
230
358
  }
231
359
 
232
- // ── Header ──────────────────────────────────────────────────────────────
233
-
234
- #headerLine(width: number): string {
360
+ #versionLabel(): string {
235
361
  const buildLabel = this.options.buildLabel ?? formatBuildLabel();
236
- const mark = theme.icon.pi ? `${theme.icon.pi} ` : "";
237
- const left = ` ${mark}${theme.bold(theme.fg("text", "Sayknow-CLI"))}${theme.fg("dim", ` v${this.version} · ${buildLabel}`)}`;
238
- const tagline = theme.fg("muted", t("welcome.tagline"));
239
- const room = width - visibleWidth(left) - visibleWidth(tagline) - 1;
240
- return room >= 2 ? `${left}${padding(room)}${tagline} ` : left;
362
+ return `v${this.version} · ${buildLabel}`;
241
363
  }
242
364
 
243
- #ruleGlyph(): string {
244
- return this.logoMode === "ascii" ? "-" : "─";
365
+ /** The Sayknow pet at half size, in half blocks; the pose follows the intro. */
366
+ #markRows(): string[] {
367
+ const step = this.#animStart == null ? -1 : Math.floor((performance.now() - this.#animStart) / WAVE_STEP_MS);
368
+ if (this.logoMode === "ascii") {
369
+ const rows: string[] = [...MARK_ASCII];
370
+ if (step >= 0 && step < INTRO_POSES.length - 1 && step % 2 === 1) rows[3] = MARK_ASCII_WAVE;
371
+ return rows.map(row => theme.fg("accent", row));
372
+ }
373
+ const pose = step >= 0 && step < INTRO_POSES.length ? INTRO_POSES[step]! : "base";
374
+ return renderPetHalfBlocks(pose, this.options.petSkin ?? "red", theme.getColorMode(), { scale: "half" });
245
375
  }
246
376
 
247
- // ── Left column: workspace ledger ───────────────────────────────────────
248
-
249
- #ledgerSections(width: number): Section[] {
250
- const labels = {
251
- workspace: t("welcome.label.workspace"),
252
- branch: t("welcome.label.branch"),
253
- commits: t("welcome.label.commits"),
254
- model: t("welcome.label.model"),
255
- reasoning: t("welcome.label.reasoning"),
256
- preset: t("welcome.label.preset"),
257
- roles: t("welcome.label.roles"),
258
- tools: t("welcome.label.tools"),
259
- };
260
- const labelWidth = Math.max(...Object.values(labels).map(label => visibleWidth(label))) + 2;
261
- const valueWidth = Math.max(1, width - labelWidth - 1);
262
- const row = (label: string, value: string): string =>
263
- ` ${theme.fg("dim", label)}${padding(Math.max(0, labelWidth - visibleWidth(label)))}${this.#truncate(value, valueWidth)}`;
264
- const continuation = (value: string): string => ` ${padding(labelWidth)}${this.#truncate(value, valueWidth)}`;
265
- const snapshot = this.#snapshot;
266
- const sep = theme.fg("dim", " · ");
267
-
268
- // Where
269
- const where: string[] = [];
270
- if (snapshot.cwd)
271
- where.push(row(labels.workspace, theme.fg("statusLinePath", this.#shortenFromLeft(snapshot.cwd, valueWidth))));
272
- if (snapshot.branch !== undefined) {
273
- const branch =
274
- snapshot.branch === null
275
- ? theme.fg("dim", t("welcome.noGit"))
276
- : `${theme.fg(this.#isDirty() ? "statusLineGitDirty" : "statusLineGitClean", snapshot.branch)}${this.#gitChangeSummary(sep)}`;
277
- where.push(row(labels.branch, branch));
278
- }
279
- (snapshot.recentCommits ?? []).slice(0, MAX_COMMIT_ROWS).forEach((oneline, index) => {
280
- const space = oneline.indexOf(" ");
281
- const value =
282
- space > 0
283
- ? `${theme.fg("accent", oneline.slice(0, space))} ${theme.fg("muted", oneline.slice(space + 1))}`
284
- : theme.fg("muted", oneline);
285
- where.push(index === 0 ? row(labels.commits, value) : continuation(value));
286
- });
377
+ /** One-line title for layouts without room for the wordmark. */
378
+ #titleLine(withIcon = false): string {
379
+ const mark = withIcon && this.logoMode !== "ascii" && theme.icon.pi ? `${theme.icon.pi} ` : "";
380
+ return `${mark}${theme.bold(theme.fg("accent", "Sayknow-CLI"))}${theme.fg("dim", ` ${this.#versionLabel()}`)}`;
381
+ }
287
382
 
288
- // Brain
289
- const brain: string[] = [];
290
- const hasModel = this.modelName !== "Unknown" && this.modelName.length > 0;
291
- brain.push(
292
- row(
293
- labels.model,
294
- hasModel
295
- ? `${theme.bold(theme.fg("statusLineModel", this.modelName))}${sep}${theme.fg("muted", this.providerName)}`
296
- : `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`,
297
- ),
298
- );
299
- if (snapshot.thinkingLevel) {
300
- const level = String(snapshot.thinkingLevel);
301
- brain.push(row(labels.reasoning, theme.getThinkingBorderColor(level as ThinkingLevel)(level)));
302
- }
303
- brain.push(row(labels.preset, snapshot.profile ? theme.fg("text", snapshot.profile) : theme.fg("dim", "—")));
304
- const roles = snapshot.roles ?? [];
305
- if (roles.length === 0) {
306
- brain.push(row(labels.roles, theme.fg("dim", t("welcome.rolesInherit"))));
307
- } else {
308
- const roleWidth = Math.max(...roles.map(role => visibleWidth(role.role))) + 2;
309
- roles.forEach((binding, index) => {
310
- const value = `${theme.fg("muted", binding.role)}${padding(roleWidth - visibleWidth(binding.role))}${theme.fg("text", binding.model)}`;
311
- brain.push(index === 0 ? row(labels.roles, value) : continuation(value));
312
- });
313
- }
383
+ /**
384
+ * Model and reasoning, then project path and branch: on one row when both fit whole,
385
+ * otherwise the place gets its own row rather than being cut to nothing.
386
+ */
387
+ #statusLines(width: number): string[] {
388
+ const model = this.#truncate(this.#modelPart(), width);
389
+ const sep = theme.fg("dim", " · ");
390
+ const fullWhere = this.#wherePart(Number.POSITIVE_INFINITY);
391
+ if (!fullWhere) return [model];
392
+ if (visibleWidth(model) + visibleWidth(sep) + visibleWidth(fullWhere) <= width)
393
+ return [`${model}${sep}${fullWhere}`];
394
+ return [model, this.#wherePart(width)];
395
+ }
314
396
 
315
- // Hands
316
- const hands: string[] = [];
317
- const toolFacts: string[] = [];
318
- if (snapshot.mcp) {
319
- const { connected, total } = snapshot.mcp;
320
- const color = total === 0 ? "dim" : connected === total ? "success" : "warning";
321
- toolFacts.push(`${theme.fg("muted", "MCP")} ${theme.fg(color, total === 0 ? "0" : `${connected}/${total}`)}`);
322
- }
323
- if (snapshot.skills !== undefined) {
324
- toolFacts.push(`${theme.fg("muted", t("welcome.skills"))} ${theme.fg("text", String(snapshot.skills))}`);
325
- }
326
- if (snapshot.contextFiles !== undefined) {
327
- const files = snapshot.contextFiles;
328
- const shown = files.length === 0 ? theme.fg("dim", "—") : theme.fg("text", files[0]!);
329
- const more = files.length > 1 ? theme.fg("dim", ` +${files.length - 1}`) : "";
330
- toolFacts.push(`${theme.fg("muted", t("welcome.rules"))} ${shown}${more}`);
397
+ #modelPart(): string {
398
+ const sep = theme.fg("dim", " · ");
399
+ if (this.modelName === "Unknown" || this.modelName.length === 0) {
400
+ return `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`;
331
401
  }
332
- const lspLines = this.#lspLines();
333
- const toolRows = [...(toolFacts.length > 0 ? [toolFacts.join(sep)] : []), ...lspLines];
334
- toolRows.forEach((value, index) => {
335
- hands.push(index === 0 ? row(labels.tools, value) : continuation(value));
336
- });
337
-
338
- return [where, brain, hands].filter(lines => lines.length > 0).map(lines => ({ lines: [...lines, ""] }));
402
+ const parts = [theme.bold(theme.fg("statusLineModel", this.modelName))];
403
+ const level = this.#snapshot.thinkingLevel;
404
+ if (level) parts.push(theme.getThinkingBorderColor(String(level) as ThinkingLevel)(String(level)));
405
+ else if (this.providerName) parts.push(theme.fg("muted", this.providerName));
406
+ return parts.join(sep);
339
407
  }
340
408
 
341
- #lspLines(): string[] {
342
- if (this.lspServers.length === 0) return [theme.fg("dim", t("welcome.noLsp"))];
343
- const lines = this.lspServers.slice(0, MAX_LSP_ROWS).map(server => {
344
- const icon =
345
- server.status === "ready"
346
- ? theme.styledSymbol("status.success", "success")
347
- : server.status === "error"
348
- ? theme.styledSymbol("status.error", "error")
349
- : theme.styledSymbol("status.pending", "muted");
350
- return `${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", server.fileTypes.slice(0, 3).join(" "))}`;
351
- });
352
- const hidden = this.lspServers.length - MAX_LSP_ROWS;
353
- if (hidden > 0) lines.push(theme.fg("dim", `+${hidden} LSP`));
354
- return lines;
409
+ #wherePart(width: number): string {
410
+ const { cwd, branch } = this.#snapshot;
411
+ const sep = theme.fg("dim", " · ");
412
+ const branchPart =
413
+ typeof branch === "string"
414
+ ? `${theme.fg(this.#isDirty() ? "statusLineGitDirty" : "statusLineGitClean", branch)}${this.#changeSummary()}`
415
+ : "";
416
+ if (!cwd) return branchPart;
417
+ const pathBudget = Math.max(8, width - (branchPart ? visibleWidth(branchPart) + 3 : 0));
418
+ const path = theme.fg("statusLinePath", this.#shortenFromLeft(cwd, pathBudget));
419
+ return branchPart ? `${path}${sep}${branchPart}` : path;
355
420
  }
356
421
 
357
422
  #isDirty(): boolean {
@@ -359,144 +424,101 @@ export class WelcomeComponent implements Component {
359
424
  return !!changes && changes.staged + changes.unstaged + changes.untracked > 0;
360
425
  }
361
426
 
362
- #gitChangeSummary(sep: string): string {
427
+ #changeSummary(): string {
363
428
  const changes = this.#snapshot.gitChanges;
364
- if (changes === undefined) return "";
365
- if (changes === null) return "";
366
- if (!this.#isDirty()) return `${sep}${theme.fg("dim", t("welcome.clean"))}`;
429
+ if (!changes || !this.#isDirty()) return "";
367
430
  const parts: string[] = [];
368
431
  if (changes.staged > 0) parts.push(theme.fg("statusLineStaged", `+${changes.staged}`));
369
432
  if (changes.unstaged > 0) parts.push(theme.fg("statusLineDirty", `~${changes.unstaged}`));
370
433
  if (changes.untracked > 0) parts.push(theme.fg("statusLineUntracked", `?${changes.untracked}`));
371
- return `${sep}${parts.join(" ")}`;
434
+ return ` ${parts.join(" ")}`;
372
435
  }
373
436
 
374
- // ── Right column: activity ──────────────────────────────────────────────
437
+ // ── Release note, sessions, keys ────────────────────────────────────────
375
438
 
376
439
  /**
377
- * The launch screen stays short on purpose: three recent sessions and three
378
- * notes, never more. The full session list lives in the resume picker, and the
379
- * sessions heading names the key that opens it.
440
+ * A section header that is also the divider: the label, a hairline rule filling the
441
+ * row, and hints at the right end.
380
442
  */
381
- #activitySections(width: number): Section[] {
382
- const keyRows = this.#flowKeyRows(width);
383
- const heading = (label: string, note?: string): string =>
384
- ` ${theme.bold(theme.fg("accent", label))}${note ? theme.fg("dim", ` ${note}`) : ""}`;
385
- const whatsNew = this.#whatsNewLines(width, WHATS_NEW_ROWS);
386
-
387
- const changelog = this.options.changelogMarkdown?.trim();
388
- const version = changelog ? this.#latestChangelogVersion(changelog) : undefined;
389
- const context = this.options.keyDisplayContext ?? { platform: process.platform };
390
- const resumeKey = this.options.resumeKey ? formatKeyHint(this.options.resumeKey, context) : "/resume";
391
- const sessionsNote = this.recentSessions.length > 0 ? t("welcome.allSessions", { key: resumeKey }) : undefined;
392
-
393
- const workflowWidth = Math.max(...WORKFLOWS.map(item => visibleWidth(item.command))) + 2;
394
- return [
395
- { lines: [heading(t("welcome.sessionTrail"), sessionsNote), ...this.#sessionTrailLines(width), ""] },
396
- { lines: [heading(t("welcome.whatsNew"), version ? `v${version}` : undefined), ...whatsNew, ""] },
397
- {
398
- lines: [
399
- heading(t("welcome.workflows")),
400
- ...WORKFLOWS.map(
401
- item =>
402
- ` ${theme.fg("accent", item.command)}${padding(workflowWidth - visibleWidth(item.command))}${theme.fg("muted", t(item.key))}`,
403
- ),
404
- "",
405
- ],
406
- },
407
- { lines: [heading(t("welcome.flowKeys")), ...keyRows] },
408
- ];
409
- }
410
-
411
- #flowKeyItemText(item: { key: string; label: string }): string {
412
- const context = this.options.keyDisplayContext ?? { platform: process.platform };
413
- return `${theme.fg("text", formatKeyHint(item.key, context))}${theme.fg("dim", ` ${this.#flowKeyLabel(item.label)}`)}`;
414
- }
415
-
416
- #flowKeyLabel(label: string): string {
417
- switch (label) {
418
- case "commands":
419
- return t("welcome.commands");
420
- case "actions":
421
- return t("welcome.actions");
422
- case "shell":
423
- return t("welcome.shell");
424
- case "python":
425
- return t("welcome.python");
426
- case "keymap":
427
- return t("welcome.keymap");
428
- case "model":
429
- return t("welcome.model");
430
- case "reasoning":
431
- return t("welcome.reasoning");
432
- default:
433
- return label;
434
- }
443
+ #ruleHeader(label: string, hint: string, width: number): string {
444
+ const left = `${label} `;
445
+ const right = hint ? ` ${hint}` : "";
446
+ const fill = width - visibleWidth(left) - visibleWidth(right);
447
+ if (fill < 3) return this.#truncate(`${label}${right}`, width);
448
+ return `${left}${theme.fg("borderMuted", "─".repeat(fill))}${right}`;
435
449
  }
436
450
 
437
- #flowKeyRows(width: number): string[] {
438
- const contentWidth = Math.max(1, width - 2);
439
- const separator = theme.fg("dim", " ");
440
- const rows: string[] = [];
441
- let current = "";
442
- for (const item of flowKeyItems(this.options.keyDisplayContext ?? { platform: process.platform })) {
443
- const segment = this.#flowKeyItemText(item);
444
- const next = current ? `${current}${separator}${segment}` : segment;
445
- if (current && visibleWidth(next) > contentWidth) {
446
- rows.push(` ${current}`);
447
- current = segment;
448
- } else {
449
- current = next;
450
- }
451
- }
452
- if (current) rows.push(` ${current}`);
453
- return rows;
451
+ /** One line, only after an update: the new version and its first note, pointing at /changelog. */
452
+ #releaseNoteLine(width: number): string | undefined {
453
+ const changelog = this.options.changelogMarkdown?.trim();
454
+ if (!changelog) return undefined;
455
+ const version = this.#latestChangelogVersion(changelog);
456
+ const lead = theme.bold(theme.fg("accent", `v${version}`));
457
+ const pointer = theme.fg("dim", "/changelog");
458
+ const first = this.options.collapseChangelog ? undefined : this.#changelogItems(changelog)[0];
459
+ const room = width - visibleWidth(lead) - visibleWidth(pointer) - 8;
460
+ const label = first && room >= 12 ? `${lead} ${theme.fg("muted", this.#truncate(first, room))}` : lead;
461
+ return this.#ruleHeader(label, pointer, width);
454
462
  }
455
463
 
456
- #sessionTrailLines(width: number): string[] {
464
+ #sessionSection(width: number): Pick<Section, "lines" | "sessionRows"> {
465
+ const heading = theme.bold(theme.fg("accent", t("welcome.sessionTrail")));
457
466
  if (this.recentSessions.length === 0) {
458
- return [` ${theme.fg("dim", t("welcome.noSessions"))}`];
467
+ return { lines: [this.#ruleHeader(heading, "", width), ` ${theme.fg("dim", t("welcome.noSessions"))}`] };
459
468
  }
460
- const lines: string[] = [];
461
- for (const session of this.recentSessions.slice(0, SESSION_ROWS)) {
469
+ const context = this.options.keyDisplayContext ?? { platform: process.platform };
470
+ const picking = this.#selected !== undefined;
471
+ const hints = picking
472
+ ? [t("welcome.pickActive")]
473
+ : [
474
+ ...(this.openableCount > 0 ? [t("welcome.pick", { key: "↓" })] : []),
475
+ ...(this.options.continueKey
476
+ ? [t("welcome.continue", { key: formatKeyHint(this.options.continueKey, context) })]
477
+ : []),
478
+ t("welcome.allSessions", { key: this.#resumeKey() }),
479
+ ];
480
+ const lines = [this.#ruleHeader(heading, theme.fg("dim", hints.join(" · ")), width)];
481
+ const sessionRows: Array<number | undefined> = [undefined];
482
+ const openable = this.#openable();
483
+ this.recentSessions.slice(0, SESSION_ROWS).forEach((session, index) => {
484
+ const openIndex = openable.indexOf(session);
485
+ const selected = picking && openIndex === this.#selected;
486
+ // Idle: the first row is the one the continue key resumes. Picking: the highlighted row.
487
+ const marked = picking ? selected : index === 0 && !!this.options.continueKey;
488
+ const lead = marked ? theme.fg("accent", "› ") : " ";
462
489
  const time = theme.fg("dim", session.timeAgo);
463
- const nameBudget = Math.max(1, width - 2 - visibleWidth(session.timeAgo) - 2);
464
- const name = this.#truncate(session.name, nameBudget);
465
- const pad = padding(Math.max(1, width - 2 - visibleWidth(name) - visibleWidth(session.timeAgo)));
466
- lines.push(` ${theme.fg("muted", name)}${pad}${time}`);
467
- }
468
- return lines;
490
+ const nameWidth = Math.max(1, width - visibleWidth(lead) - visibleWidth(time) - 2);
491
+ const tone = selected ? "text" : picking ? "muted" : index === 0 ? "text" : "muted";
492
+ const name = this.#truncate(session.name, nameWidth);
493
+ lines.push(
494
+ this.#spread(`${lead}${selected ? theme.bold(theme.fg(tone, name)) : theme.fg(tone, name)}`, time, width),
495
+ );
496
+ sessionRows.push(openIndex >= 0 ? openIndex : undefined);
497
+ });
498
+ return { lines, sessionRows };
469
499
  }
470
500
 
471
- #whatsNewLines(width: number, maxRows: number): string[] {
472
- const rowLimit = Math.max(1, Math.floor(maxRows));
473
- const changelog = this.options.changelogMarkdown?.trim();
474
- if (!changelog) return [` ${theme.fg("dim", t("welcome.readyPrompt"))}`];
475
-
476
- const version = this.#latestChangelogVersion(changelog);
477
- const items = this.options.collapseChangelog ? [] : this.#changelogItems(changelog);
478
- if (items.length === 0) {
479
- return [
480
- ` ${theme.fg("muted", `Updated to v${version}`)}`,
481
- ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
482
- ].slice(0, rowLimit);
483
- }
501
+ #keysLine(width: number): string {
502
+ const context = this.options.keyDisplayContext ?? { platform: process.platform };
503
+ const items: ReadonlyArray<{ key: string; label: MsgKey }> = [
504
+ { key: "/", label: "welcome.commands" },
505
+ { key: this.#resumeKey(), label: "welcome.sessions" },
506
+ { key: formatKeyHint("ctrl+l", context), label: "welcome.model" },
507
+ { key: "?", label: "welcome.keymap" },
508
+ ];
509
+ const text = items
510
+ .map(item => `${theme.fg("accent", item.key)} ${theme.fg("dim", t(item.label))}`)
511
+ .join(theme.fg("borderMuted", " · "));
512
+ return this.#truncate(text, width);
513
+ }
484
514
 
485
- const bullet = ` ${theme.md.bullet} `;
486
- const textWidth = Math.max(1, width - visibleWidth(bullet));
487
- const visibleCount = items.length > rowLimit ? Math.max(1, rowLimit - 1) : rowLimit;
488
- const lines = items
489
- .slice(0, visibleCount)
490
- .map(item => `${theme.fg("accent", bullet)}${theme.fg("muted", this.#truncate(item, textWidth))}`);
491
- if (items.length > lines.length && lines.length < rowLimit) {
492
- lines.push(` ${theme.fg("dim", `… ${theme.bold("/changelog")} for full notes`)}`);
493
- }
494
- return lines;
515
+ #resumeKey(): string {
516
+ const context = this.options.keyDisplayContext ?? { platform: process.platform };
517
+ return this.options.resumeKey ? formatKeyHint(this.options.resumeKey, context) : "/resume";
495
518
  }
496
519
 
497
520
  #latestChangelogVersion(markdown: string): string {
498
- const versionMatch = markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
499
- return versionMatch?.[1] ?? this.version;
521
+ return markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/)?.[1] ?? this.version;
500
522
  }
501
523
 
502
524
  #changelogItems(markdown: string): string[] {
@@ -509,60 +531,41 @@ export class WelcomeComponent implements Component {
509
531
  continue;
510
532
  }
511
533
  if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) continue;
512
- const withoutBullet = line
534
+ const cleaned = line
513
535
  .replace(/^[-*]\s+/, "")
514
536
  .replace(/^\d+\.\s+/, "")
515
- .replace(/^>\s*/, "");
516
- const cleaned = this.#stripMarkdown(withoutBullet);
537
+ .replace(/^>\s*/, "")
538
+ .replace(/`([^`]+)`/g, "$1")
539
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
540
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
541
+ .replace(/__([^_]+)__/g, "$1")
542
+ .replace(/\*([^*]+)\*/g, "$1")
543
+ .replace(/[_~]/g, "")
544
+ .trim();
517
545
  if (cleaned) items.push(cleaned);
518
546
  }
519
547
  return items;
520
548
  }
521
549
 
522
- #stripMarkdown(text: string): string {
523
- return text
524
- .replace(/`([^`]+)`/g, "$1")
525
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
526
- .replace(/\*\*([^*]+)\*\*/g, "$1")
527
- .replace(/__([^_]+)__/g, "$1")
528
- .replace(/\*([^*]+)\*/g, "$1")
529
- .replace(/[_~]/g, "")
530
- .trim();
531
- }
550
+ // ── Intro ───────────────────────────────────────────────────────────────
532
551
 
533
- // ── Launch reveal ───────────────────────────────────────────────────────
534
-
535
- /** How many sections are visible, and which one is still settling, at this frame. */
536
- /** Number of sections that have taken their colors; the rest are still drawn, but in dim ink. */
537
- #revealState(sectionCount: number): { colored: number } {
538
- if (this.#animStart == null) return { colored: sectionCount };
552
+ /** Sections that have taken their colors; the rest are drawn in dim ink until the intro reaches them. */
553
+ #colorReveal(sectionCount: number): number {
554
+ if (this.#animStart == null) return sectionCount;
539
555
  const elapsed = performance.now() - this.#animStart;
540
- return { colored: Math.min(sectionCount, Math.floor((elapsed - SECTION_SETTLE_MS) / SECTION_STAGGER_MS) + 1) };
556
+ return Math.min(sectionCount, Math.floor((elapsed - SECTION_SETTLE_MS) / SECTION_STAGGER_MS) + 1);
541
557
  }
542
558
 
543
- /**
544
- * Every fact is on screen from the first frame; the intro only lets color
545
- * spread top to bottom, like ink soaking in. Content never waits on the effect.
546
- */
547
- #revealLines(sections: Section[], offset: number, reveal: { colored: number }): string[] {
548
- const lines: string[] = [];
549
- sections.forEach((section, index) => {
550
- if (offset + index < reveal.colored) {
551
- lines.push(...section.lines);
552
- return;
553
- }
554
- for (const line of section.lines) lines.push(theme.fg("dim", Bun.stripANSI(line)));
555
- });
556
- return lines;
559
+ #inkSection(lines: string[], order: number, colored: number): string[] {
560
+ return order < colored ? lines : lines.map(line => theme.fg("dim", Bun.stripANSI(line)));
557
561
  }
558
562
 
559
563
  // ── Layout helpers ──────────────────────────────────────────────────────
560
564
 
561
- #clip(lines: string[], rows: number): string[] {
562
- if (rows <= 0) return [];
563
- if (lines.length <= rows) return lines;
564
- if (rows === 1) return [theme.fg("dim", " …")];
565
- return [...lines.slice(0, rows - 1), theme.fg("dim", " …")];
565
+ /** `left` and `right` on one row, `right` flush with the card's right edge. */
566
+ #spread(left: string, right: string, width: number): string {
567
+ const room = width - visibleWidth(left) - visibleWidth(right);
568
+ return room >= 2 ? `${left}${padding(room)}${right}` : this.#truncate(left, width);
566
569
  }
567
570
 
568
571
  #truncate(text: string, width: number): string {
@@ -578,8 +581,8 @@ export class WelcomeComponent implements Component {
578
581
  return `…${tail}`;
579
582
  }
580
583
 
581
- /** Fit string to exact width with native ANSI/wide-glyph truncation and padding. */
582
- #fitToWidth(str: string, width: number): string {
584
+ /** Fit a string to exactly `width` columns (native ANSI/wide-glyph aware). */
585
+ #fit(str: string, width: number): string {
583
586
  const visLen = visibleWidth(str);
584
587
  if (visLen > width) return truncateToWidth(str, width, null, true);
585
588
  return str + padding(width - visLen);
@@ -588,8 +591,7 @@ export class WelcomeComponent implements Component {
588
591
  #rightGutterWidth(termWidth: number): number {
589
592
  const configured = this.options.rightGutterWidth ?? 0;
590
593
  if (!Number.isFinite(configured) || configured <= 0) return 0;
591
- const gutterWidth = Math.floor(configured);
592
- return Math.min(gutterWidth, Math.max(0, termWidth - 4));
594
+ return Math.min(Math.floor(configured), Math.max(0, termWidth - 4));
593
595
  }
594
596
 
595
597
  #withRightGutter(lines: string[], rightGutterWidth: number): string[] {
@@ -600,16 +602,12 @@ export class WelcomeComponent implements Component {
600
602
 
601
603
  #targetRows(termWidth: number): number | undefined {
602
604
  const viewportRows = this.options.getViewportRows?.();
603
- if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) {
604
- return undefined;
605
- }
605
+ if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) return undefined;
606
606
  const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
607
607
  return Math.max(0, Math.floor(viewportRows) - reservedRows);
608
608
  }
609
609
  }
610
610
 
611
- /** Upper bound on the reveal: enough for every section to land and settle. */
612
- const INTRO_MS = 12 * SECTION_STAGGER_MS + SECTION_SETTLE_MS;
613
611
  /** Resolve the intro cadence without making tests mutate global process state. */
614
612
  export function resolveWelcomeIntroTickMs(
615
613
  platform: NodeJS.Platform = process.platform,