@sayknow-cli/coding-agent 0.6.4 → 0.6.5

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 (81) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +2 -2
  3. package/dist/types/config/keybindings.d.ts +1 -1
  4. package/dist/types/config/settings-schema.d.ts +7 -3
  5. package/dist/types/i18n/messages/en.d.ts +16 -0
  6. package/dist/types/modes/components/session-selector.d.ts +7 -3
  7. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  8. package/dist/types/modes/components/user-message-selector.d.ts +5 -2
  9. package/dist/types/modes/components/user-message.d.ts +3 -1
  10. package/dist/types/modes/components/welcome.d.ts +49 -6
  11. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  12. package/dist/types/modes/controllers/selector-controller.d.ts +5 -0
  13. package/dist/types/modes/interactive-mode.d.ts +7 -1
  14. package/dist/types/modes/theme/defaults/index.d.ts +342 -0
  15. package/dist/types/modes/theme/theme.d.ts +5 -1
  16. package/dist/types/modes/types.d.ts +1 -1
  17. package/dist/types/session/session-manager.d.ts +9 -0
  18. package/dist/types/session/session-stars.d.ts +15 -0
  19. package/dist/types/tui/output-block.d.ts +12 -1
  20. package/dist/types/tui/utils.d.ts +1 -3
  21. package/dist/types/utils/session-title-generation.d.ts +6 -0
  22. package/dist/types/utils/title-generator.d.ts +11 -2
  23. package/package.json +7 -7
  24. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  25. package/src/cli/session-picker.ts +4 -0
  26. package/src/config/keybindings.ts +1 -1
  27. package/src/config/settings-schema.ts +4 -3
  28. package/src/i18n/messages/de.messages.ts +4 -1
  29. package/src/i18n/messages/de.settings.ts +2 -0
  30. package/src/i18n/messages/de.ts +16 -0
  31. package/src/i18n/messages/en.ts +16 -0
  32. package/src/i18n/messages/es.messages.ts +4 -1
  33. package/src/i18n/messages/es.settings.ts +2 -0
  34. package/src/i18n/messages/es.ts +16 -0
  35. package/src/i18n/messages/fr.messages.ts +4 -1
  36. package/src/i18n/messages/fr.settings.ts +2 -0
  37. package/src/i18n/messages/fr.ts +16 -0
  38. package/src/i18n/messages/ja.messages.ts +4 -1
  39. package/src/i18n/messages/ja.settings.ts +2 -0
  40. package/src/i18n/messages/ja.ts +16 -0
  41. package/src/i18n/messages/ko.messages.ts +4 -1
  42. package/src/i18n/messages/ko.settings.ts +2 -0
  43. package/src/i18n/messages/ko.ts +16 -0
  44. package/src/i18n/messages/zh.messages.ts +2 -1
  45. package/src/i18n/messages/zh.settings.ts +2 -0
  46. package/src/i18n/messages/zh.ts +16 -0
  47. package/src/internal-urls/docs-index.generated.ts +10 -10
  48. package/src/lsp/render.ts +0 -1
  49. package/src/modes/components/session-selector.ts +71 -21
  50. package/src/modes/components/sessions-dashboard.ts +8 -5
  51. package/src/modes/components/settings-selector.ts +1 -0
  52. package/src/modes/components/status-line/presets.ts +5 -5
  53. package/src/modes/components/status-line/separators.ts +4 -0
  54. package/src/modes/components/tool-execution.ts +6 -19
  55. package/src/modes/components/tool-status-header.ts +103 -12
  56. package/src/modes/components/user-message-selector.ts +57 -15
  57. package/src/modes/components/user-message.ts +25 -15
  58. package/src/modes/components/welcome.ts +374 -478
  59. package/src/modes/controllers/command-controller.ts +56 -7
  60. package/src/modes/controllers/input-controller.ts +4 -0
  61. package/src/modes/controllers/selector-controller.ts +91 -10
  62. package/src/modes/interactive-mode.ts +101 -12
  63. package/src/modes/theme/defaults/glow-octopus.json +114 -0
  64. package/src/modes/theme/defaults/index.ts +6 -0
  65. package/src/modes/theme/defaults/ink-octopus.json +114 -0
  66. package/src/modes/theme/defaults/violet-octopus.json +114 -0
  67. package/src/modes/theme/theme.ts +18 -2
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/title-system.md +1 -1
  70. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  71. package/src/session/agent-session.ts +12 -0
  72. package/src/session/internal/managed-session-scope.ts +10 -0
  73. package/src/session/session-manager.ts +26 -1
  74. package/src/session/session-stars.ts +104 -0
  75. package/src/slash-commands/builtin-registry.ts +104 -4
  76. package/src/tools/debug.ts +0 -1
  77. package/src/tools/fetch.ts +0 -1
  78. package/src/tui/output-block.ts +29 -61
  79. package/src/tui/utils.ts +1 -8
  80. package/src/utils/session-title-generation.ts +29 -0
  81. package/src/utils/title-generator.ts +46 -2
@@ -1,9 +1,9 @@
1
- import { type Component, padding, TERMINAL, truncateToWidth, visibleWidth } from "@sayknow-cli/tui";
2
- import { APP_NAME } from "@sayknow-cli/utils";
1
+ import type { ThinkingLevel } from "@sayknow-cli/agent-core";
2
+ import { type Component, padding, truncateToWidth, visibleWidth } from "@sayknow-cli/tui";
3
3
  import { formatBuildLabel } from "../../build-metadata";
4
4
  import { formatKeyHint, type KeyDisplayContext } from "../../config/keybindings";
5
- import { t } from "../../i18n";
6
- import { type ThemeColor, theme } from "../../modes/theme/theme";
5
+ import { type MsgKey, t } from "../../i18n";
6
+ import { theme } from "../../modes/theme/theme";
7
7
 
8
8
  export interface RecentSession {
9
9
  name: string;
@@ -16,6 +16,34 @@ export interface LspServerInfo {
16
16
  fileTypes: string[];
17
17
  }
18
18
 
19
+ export interface WelcomeRoleBinding {
20
+ role: string;
21
+ model: string;
22
+ }
23
+
24
+ /**
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.
28
+ */
29
+ export interface WelcomeSnapshot {
30
+ /** Display path of the project directory (already home-shortened). */
31
+ cwd?: string;
32
+ /** Current branch, `"detached"`, or `null` outside a git repository. */
33
+ branch?: string | null;
34
+ gitChanges?: { staged: number; unstaged: number; untracked: number } | null;
35
+ /** Latest commits as `<short-sha> <subject>` onelines, newest first. */
36
+ recentCommits?: readonly string[];
37
+ 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
+ }
46
+
19
47
  export type WelcomeLogoMode = "unicode" | "square" | "ascii";
20
48
  export interface WelcomeComponentOptions {
21
49
  getViewportRows?: () => number | undefined;
@@ -26,11 +54,25 @@ export interface WelcomeComponentOptions {
26
54
  buildLabel?: string;
27
55
  keyDisplayContext?: KeyDisplayContext;
28
56
  skipLogoAnimation?: boolean;
57
+ snapshot?: WelcomeSnapshot;
58
+ /** Key bound to the resume picker (`app.session.resume`); the sessions heading names it. */
59
+ resumeKey?: string;
29
60
  }
30
61
 
31
- const WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS = 15;
32
- const DEFAULT_WHATS_NEW_ROWS = 3;
33
- const MAX_WHATS_NEW_ROWS = 12;
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. */
68
+ const SESSION_ROWS = 3;
69
+ const MAX_LSP_ROWS = 3;
70
+ const MAX_COMMIT_ROWS = 3;
71
+
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;
34
76
 
35
77
  function flowKeyItems(context: KeyDisplayContext): ReadonlyArray<{ key: string; label: string }> {
36
78
  const newlineKey = context.platform === "win32" ? "alt+enter" : "ctrl+j";
@@ -48,14 +90,30 @@ function flowKeyItems(context: KeyDisplayContext): ReadonlyArray<{ key: string;
48
90
  ];
49
91
  }
50
92
 
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
+ ];
99
+
100
+ /** A block of rows revealed together during the launch intro. */
101
+ interface Section {
102
+ lines: string[];
103
+ }
104
+
51
105
  /**
52
- * Sayknow-CLI launch surface: a blue-gradient SAYKNOW wordmark, compact
53
- * command affordances, and project signals — a distinct identity, not a
54
- * copy of another agent shell.
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.
55
112
  */
56
113
  export class WelcomeComponent implements Component {
57
114
  #animStart: number | null = null;
58
115
  #animTimer: NodeJS.Timeout | null = null;
116
+ #snapshot: WelcomeSnapshot;
59
117
 
60
118
  constructor(
61
119
  private readonly version: string,
@@ -65,14 +123,17 @@ export class WelcomeComponent implements Component {
65
123
  private lspServers: LspServerInfo[] = [],
66
124
  private readonly logoMode: WelcomeLogoMode = "unicode",
67
125
  private readonly options: WelcomeComponentOptions = {},
68
- ) {}
126
+ ) {
127
+ this.#snapshot = { ...options.snapshot };
128
+ }
69
129
 
70
130
  invalidate(): void {}
71
131
 
72
132
  /**
73
- * Play a one-shot intro that sweeps the gradient through every phase
74
- * before settling on the resting frame. Safe to call multiple times —
75
- * subsequent calls reset and replay.
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.
76
137
  */
77
138
  playIntro(requestRender: () => void): void {
78
139
  this.#stopAnimation();
@@ -117,261 +178,239 @@ export class WelcomeComponent implements Component {
117
178
  this.lspServers = servers;
118
179
  }
119
180
 
181
+ /** Merge newly probed workspace facts into the ledger. */
182
+ setSnapshot(patch: WelcomeSnapshot): void {
183
+ this.#snapshot = { ...this.#snapshot, ...patch };
184
+ }
185
+
120
186
  render(termWidth: number): string[] {
121
- const rightGutterWidth = this.#rightGutterWidth(termWidth);
122
- const boxWidth = Math.max(0, termWidth - rightGutterWidth);
123
- if (boxWidth < 4) {
124
- return [];
125
- }
187
+ const gutterWidth = this.#rightGutterWidth(termWidth);
188
+ const width = Math.max(0, termWidth - gutterWidth);
189
+ if (width < 4) return [];
126
190
 
127
191
  const targetRows = this.#targetRows(termWidth);
128
- if (targetRows !== undefined && targetRows <= 0) {
129
- return [];
130
- }
131
- const targetContentRows = targetRows === undefined ? undefined : Math.max(0, targetRows - 2);
132
- const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
133
- const minLeftCol = 20; // wordmark plus Sayknow identity labels
134
- const minRightCol = 24;
135
- const modelPill = this.#pill(theme.icon.model || "model", this.modelName, "statusLineModel");
136
- const providerPill = this.#pill(theme.icon.package || "provider", this.providerName, "statusLinePath");
137
- const logoLines = this.#logoLines();
138
- const logoMinWidth = Math.max(...logoLines.map(line => visibleWidth(line)));
139
- const leftMinContentWidth = Math.max(
140
- minLeftCol,
141
- logoMinWidth,
142
- visibleWidth(t("welcome.tagline")),
143
- visibleWidth(modelPill),
144
- visibleWidth(providerPill),
145
- );
146
- const evenLeftCol = Math.floor(dualContentWidth / 2);
147
- const maxLeftColWithRightMinimum = Math.max(1, dualContentWidth - minRightCol);
148
- const desiredLeftCol = Math.max(leftMinContentWidth, evenLeftCol);
149
- const dualLeftCol =
150
- dualContentWidth >= minRightCol + 1
151
- ? Math.min(desiredLeftCol, maxLeftColWithRightMinimum)
152
- : Math.max(1, dualContentWidth - 1);
153
- const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
154
- const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
155
- const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
156
- const rightCol = showRightColumn ? dualRightCol : 0;
157
-
158
- const logoColored = this.#currentLogoFrame(logoLines);
159
-
160
- // When no model is resolved yet, guide the user instead of showing "Unknown".
161
- const hasModel = this.modelName !== "Unknown" && this.modelName.length > 0;
162
- const identityTail = hasModel
163
- ? [this.#centerText(modelPill, leftCol), this.#centerText(providerPill, leftCol)]
164
- : [
165
- this.#centerText(
166
- this.#pill(theme.icon.model || "model", t("welcome.chooseModel"), "statusLineModel"),
167
- leftCol,
168
- ),
169
- this.#centerText(theme.fg("dim", t("welcome.modelHint")), leftCol),
170
- ];
171
-
172
- // Left column - centered identity stack: wordmark, tagline, then model state.
173
- const leftLines = [
174
- "",
175
- ...logoColored.map(l => this.#centerText(l, leftCol)),
176
- "",
177
- this.#centerText(theme.fg("muted", t("welcome.tagline")), leftCol),
178
- "",
179
- ...identityTail,
180
- ];
181
-
182
- const buildSeparator = (columnWidth: number): string =>
183
- ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(Math.max(0, columnWidth - 2)))}`;
184
-
185
- const rightColumnWidth = showRightColumn ? rightCol : leftCol;
186
- const separator = buildSeparator(rightColumnWidth);
187
- const lspLines: string[] = [];
188
- if (this.lspServers.length === 0) {
189
- lspLines.push(` ${theme.fg("dim", t("welcome.noLsp"))}`);
190
- } else {
191
- for (const server of this.lspServers.slice(0, 4)) {
192
- const icon =
193
- server.status === "ready"
194
- ? theme.styledSymbol("status.success", "success")
195
- : server.status === "error"
196
- ? theme.styledSymbol("status.error", "error")
197
- : theme.styledSymbol("status.pending", "muted");
198
- const exts = server.fileTypes.slice(0, 3).join(" ");
199
- lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
192
+ if (targetRows !== undefined && targetRows <= 0) return [];
193
+
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));
200
221
  }
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));
201
227
  }
202
228
 
203
- const flowPreferredRows = this.#flowKeyRows(rightColumnWidth).length;
204
- const changelogRowLimit = this.#whatsNewRowLimit(targetContentRows, lspLines.length, flowPreferredRows);
205
- const changelogLines = this.#whatsNewLines(rightColumnWidth, changelogRowLimit);
206
- const flowRowLimit = this.#flowKeyRowLimit(
207
- targetContentRows,
208
- changelogLines.length,
209
- lspLines.length,
210
- rightColumnWidth,
211
- );
212
- const flowLines = this.#flowKeyLines(rightColumnWidth, flowRowLimit);
213
- const sessionLimit = this.#sessionTrailLimit(
214
- targetContentRows,
215
- changelogLines.length,
216
- lspLines.length,
217
- flowLines.length,
218
- );
219
- const sessionLines = this.#sessionTrailLines(rightColumnWidth, sessionLimit);
229
+ return this.#withRightGutter([header, rule, ...body], gutterWidth);
230
+ }
220
231
 
221
- // Workflow affordances: pad the command so descriptions align.
222
- const wf = (cmd: string, desc: string): string => {
223
- const pad = " ".repeat(Math.max(1, 17 - visibleWidth(cmd)));
224
- return ` ${theme.fg("accent", cmd)}${pad}${theme.fg("muted", desc)}`;
225
- };
232
+ // ── Header ──────────────────────────────────────────────────────────────
226
233
 
227
- const rightLines = [
228
- "",
229
- ` ${theme.bold(theme.fg("accent", "What's New"))}`,
230
- ...changelogLines,
231
- separator,
232
- ` ${theme.bold(theme.fg("accent", t("welcome.workflows")))}`,
233
- wf("/deep-interview", t("welcome.wf.deepInterview")),
234
- wf("/ralplan", t("welcome.wf.ralplan")),
235
- wf("/ultragoal", t("welcome.wf.ultragoal")),
236
- wf("/team", t("welcome.wf.team")),
237
- separator,
238
- ` ${theme.bold(theme.fg("accent", t("welcome.flowKeys")))}`,
239
- ...flowLines,
240
- separator,
241
- ` ${theme.bold(theme.fg("accent", t("welcome.projectPulse")))}`,
242
- ...lspLines,
243
- separator,
244
- ` ${theme.bold(theme.fg("accent", t("welcome.sessionTrail")))}`,
245
- ...sessionLines,
246
- "",
247
- ];
234
+ #headerLine(width: number): string {
235
+ 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;
241
+ }
248
242
 
249
- const contentRows =
250
- targetContentRows ??
251
- (showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length + rightLines.length);
252
- const outputRows = targetRows === undefined ? Math.max(3, contentRows + 2) : targetRows;
253
- const bodyRows = Math.max(0, outputRows - 2);
243
+ #ruleGlyph(): string {
244
+ return this.logoMode === "ascii" ? "-" : "─";
245
+ }
254
246
 
255
- const hChar = theme.boxRound.horizontal;
256
- const h = theme.fg("dim", hChar);
257
- const v = theme.fg("dim", theme.boxRound.vertical);
258
- const tl = theme.fg("dim", theme.boxRound.topLeft);
259
- const tr = theme.fg("dim", theme.boxRound.topRight);
260
- const bl = theme.fg("dim", theme.boxRound.bottomLeft);
261
- const br = theme.fg("dim", theme.boxRound.bottomRight);
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
+ });
262
287
 
263
- const lines: string[] = [];
264
- const buildLabel = this.options.buildLabel ?? formatBuildLabel();
265
- const title = ` ${APP_NAME} · Sayknow-CLI v${this.version} · ${buildLabel} `;
266
- const titlePrefixRaw = hChar.repeat(3);
267
- const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
268
- const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
269
- const titleSpace = boxWidth - 2;
270
- if (titleVisLen >= titleSpace) {
271
- lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
272
- } else {
273
- const afterTitle = titleSpace - titleVisLen;
274
- lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
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)));
275
302
  }
276
- if (outputRows === 1) {
277
- return this.#withRightGutter(lines, rightGutterWidth);
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
+ });
278
313
  }
279
314
 
280
- if (showRightColumn) {
281
- const leftBlock = this.#fitBlock(leftLines, bodyRows, "center");
282
- const rightBlock = this.#fitBlock(rightLines, bodyRows, "top");
283
- for (let i = 0; i < bodyRows; i++) {
284
- const left = this.#fitToWidth(leftBlock[i] ?? "", leftCol);
285
- const right = this.#fitToWidth(rightBlock[i] ?? "", rightCol);
286
- lines.push(v + left + v + right + v);
287
- }
288
- lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
289
- } else {
290
- const compactLeftLines =
291
- bodyRows < 14
292
- ? [
293
- this.#centerText(theme.bold(theme.fg("accent", "SKC Forge")), leftCol),
294
- this.#centerText(this.#loadingLine(), leftCol),
295
- this.#centerText(modelPill, leftCol),
296
- ]
297
- : leftLines;
298
- const singleBlock = this.#fitBlock([...compactLeftLines, separator, ...rightLines], bodyRows, "top");
299
- for (let i = 0; i < bodyRows; i++) {
300
- lines.push(v + this.#fitToWidth(singleBlock[i] ?? "", leftCol) + v);
301
- }
302
- lines.push(bl + h.repeat(leftCol) + br);
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}`);
303
331
  }
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
+ });
304
337
 
305
- return this.#withRightGutter(lines, rightGutterWidth);
338
+ return [where, brain, hands].filter(lines => lines.length > 0).map(lines => ({ lines: [...lines, ""] }));
306
339
  }
307
340
 
308
- /** Center text within a given width */
309
- #centerText(text: string, width: number): string {
310
- const visLen = visibleWidth(text);
311
- if (visLen >= width) {
312
- return truncateToWidth(text, width);
313
- }
314
- const leftPad = Math.floor((width - visLen) / 2);
315
- const rightPad = width - visLen - leftPad;
316
- return padding(leftPad) + text + padding(rightPad);
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;
317
355
  }
318
356
 
319
- /** Fit string to exact width with native ANSI/wide-glyph truncation and padding. */
320
- #fitToWidth(str: string, width: number): string {
321
- const visLen = visibleWidth(str);
322
- if (visLen > width) {
323
- return truncateToWidth(str, width, null, true);
324
- }
325
- return str + padding(width - visLen);
326
- }
327
- #rightGutterWidth(termWidth: number): number {
328
- const configured = this.options.rightGutterWidth ?? 0;
329
- if (!Number.isFinite(configured) || configured <= 0) return 0;
330
- const gutterWidth = Math.floor(configured);
331
- return Math.min(gutterWidth, Math.max(0, termWidth - 4));
357
+ #isDirty(): boolean {
358
+ const changes = this.#snapshot.gitChanges;
359
+ return !!changes && changes.staged + changes.unstaged + changes.untracked > 0;
332
360
  }
333
361
 
334
- #withRightGutter(lines: string[], rightGutterWidth: number): string[] {
335
- if (rightGutterWidth <= 0) return lines;
336
- const gutter = padding(rightGutterWidth);
337
- return lines.map(line => line + gutter);
362
+ #gitChangeSummary(sep: string): string {
363
+ 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"))}`;
367
+ const parts: string[] = [];
368
+ if (changes.staged > 0) parts.push(theme.fg("statusLineStaged", `+${changes.staged}`));
369
+ if (changes.unstaged > 0) parts.push(theme.fg("statusLineDirty", `~${changes.unstaged}`));
370
+ if (changes.untracked > 0) parts.push(theme.fg("statusLineUntracked", `?${changes.untracked}`));
371
+ return `${sep}${parts.join(" ")}`;
338
372
  }
339
373
 
340
- #targetRows(termWidth: number): number | undefined {
341
- const viewportRows = this.options.getViewportRows?.();
342
- if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) {
343
- return undefined;
344
- }
345
- const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
346
- return Math.max(0, Math.floor(viewportRows) - reservedRows);
347
- }
374
+ // ── Right column: activity ──────────────────────────────────────────────
348
375
 
349
- #loadingLine(): string {
350
- const frames = theme.spinnerFrames;
351
- const elapsed = this.#animStart == null ? 0 : performance.now() - this.#animStart;
352
- const frame = frames.length > 0 ? (frames[Math.floor(elapsed / 100) % frames.length] ?? "*") : "*";
353
- const label = this.#animStart == null ? "ready" : "warming workspace";
354
- return `${theme.fg("warning", frame)} ${theme.fg("muted", label)}`;
355
- }
376
+ /**
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.
380
+ */
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);
356
386
 
357
- #fitBlock(lines: string[], rows: number, align: "top" | "center"): string[] {
358
- if (rows <= 0) return [];
359
- const clipped =
360
- lines.length > rows
361
- ? rows === 1
362
- ? [theme.fg("dim", " …")]
363
- : [...lines.slice(0, rows - 1), theme.fg("dim", " …")]
364
- : lines;
365
- const missingRows = rows - clipped.length;
366
- if (missingRows <= 0) return clipped;
367
- const topPad = align === "center" ? Math.floor(missingRows / 2) : 0;
368
- const bottomPad = missingRows - topPad;
369
- return [...Array.from({ length: topPad }, () => ""), ...clipped, ...Array.from({ length: bottomPad }, () => "")];
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
+ ];
370
409
  }
371
410
 
372
411
  #flowKeyItemText(item: { key: string; label: string }): string {
373
412
  const context = this.options.keyDisplayContext ?? { platform: process.platform };
374
- return `${theme.fg("dim", formatKeyHint(item.key, context))}${theme.fg("muted", ` ${this.#flowKeyLabel(item.label)}`)}`;
413
+ return `${theme.fg("text", formatKeyHint(item.key, context))}${theme.fg("dim", ` ${this.#flowKeyLabel(item.label)}`)}`;
375
414
  }
376
415
 
377
416
  #flowKeyLabel(label: string): string {
@@ -396,107 +435,35 @@ export class WelcomeComponent implements Component {
396
435
  }
397
436
 
398
437
  #flowKeyRows(width: number): string[] {
399
- const contentWidth = Math.max(1, width - 1);
400
- const separator = ` ${theme.fg("dim", "·")} `;
438
+ const contentWidth = Math.max(1, width - 2);
439
+ const separator = theme.fg("dim", " ");
401
440
  const rows: string[] = [];
402
441
  let current = "";
403
442
  for (const item of flowKeyItems(this.options.keyDisplayContext ?? { platform: process.platform })) {
404
443
  const segment = this.#flowKeyItemText(item);
405
444
  const next = current ? `${current}${separator}${segment}` : segment;
406
445
  if (current && visibleWidth(next) > contentWidth) {
407
- rows.push(` ${current}`);
446
+ rows.push(` ${current}`);
408
447
  current = segment;
409
448
  } else {
410
449
  current = next;
411
450
  }
412
451
  }
413
- if (current) rows.push(` ${current}`);
414
- return rows.length > 0 ? rows : [` ${theme.fg("dim", "No flow keys")}`];
452
+ if (current) rows.push(` ${current}`);
453
+ return rows;
415
454
  }
416
455
 
417
- #flowKeyLines(width: number, maxRows: number): string[] {
418
- const rows = this.#flowKeyRows(width);
419
- const rowLimit = Math.max(1, Math.floor(maxRows));
420
- if (rows.length <= rowLimit) return rows;
421
- if (rowLimit === 1) {
422
- const firstItem = flowKeyItems(this.options.keyDisplayContext ?? { platform: process.platform })[0];
423
- const firstSegment = firstItem ? this.#flowKeyItemText(firstItem) : theme.fg("dim", "keys");
424
- return [this.#fitToWidth(` ${firstSegment} ${theme.fg("dim", "· … ")}${theme.bold("/help")}`, width)];
425
- }
426
- return [...rows.slice(0, rowLimit - 1), ` ${theme.fg("dim", `… ${theme.bold("/help")} for more`)}`];
427
- }
428
-
429
- #flowKeyRowLimit(
430
- targetContentRows: number | undefined,
431
- changelogLineCount: number,
432
- lspLineCount: number,
433
- rightColumnWidth: number,
434
- ): number {
435
- const preferredRows = this.#flowKeyRows(rightColumnWidth).length;
436
- if (targetContentRows === undefined) return preferredRows;
437
-
438
- const sessionBaselineRows = this.recentSessions.length === 0 ? 1 : Math.min(3, this.recentSessions.length);
439
- const availableRows =
440
- targetContentRows -
441
- WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS -
442
- changelogLineCount -
443
- lspLineCount -
444
- sessionBaselineRows;
445
- return Math.max(1, Math.min(preferredRows, availableRows));
446
- }
447
-
448
- #whatsNewRowLimit(targetContentRows: number | undefined, lspLineCount: number, flowLineCount: number): number {
449
- if (targetContentRows === undefined) return 5;
450
-
451
- const sessionBaselineRows = this.recentSessions.length === 0 ? 1 : Math.min(3, this.recentSessions.length);
452
- const dynamicRows = Math.max(
453
- 1,
454
- targetContentRows - WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS - flowLineCount - lspLineCount,
455
- );
456
- const rowsAfterBaselineSessions = Math.max(1, dynamicRows - sessionBaselineRows);
457
- const spareRows = Math.max(0, rowsAfterBaselineSessions - DEFAULT_WHATS_NEW_ROWS);
458
- return Math.max(
459
- 1,
460
- Math.min(MAX_WHATS_NEW_ROWS, rowsAfterBaselineSessions, DEFAULT_WHATS_NEW_ROWS + Math.floor(spareRows / 2)),
461
- );
462
- }
463
-
464
- #sessionTrailLimit(
465
- targetContentRows: number | undefined,
466
- changelogLineCount: number,
467
- lspLineCount: number,
468
- flowLineCount: number,
469
- ): number {
470
- if (this.recentSessions.length === 0) return 0;
471
-
472
- const defaultLimit = Math.min(3, this.recentSessions.length);
473
- if (targetContentRows === undefined) return defaultLimit;
474
-
475
- const rowsWithDefaultTrail =
476
- WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS +
477
- changelogLineCount +
478
- flowLineCount +
479
- lspLineCount +
480
- defaultLimit;
481
- const extraRows = Math.max(0, targetContentRows - rowsWithDefaultTrail);
482
- return Math.min(this.recentSessions.length, defaultLimit + extraRows);
483
- }
484
-
485
- #sessionTrailLines(rightColumnWidth: number, limit: number): string[] {
456
+ #sessionTrailLines(width: number): string[] {
486
457
  if (this.recentSessions.length === 0) {
487
- return [` ${theme.fg("dim", t("welcome.noSessions"))}`];
458
+ return [` ${theme.fg("dim", t("welcome.noSessions"))}`];
488
459
  }
489
-
490
- const bulletPrefix = ` ${theme.md.bullet} `;
491
- const prefixWidth = visibleWidth(bulletPrefix);
492
460
  const lines: string[] = [];
493
- for (const session of this.recentSessions.slice(0, limit)) {
494
- const timeSuffixRaw = ` (${session.timeAgo})`;
495
- const timeWidth = visibleWidth(timeSuffixRaw);
496
- const nameBudget = Math.max(1, rightColumnWidth - prefixWidth - timeWidth);
497
- const nameVis = visibleWidth(session.name);
498
- const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
499
- lines.push(`${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`);
461
+ for (const session of this.recentSessions.slice(0, SESSION_ROWS)) {
462
+ 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}`);
500
467
  }
501
468
  return lines;
502
469
  }
@@ -504,37 +471,27 @@ export class WelcomeComponent implements Component {
504
471
  #whatsNewLines(width: number, maxRows: number): string[] {
505
472
  const rowLimit = Math.max(1, Math.floor(maxRows));
506
473
  const changelog = this.options.changelogMarkdown?.trim();
507
- if (!changelog) {
508
- return [` ${theme.fg("dim", "Ready for your next prompt")}`];
509
- }
474
+ if (!changelog) return [` ${theme.fg("dim", t("welcome.readyPrompt"))}`];
510
475
 
511
476
  const version = this.#latestChangelogVersion(changelog);
512
- if (this.options.collapseChangelog) {
513
- return [
514
- ` ${theme.fg("muted", `Updated to v${version}`)}`,
515
- ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
516
- ].slice(0, rowLimit);
517
- }
518
-
519
- const items = this.#changelogItems(changelog);
477
+ const items = this.options.collapseChangelog ? [] : this.#changelogItems(changelog);
520
478
  if (items.length === 0) {
521
479
  return [
522
- ` ${theme.fg("muted", `Updated to v${version}`)}`,
523
- ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
480
+ ` ${theme.fg("muted", `Updated to v${version}`)}`,
481
+ ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
524
482
  ].slice(0, rowLimit);
525
483
  }
526
484
 
527
- const prefix = ` ${theme.md.bullet} `;
528
- const textWidth = Math.max(1, width - visibleWidth(prefix));
529
- const visibleItemCount = items.length > rowLimit ? Math.max(1, rowLimit - 1) : rowLimit;
530
- const visibleItems = items.slice(0, visibleItemCount).map(item => {
531
- const text = visibleWidth(item) > textWidth ? truncateToWidth(item, textWidth) : item;
532
- return `${theme.fg("dim", prefix)}${theme.fg("muted", text)}`;
533
- });
534
- if (items.length > visibleItems.length && visibleItems.length < rowLimit) {
535
- visibleItems.push(` ${theme.fg("dim", `… ${theme.bold("/changelog")} for full notes`)}`);
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`)}`);
536
493
  }
537
- return visibleItems;
494
+ return lines;
538
495
  }
539
496
 
540
497
  #latestChangelogVersion(markdown: string): string {
@@ -551,9 +508,7 @@ export class WelcomeComponent implements Component {
551
508
  inFence = !inFence;
552
509
  continue;
553
510
  }
554
- if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) {
555
- continue;
556
- }
511
+ if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) continue;
557
512
  const withoutBullet = line
558
513
  .replace(/^[-*]\s+/, "")
559
514
  .replace(/^\d+\.\s+/, "")
@@ -575,134 +530,86 @@ export class WelcomeComponent implements Component {
575
530
  .trim();
576
531
  }
577
532
 
578
- #pill(icon: string, text: string, color: ThemeColor): string {
579
- return `${theme.fg("borderMuted", "[")} ${theme.fg(color, icon)} ${theme.fg("muted", text)} ${theme.fg(
580
- "borderMuted",
581
- "]",
582
- )}`;
583
- }
533
+ // ── Launch reveal ───────────────────────────────────────────────────────
584
534
 
585
- /** Pick the logo frame for the current intro phase, or the resting frame. */
586
- #currentLogoFrame(logoLines: readonly string[]): readonly string[] {
587
- if (this.#animStart == null) return REST_FRAMES[this.logoMode];
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 };
588
539
  const elapsed = performance.now() - this.#animStart;
589
- if (elapsed >= INTRO_MS) return REST_FRAMES[this.logoMode];
590
- // Ease-out cubic so the spin decelerates into the resting state.
591
- const progress = elapsed / INTRO_MS;
592
- const eased = 1 - (1 - progress) ** 3;
593
- // Sweep backward through INTRO_SWEEPS full rotations so the gradient
594
- // visibly spins multiple times. `eased == 1` → phase = 0 = resting frame.
595
- const phase = ((((1 - eased) * INTRO_SWEEPS) % 1) + 1) % 1;
596
- // Shine traverses the diagonal at a steady pace, decoupled from the
597
- // gradient phase so the two layers parallax. Strength fades out with
598
- // the same ease-out curve so the highlight is gone by the resting frame.
599
- const shinePos = (((progress * INTRO_SHINE_TRAVERSALS) % 1) + 1) % 1;
600
- const shineStrength = (1 - eased) ** 1.5;
601
- return gradientLogo(logoLines, phase, { strength: shineStrength, pos: shinePos });
602
- }
603
-
604
- #logoLines(): readonly string[] {
605
- return BRAND_LOGO;
540
+ return { colored: Math.min(sectionCount, Math.floor((elapsed - SECTION_SETTLE_MS) / SECTION_STAGGER_MS) + 1) };
606
541
  }
607
- }
608
542
 
609
- // biome-ignore format: preserve ASCII art layout
610
- const BRAND_LOGO = [
611
- "╔═╗╔═╗╦ ╦╦╔═╔╗╔╔═╗╦ ╦",
612
- "╚═╗╠═╣╚╦╝╠╩╗║║║║ ║║║║",
613
- "╚═╝╩ ╩ ╩ ╩ ╩╝╚╝╚═╝╚╩╝",
614
- ];
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;
557
+ }
615
558
 
616
- /** Multi-stop palette for the blue-octopus diagonal gradient. */
617
- const GRADIENT_STOPS: ReadonlyArray<readonly [number, number, number]> = [
618
- [6, 19, 33], // deep navy
619
- [11, 79, 138], // ocean
620
- [47, 155, 255], // octopus mantle blue
621
- [94, 200, 255], // bright tentacle blue
622
- [168, 224, 255], // sky highlight
623
- ];
559
+ // ── Layout helpers ──────────────────────────────────────────────────────
624
560
 
625
- /** 256-color ramp fallback when truecolor isn't available. */
626
- const GRADIENT_RAMP_256 = [17, 18, 24, 31, 38, 75, 117];
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", " …")];
566
+ }
627
567
 
628
- /** Half-width of the shine highlight band, expressed in gradient-t units. */
629
- const SHINE_HALF_WIDTH = 0.18;
568
+ #truncate(text: string, width: number): string {
569
+ return visibleWidth(text) > width ? truncateToWidth(text, width) : text;
570
+ }
630
571
 
631
- interface ShineConfig {
632
- /** Overall opacity of the shine overlay, in [0, 1]. */
633
- strength: number;
634
- /** Center of the shine band along the diagonal, in [0, 1]. */
635
- pos: number;
636
- }
637
- /**
638
- * Apply a multi-stop diagonal gradient (bottom-left → top-right) plus an
639
- * optional sliding shine band across multi-line art. `phase` (0..1) shifts the
640
- * gradient along the diagonal, wrapping at 1. When `shine` is provided, a soft
641
- * white highlight is composited on top, centered at `shine.pos`.
642
- */
643
- function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig): string[] {
644
- const reset = "\x1b[0m";
645
- const rows = lines.length;
646
- const cols = Math.max(...lines.map(l => l.length));
647
- // span+1 so `base` stays strictly < 1: avoids the wrap-around at the
648
- // far corner mapping back to t=0 (hot pink) on the resting frame.
649
- const span = Math.max(1, cols + rows - 1);
650
- const shineStrength = shine && shine.strength > 0 ? shine.strength : 0;
651
- const shinePos = shine ? shine.pos : 0;
652
- const colorAt = TERMINAL.trueColor
653
- ? (t: number): string => {
654
- // 5-stop palette widens the visible color range and avoids the
655
- // deep-blue valley a naive HSL lerp falls into.
656
- const stops = GRADIENT_STOPS;
657
- const seg = t * (stops.length - 1);
658
- const i = Math.min(stops.length - 2, Math.floor(seg));
659
- const f = seg - i;
660
- const a = stops[i];
661
- const b = stops[i + 1];
662
- let r = a[0] + (b[0] - a[0]) * f;
663
- let g = a[1] + (b[1] - a[1]) * f;
664
- let bl = a[2] + (b[2] - a[2]) * f;
665
- if (shineStrength > 0) {
666
- const dist = Math.abs(t - shinePos);
667
- const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
668
- if (intensity > 0) {
669
- r += (255 - r) * intensity;
670
- g += (255 - g) * intensity;
671
- bl += (255 - bl) * intensity;
672
- }
673
- }
674
- return `\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(bl)}m`;
675
- }
676
- : (t: number): string => {
677
- const ramp = GRADIENT_RAMP_256;
678
- let idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
679
- if (shineStrength > 0) {
680
- const dist = Math.abs(t - shinePos);
681
- const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
682
- // Promote to the brightest ramp slot when the shine band peaks here.
683
- if (intensity > 0.5) idx = ramp.length - 1;
684
- }
685
- return `\x1b[38;5;${ramp[idx]}m`;
686
- };
687
- return lines.map((line, y) => {
688
- let result = "";
689
- for (let x = 0; x < line.length; x++) {
690
- const char = line[x];
691
- if (char === " ") {
692
- result += char;
693
- continue;
694
- }
695
- // Diagonal: bottom-left (x=0, y=rows-1) → top-right (x=cols-1, y=0)
696
- const base = (x + (rows - 1 - y)) / span;
697
- const t = (((base + phase) % 1) + 1) % 1;
698
- result += colorAt(t) + char + reset;
572
+ /** Keep the tail of a long path — the project directory is the part that identifies it. */
573
+ #shortenFromLeft(value: string, width: number): string {
574
+ if (visibleWidth(value) <= width) return value;
575
+ if (width <= 1) return "…";
576
+ let tail = value;
577
+ while (tail.length > 0 && visibleWidth(tail) > width - 1) tail = tail.slice(1);
578
+ return `…${tail}`;
579
+ }
580
+
581
+ /** Fit string to exact width with native ANSI/wide-glyph truncation and padding. */
582
+ #fitToWidth(str: string, width: number): string {
583
+ const visLen = visibleWidth(str);
584
+ if (visLen > width) return truncateToWidth(str, width, null, true);
585
+ return str + padding(width - visLen);
586
+ }
587
+
588
+ #rightGutterWidth(termWidth: number): number {
589
+ const configured = this.options.rightGutterWidth ?? 0;
590
+ if (!Number.isFinite(configured) || configured <= 0) return 0;
591
+ const gutterWidth = Math.floor(configured);
592
+ return Math.min(gutterWidth, Math.max(0, termWidth - 4));
593
+ }
594
+
595
+ #withRightGutter(lines: string[], rightGutterWidth: number): string[] {
596
+ if (rightGutterWidth <= 0) return lines;
597
+ const gutter = padding(rightGutterWidth);
598
+ return lines.map(line => line + gutter);
599
+ }
600
+
601
+ #targetRows(termWidth: number): number | undefined {
602
+ const viewportRows = this.options.getViewportRows?.();
603
+ if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) {
604
+ return undefined;
699
605
  }
700
- return result;
701
- });
606
+ const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
607
+ return Math.max(0, Math.floor(viewportRows) - reservedRows);
608
+ }
702
609
  }
703
610
 
704
- /** Total length of the intro animation. */
705
- const INTRO_MS = 3000;
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;
706
613
  /** Resolve the intro cadence without making tests mutate global process state. */
707
614
  export function resolveWelcomeIntroTickMs(
708
615
  platform: NodeJS.Platform = process.platform,
@@ -713,14 +620,3 @@ export function resolveWelcomeIntroTickMs(
713
620
 
714
621
  /** Render at 30fps directly, but cap native Windows multiplexers at 10fps to avoid ConPTY output backpressure. */
715
622
  const INTRO_TICK_MS = resolveWelcomeIntroTickMs();
716
- /** Number of full gradient rotations the sweep performs before settling. */
717
- const INTRO_SWEEPS = 2.5;
718
- /** Number of times the shine highlight crosses the diagonal across the intro. */
719
- const INTRO_SHINE_TRAVERSALS = 3;
720
-
721
- /** Resting gradient frames, cached for re-renders outside of the intro. */
722
- const REST_FRAMES: Record<WelcomeLogoMode, readonly string[]> = {
723
- unicode: gradientLogo(BRAND_LOGO, 0),
724
- square: gradientLogo(BRAND_LOGO, 0),
725
- ascii: gradientLogo(BRAND_LOGO, 0),
726
- };