@sayknow-cli/coding-agent 0.6.4 → 0.6.6

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 (83) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +2 -2
  3. package/dist/types/config/keybindings.d.ts +6 -1
  4. package/dist/types/config/settings-schema.d.ts +7 -3
  5. package/dist/types/i18n/messages/en.d.ts +5 -14
  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 +34 -14
  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 +14 -2
  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 +3 -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/tools/debug.d.ts +2 -2
  20. package/dist/types/tui/output-block.d.ts +12 -1
  21. package/dist/types/tui/utils.d.ts +1 -3
  22. package/dist/types/utils/session-title-generation.d.ts +6 -0
  23. package/dist/types/utils/title-generator.d.ts +11 -2
  24. package/package.json +7 -7
  25. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  26. package/src/cli/session-picker.ts +4 -0
  27. package/src/config/keybindings.ts +8 -1
  28. package/src/config/settings-schema.ts +4 -3
  29. package/src/i18n/messages/de.messages.ts +4 -1
  30. package/src/i18n/messages/de.settings.ts +2 -0
  31. package/src/i18n/messages/de.ts +3 -12
  32. package/src/i18n/messages/en.ts +5 -14
  33. package/src/i18n/messages/es.messages.ts +4 -1
  34. package/src/i18n/messages/es.settings.ts +2 -0
  35. package/src/i18n/messages/es.ts +3 -12
  36. package/src/i18n/messages/fr.messages.ts +4 -1
  37. package/src/i18n/messages/fr.settings.ts +2 -0
  38. package/src/i18n/messages/fr.ts +3 -12
  39. package/src/i18n/messages/ja.messages.ts +4 -1
  40. package/src/i18n/messages/ja.settings.ts +2 -0
  41. package/src/i18n/messages/ja.ts +3 -12
  42. package/src/i18n/messages/ko.messages.ts +4 -1
  43. package/src/i18n/messages/ko.settings.ts +2 -0
  44. package/src/i18n/messages/ko.ts +4 -13
  45. package/src/i18n/messages/zh.messages.ts +2 -1
  46. package/src/i18n/messages/zh.settings.ts +2 -0
  47. package/src/i18n/messages/zh.ts +3 -12
  48. package/src/internal-urls/docs-index.generated.ts +10 -10
  49. package/src/lsp/render.ts +0 -1
  50. package/src/modes/action-registry.ts +1 -0
  51. package/src/modes/components/session-selector.ts +71 -21
  52. package/src/modes/components/sessions-dashboard.ts +8 -5
  53. package/src/modes/components/settings-selector.ts +1 -0
  54. package/src/modes/components/status-line/presets.ts +5 -5
  55. package/src/modes/components/status-line/separators.ts +4 -0
  56. package/src/modes/components/tool-execution.ts +6 -19
  57. package/src/modes/components/tool-status-header.ts +103 -12
  58. package/src/modes/components/user-message-selector.ts +57 -15
  59. package/src/modes/components/user-message.ts +25 -15
  60. package/src/modes/components/welcome.ts +298 -576
  61. package/src/modes/controllers/command-controller.ts +56 -7
  62. package/src/modes/controllers/input-controller.ts +17 -0
  63. package/src/modes/controllers/selector-controller.ts +91 -10
  64. package/src/modes/interactive-mode.ts +85 -38
  65. package/src/modes/theme/defaults/glow-octopus.json +114 -0
  66. package/src/modes/theme/defaults/index.ts +6 -0
  67. package/src/modes/theme/defaults/ink-octopus.json +114 -0
  68. package/src/modes/theme/defaults/violet-octopus.json +114 -0
  69. package/src/modes/theme/theme.ts +18 -2
  70. package/src/modes/types.ts +3 -1
  71. package/src/prompts/system/title-system.md +1 -1
  72. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  73. package/src/session/agent-session.ts +12 -0
  74. package/src/session/internal/managed-session-scope.ts +10 -0
  75. package/src/session/session-manager.ts +26 -1
  76. package/src/session/session-stars.ts +104 -0
  77. package/src/slash-commands/builtin-registry.ts +104 -4
  78. package/src/tools/debug.ts +0 -1
  79. package/src/tools/fetch.ts +0 -1
  80. package/src/tui/output-block.ts +29 -61
  81. package/src/tui/utils.ts +1 -8
  82. package/src/utils/session-title-generation.ts +29 -0
  83. package/src/utils/title-generator.ts +46 -2
@@ -1,19 +1,34 @@
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 {
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
- import { t } from "../../i18n";
6
- import { type ThemeColor, theme } from "../../modes/theme/theme";
13
+ import { type MsgKey, t } from "../../i18n";
14
+ import { theme } from "../../modes/theme/theme";
7
15
 
8
16
  export interface RecentSession {
9
17
  name: string;
10
18
  timeAgo: string;
11
19
  }
12
20
 
13
- export interface LspServerInfo {
14
- name: string;
15
- status: "idle" | "ready" | "error" | "connecting";
16
- fileTypes: string[];
21
+ /**
22
+ * Workspace facts on the launch card. Every field is optional: the card renders
23
+ * what is known and fills in git state once the asynchronous probe settles.
24
+ */
25
+ export interface WelcomeSnapshot {
26
+ /** Display path of the project directory (already home-shortened). */
27
+ cwd?: string;
28
+ /** Current branch, `"detached"`, or `null` outside a git repository. */
29
+ branch?: string | null;
30
+ gitChanges?: { staged: number; unstaged: number; untracked: number } | null;
31
+ thinkingLevel?: ThinkingLevel | string;
17
32
  }
18
33
 
19
34
  export type WelcomeLogoMode = "unicode" | "square" | "ascii";
@@ -22,57 +37,78 @@ export interface WelcomeComponentOptions {
22
37
  getReservedBottomRows?: (termWidth: number) => number;
23
38
  changelogMarkdown?: string;
24
39
  rightGutterWidth?: number;
40
+ /** Show only the version on the release line, without its first note. */
25
41
  collapseChangelog?: boolean;
26
42
  buildLabel?: string;
27
43
  keyDisplayContext?: KeyDisplayContext;
28
44
  skipLogoAnimation?: boolean;
45
+ snapshot?: WelcomeSnapshot;
46
+ /** Key bound to the resume picker (`app.session.resume`); the card names it. */
47
+ resumeKey?: string;
48
+ /** Key bound to `app.session.continue`, which resumes the first session on the card. */
49
+ continueKey?: string;
50
+ /** Which Sayknow pet stands on the card (default red). */
51
+ petSkin?: PetSkinId;
29
52
  }
30
53
 
31
- const WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS = 15;
32
- const DEFAULT_WHATS_NEW_ROWS = 3;
33
- const MAX_WHATS_NEW_ROWS = 12;
34
-
35
- function flowKeyItems(context: KeyDisplayContext): ReadonlyArray<{ key: string; label: string }> {
36
- const newlineKey = context.platform === "win32" ? "alt+enter" : "ctrl+j";
37
- return [
38
- { key: "/", label: "commands" },
39
- { key: "#", label: "actions" },
40
- { key: "!", label: "shell" },
41
- { key: "$", label: "python" },
42
- { key: "?", label: "keymap" },
43
- { key: "ctrl+l", label: "model" },
44
- { key: "shift+tab", label: "reasoning" },
45
- { key: "tab", label: "complete" },
46
- { key: newlineKey, label: "newline" },
47
- { key: "ctrl+c", label: "clear" },
48
- ];
54
+ /** Left margin of the card, and the widest the card grows on wide terminals. */
55
+ const MARGIN = 2;
56
+ const MAX_CARD_WIDTH = 76;
57
+ /** Gap between the mark and the identity lines. */
58
+ const MARK_GAP = 3;
59
+ /** Below this card width the mark is dropped and only the identity lines remain. */
60
+ const MIN_WIDTH_FOR_MARK = 44;
61
+ /** Recent sessions on the card; the resume picker has the rest. */
62
+ const SESSION_ROWS = 3;
63
+
64
+ /** ASCII-safe stand-in for the pet when the banner must avoid block glyphs. */
65
+ const MARK_ASCII = [" .---. ", " ( o o ) ", " ) ( ", " /\\/\\/\\/ "] as const;
66
+ const MARK_ASCII_WAVE = " \\/\\/\\/\\ ";
67
+
68
+ /**
69
+ * Intro: the pet does a short para-para (tentacles sway left, right, left) and
70
+ * lands on its resting pose while color spreads down the card.
71
+ */
72
+ const INTRO_POSES: readonly SayknowPixelFrameName[] = ["danceL", "danceR", "danceL", "base"];
73
+ const SECTION_STAGGER_MS = 55;
74
+ const SECTION_SETTLE_MS = 90;
75
+ const WAVE_STEP_MS = 140;
76
+ const INTRO_MS = INTRO_POSES.length * WAVE_STEP_MS;
77
+
78
+ /** A block of rows that takes its colors together during the intro. */
79
+ interface Section {
80
+ lines: string[];
81
+ /** When the viewport is short, the section with the highest rank is dropped first. */
82
+ dropRank: number;
49
83
  }
50
84
 
51
85
  /**
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.
86
+ * Sayknow-CLI launch card: the octopus mark beside who and where you are
87
+ * (version, model and reasoning, project and branch), then the three most recent
88
+ * sessions and a single row of keys. Everything else — the full session list,
89
+ * workflows, the keymap, release notes — is one key away, not on the card.
55
90
  */
56
91
  export class WelcomeComponent implements Component {
57
92
  #animStart: number | null = null;
58
93
  #animTimer: NodeJS.Timeout | null = null;
94
+ #snapshot: WelcomeSnapshot;
59
95
 
60
96
  constructor(
61
97
  private readonly version: string,
62
98
  private modelName: string,
63
99
  private providerName: string,
64
100
  private recentSessions: RecentSession[] = [],
65
- private lspServers: LspServerInfo[] = [],
66
101
  private readonly logoMode: WelcomeLogoMode = "unicode",
67
102
  private readonly options: WelcomeComponentOptions = {},
68
- ) {}
103
+ ) {
104
+ this.#snapshot = { ...options.snapshot };
105
+ }
69
106
 
70
107
  invalidate(): void {}
71
108
 
72
109
  /**
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.
110
+ * Play the short launch intro. Content is fully readable from the first frame;
111
+ * only color and the tentacles move. Safe to call again — it restarts.
76
112
  */
77
113
  playIntro(requestRender: () => void): void {
78
114
  this.#stopAnimation();
@@ -84,9 +120,7 @@ export class WelcomeComponent implements Component {
84
120
  requestRender();
85
121
  this.#animTimer = setInterval(() => {
86
122
  const elapsed = performance.now() - (this.#animStart ?? 0);
87
- if (elapsed >= INTRO_MS) {
88
- this.#stopAnimation();
89
- }
123
+ if (elapsed >= INTRO_MS) this.#stopAnimation();
90
124
  requestRender();
91
125
  }, INTRO_TICK_MS);
92
126
  this.#animTimer.unref?.();
@@ -113,433 +147,206 @@ export class WelcomeComponent implements Component {
113
147
  this.recentSessions = sessions;
114
148
  }
115
149
 
116
- setLspServers(servers: LspServerInfo[]): void {
117
- this.lspServers = servers;
150
+ /** Merge newly probed workspace facts into the card. */
151
+ setSnapshot(patch: WelcomeSnapshot): void {
152
+ this.#snapshot = { ...this.#snapshot, ...patch };
118
153
  }
119
154
 
120
155
  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
- }
156
+ const gutterWidth = this.#rightGutterWidth(termWidth);
157
+ const width = Math.max(0, termWidth - gutterWidth);
158
+ if (width < 4) return [];
126
159
 
127
160
  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)}`);
200
- }
201
- }
161
+ if (targetRows !== undefined && targetRows <= 0) return [];
162
+ if (targetRows === 1) return this.#withRightGutter([this.#fit(this.#titleLine(true), width)], gutterWidth);
163
+
164
+ const cardWidth = Math.max(1, Math.min(MAX_CARD_WIDTH, width - MARGIN));
165
+ const sections = this.#sections(cardWidth);
166
+ const lines = this.#fitRows(sections, targetRows);
167
+ const margin = padding(MARGIN);
168
+ const out = lines.map(line => this.#fit(line ? margin + line : "", width));
169
+ if (targetRows !== undefined) while (out.length < targetRows) out.push(padding(width));
170
+ return this.#withRightGutter(out, gutterWidth);
171
+ }
202
172
 
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);
220
-
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)}`;
173
+ /**
174
+ * Lay the sections out with a blank row above and between them. When the
175
+ * viewport is short, whole sections go by rank — keys, then the release
176
+ * note, then sessions — and the identity block is clipped last.
177
+ */
178
+ #fitRows(sections: { identity: string[]; others: Section[] }, targetRows: number | undefined): string[] {
179
+ const colored = this.#colorReveal(1 + sections.others.length);
180
+ const identity = this.#inkSection(sections.identity, 0, colored);
181
+ let others = sections.others.map((section, index) => ({
182
+ ...section,
183
+ lines: this.#inkSection(section.lines, index + 1, colored),
184
+ }));
185
+ const assemble = (): string[] => {
186
+ const out = ["", ...identity];
187
+ for (const section of others) out.push("", ...section.lines);
188
+ return out;
225
189
  };
226
-
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
- ];
248
-
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);
254
-
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);
262
-
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);
275
- }
276
- if (outputRows === 1) {
277
- return this.#withRightGutter(lines, rightGutterWidth);
278
- }
279
-
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);
190
+ let lines = assemble();
191
+ if (targetRows === undefined) return lines;
192
+ while (lines.length > targetRows && others.length > 0) {
193
+ const drop = others.reduce((worst, section) => (section.dropRank > worst.dropRank ? section : worst));
194
+ others = others.filter(section => section !== drop);
195
+ lines = assemble();
303
196
  }
197
+ return lines.slice(0, targetRows);
198
+ }
304
199
 
305
- return this.#withRightGutter(lines, rightGutterWidth);
200
+ #sections(cardWidth: number): { identity: string[]; others: Section[] } {
201
+ const others: Section[] = [];
202
+ const note = this.#releaseNoteLine(cardWidth);
203
+ if (note) others.push({ lines: [note], dropRank: 2 });
204
+ others.push({ lines: this.#sessionLines(cardWidth), dropRank: 1 });
205
+ others.push({ lines: [this.#keysLine(cardWidth)], dropRank: 3 });
206
+ return { identity: this.#identityLines(cardWidth), others };
306
207
  }
307
208
 
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);
209
+ // ── Identity: mark + who and where ──────────────────────────────────────
210
+
211
+ #identityLines(cardWidth: number): string[] {
212
+ const withMark = cardWidth >= MIN_WIDTH_FOR_MARK;
213
+ const mark = withMark ? this.#markRows() : [];
214
+ const markWidth = Math.max(0, ...mark.map(row => visibleWidth(row)));
215
+ const infoWidth = Math.max(1, withMark ? cardWidth - markWidth - MARK_GAP : cardWidth);
216
+ const info = [
217
+ this.#truncate(this.#titleLine(!withMark), infoWidth),
218
+ this.#truncate(theme.fg("muted", t("welcome.tagline")), infoWidth),
219
+ this.#truncate(this.#modelLine(), infoWidth),
220
+ this.#truncate(this.#whereLine(infoWidth), infoWidth),
221
+ ];
222
+ if (!withMark) return info;
223
+ // Center the four identity lines against the pet's height.
224
+ const offset = Math.max(0, Math.floor((mark.length - info.length) / 2));
225
+ const rows = Math.max(mark.length, info.length + offset);
226
+ const out: string[] = [];
227
+ for (let row = 0; row < rows; row++) {
228
+ const art = mark[row] ?? padding(markWidth);
229
+ const text = info[row - offset] ?? "";
230
+ out.push(text ? `${art}${padding(MARK_GAP)}${text}` : art);
313
231
  }
314
- const leftPad = Math.floor((width - visLen) / 2);
315
- const rightPad = width - visLen - leftPad;
316
- return padding(leftPad) + text + padding(rightPad);
232
+ return out;
317
233
  }
318
234
 
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);
235
+ /** The Sayknow pet itself, in half blocks; the pose follows the intro. */
236
+ #markRows(): string[] {
237
+ const step = this.#animStart == null ? -1 : Math.floor((performance.now() - this.#animStart) / WAVE_STEP_MS);
238
+ if (this.logoMode === "ascii") {
239
+ const rows: string[] = [...MARK_ASCII];
240
+ if (step >= 0 && step < INTRO_POSES.length - 1 && step % 2 === 1) rows[3] = MARK_ASCII_WAVE;
241
+ return rows.map(row => theme.fg("accent", row));
324
242
  }
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));
243
+ const pose = step >= 0 && step < INTRO_POSES.length ? INTRO_POSES[step]! : "base";
244
+ return renderPetHalfBlocks(pose, this.options.petSkin ?? "red", theme.getColorMode());
332
245
  }
333
246
 
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);
247
+ /** `withIcon` adds the 🐙 glyph for layouts that have no room for the drawn mark. */
248
+ #titleLine(withIcon = false): string {
249
+ const buildLabel = this.options.buildLabel ?? formatBuildLabel();
250
+ const mark = withIcon && this.logoMode !== "ascii" && theme.icon.pi ? `${theme.icon.pi} ` : "";
251
+ return `${mark}${theme.bold(theme.fg("text", "Sayknow-CLI"))}${theme.fg("dim", ` v${this.version} · ${buildLabel}`)}`;
338
252
  }
339
253
 
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;
254
+ #modelLine(): string {
255
+ const sep = theme.fg("dim", " · ");
256
+ if (this.modelName === "Unknown" || this.modelName.length === 0) {
257
+ return `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`;
344
258
  }
345
- const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
346
- return Math.max(0, Math.floor(viewportRows) - reservedRows);
259
+ const parts = [theme.bold(theme.fg("statusLineModel", this.modelName))];
260
+ const level = this.#snapshot.thinkingLevel;
261
+ if (level) parts.push(theme.getThinkingBorderColor(String(level) as ThinkingLevel)(String(level)));
262
+ else if (this.providerName) parts.push(theme.fg("muted", this.providerName));
263
+ return parts.join(sep);
347
264
  }
348
265
 
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)}`;
266
+ #whereLine(width: number): string {
267
+ const { cwd, branch } = this.#snapshot;
268
+ const sep = theme.fg("dim", " · ");
269
+ const branchPart =
270
+ typeof branch === "string"
271
+ ? `${theme.fg(this.#isDirty() ? "statusLineGitDirty" : "statusLineGitClean", branch)}${this.#changeSummary()}`
272
+ : "";
273
+ if (!cwd) return branchPart;
274
+ const pathBudget = Math.max(8, width - (branchPart ? visibleWidth(branchPart) + 3 : 0));
275
+ const path = theme.fg("statusLinePath", this.#shortenFromLeft(cwd, pathBudget));
276
+ return branchPart ? `${path}${sep}${branchPart}` : path;
355
277
  }
356
278
 
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 }, () => "")];
279
+ #isDirty(): boolean {
280
+ const changes = this.#snapshot.gitChanges;
281
+ return !!changes && changes.staged + changes.unstaged + changes.untracked > 0;
370
282
  }
371
283
 
372
- #flowKeyItemText(item: { key: string; label: string }): string {
373
- const context = this.options.keyDisplayContext ?? { platform: process.platform };
374
- return `${theme.fg("dim", formatKeyHint(item.key, context))}${theme.fg("muted", ` ${this.#flowKeyLabel(item.label)}`)}`;
375
- }
376
-
377
- #flowKeyLabel(label: string): string {
378
- switch (label) {
379
- case "commands":
380
- return t("welcome.commands");
381
- case "actions":
382
- return t("welcome.actions");
383
- case "shell":
384
- return t("welcome.shell");
385
- case "python":
386
- return t("welcome.python");
387
- case "keymap":
388
- return t("welcome.keymap");
389
- case "model":
390
- return t("welcome.model");
391
- case "reasoning":
392
- return t("welcome.reasoning");
393
- default:
394
- return label;
395
- }
284
+ #changeSummary(): string {
285
+ const changes = this.#snapshot.gitChanges;
286
+ if (!changes || !this.#isDirty()) return "";
287
+ const parts: string[] = [];
288
+ if (changes.staged > 0) parts.push(theme.fg("statusLineStaged", `+${changes.staged}`));
289
+ if (changes.unstaged > 0) parts.push(theme.fg("statusLineDirty", `~${changes.unstaged}`));
290
+ if (changes.untracked > 0) parts.push(theme.fg("statusLineUntracked", `?${changes.untracked}`));
291
+ return ` ${parts.join(" ")}`;
396
292
  }
397
293
 
398
- #flowKeyRows(width: number): string[] {
399
- const contentWidth = Math.max(1, width - 1);
400
- const separator = ` ${theme.fg("dim", "·")} `;
401
- const rows: string[] = [];
402
- let current = "";
403
- for (const item of flowKeyItems(this.options.keyDisplayContext ?? { platform: process.platform })) {
404
- const segment = this.#flowKeyItemText(item);
405
- const next = current ? `${current}${separator}${segment}` : segment;
406
- if (current && visibleWidth(next) > contentWidth) {
407
- rows.push(` ${current}`);
408
- current = segment;
409
- } else {
410
- current = next;
411
- }
412
- }
413
- if (current) rows.push(` ${current}`);
414
- return rows.length > 0 ? rows : [` ${theme.fg("dim", "No flow keys")}`];
415
- }
416
-
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[] {
486
- if (this.recentSessions.length === 0) {
487
- return [` ${theme.fg("dim", t("welcome.noSessions"))}`];
488
- }
489
-
490
- const bulletPrefix = ` ${theme.md.bullet} `;
491
- const prefixWidth = visibleWidth(bulletPrefix);
492
- 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)}`);
500
- }
501
- return lines;
502
- }
294
+ // ── Release note, sessions, keys ────────────────────────────────────────
503
295
 
504
- #whatsNewLines(width: number, maxRows: number): string[] {
505
- const rowLimit = Math.max(1, Math.floor(maxRows));
296
+ /** One line, only after an update: the new version, its first note, and a pointer to /changelog. */
297
+ #releaseNoteLine(width: number): string | undefined {
506
298
  const changelog = this.options.changelogMarkdown?.trim();
507
- if (!changelog) {
508
- return [` ${theme.fg("dim", "Ready for your next prompt")}`];
509
- }
510
-
299
+ if (!changelog) return undefined;
511
300
  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);
520
- if (items.length === 0) {
521
- return [
522
- ` ${theme.fg("muted", `Updated to v${version}`)}`,
523
- ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
524
- ].slice(0, rowLimit);
525
- }
301
+ const lead = theme.bold(theme.fg("accent", `v${version}`));
302
+ const pointer = theme.fg("dim", "/changelog");
303
+ const first = this.options.collapseChangelog ? undefined : this.#changelogItems(changelog)[0];
304
+ if (!first) return this.#spread(lead, pointer, width);
305
+ const room = width - visibleWidth(lead) - visibleWidth(pointer) - 4;
306
+ const body = room >= 12 ? ` ${theme.fg("muted", this.#truncate(first, room))}` : "";
307
+ return this.#spread(`${lead}${body}`, pointer, width);
308
+ }
526
309
 
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)}`;
310
+ #sessionLines(width: number): string[] {
311
+ const heading = theme.bold(theme.fg("accent", t("welcome.sessionTrail")));
312
+ if (this.recentSessions.length === 0) return [heading, theme.fg("dim", t("welcome.noSessions"))];
313
+ const context = this.options.keyDisplayContext ?? { platform: process.platform };
314
+ const hints = [t("welcome.allSessions", { key: this.#resumeKey() })];
315
+ if (this.options.continueKey)
316
+ hints.unshift(t("welcome.continue", { key: formatKeyHint(this.options.continueKey, context) }));
317
+ const lines = [this.#spread(heading, theme.fg("dim", hints.join(" · ")), width)];
318
+ this.recentSessions.slice(0, SESSION_ROWS).forEach((session, index) => {
319
+ // The first row is the one the continue key resumes; mark it.
320
+ const lead = index === 0 && this.options.continueKey ? theme.fg("accent", "› ") : " ";
321
+ const time = theme.fg("dim", session.timeAgo);
322
+ const nameWidth = Math.max(1, width - visibleWidth(lead) - visibleWidth(time) - 2);
323
+ const name = theme.fg(index === 0 ? "text" : "muted", this.#truncate(session.name, nameWidth));
324
+ lines.push(this.#spread(`${lead}${name}`, time, width));
533
325
  });
534
- if (items.length > visibleItems.length && visibleItems.length < rowLimit) {
535
- visibleItems.push(` ${theme.fg("dim", `… ${theme.bold("/changelog")} for full notes`)}`);
536
- }
537
- return visibleItems;
326
+ return lines;
327
+ }
328
+
329
+ #keysLine(width: number): string {
330
+ const context = this.options.keyDisplayContext ?? { platform: process.platform };
331
+ const items: ReadonlyArray<{ key: string; label: MsgKey }> = [
332
+ { key: "/", label: "welcome.commands" },
333
+ { key: this.#resumeKey(), label: "welcome.sessions" },
334
+ { key: formatKeyHint("ctrl+l", context), label: "welcome.model" },
335
+ { key: "?", label: "welcome.keymap" },
336
+ ];
337
+ const text = items
338
+ .map(item => `${theme.fg("text", item.key)} ${theme.fg("dim", t(item.label))}`)
339
+ .join(theme.fg("dim", " "));
340
+ return this.#truncate(text, width);
341
+ }
342
+
343
+ #resumeKey(): string {
344
+ const context = this.options.keyDisplayContext ?? { platform: process.platform };
345
+ return this.options.resumeKey ? formatKeyHint(this.options.resumeKey, context) : "/resume";
538
346
  }
539
347
 
540
348
  #latestChangelogVersion(markdown: string): string {
541
- const versionMatch = markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
542
- return versionMatch?.[1] ?? this.version;
349
+ return markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/)?.[1] ?? this.version;
543
350
  }
544
351
 
545
352
  #changelogItems(markdown: string): string[] {
@@ -551,158 +358,84 @@ export class WelcomeComponent implements Component {
551
358
  inFence = !inFence;
552
359
  continue;
553
360
  }
554
- if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) {
555
- continue;
556
- }
557
- const withoutBullet = line
361
+ if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) continue;
362
+ const cleaned = line
558
363
  .replace(/^[-*]\s+/, "")
559
364
  .replace(/^\d+\.\s+/, "")
560
- .replace(/^>\s*/, "");
561
- const cleaned = this.#stripMarkdown(withoutBullet);
365
+ .replace(/^>\s*/, "")
366
+ .replace(/`([^`]+)`/g, "$1")
367
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
368
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
369
+ .replace(/__([^_]+)__/g, "$1")
370
+ .replace(/\*([^*]+)\*/g, "$1")
371
+ .replace(/[_~]/g, "")
372
+ .trim();
562
373
  if (cleaned) items.push(cleaned);
563
374
  }
564
375
  return items;
565
376
  }
566
377
 
567
- #stripMarkdown(text: string): string {
568
- return text
569
- .replace(/`([^`]+)`/g, "$1")
570
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
571
- .replace(/\*\*([^*]+)\*\*/g, "$1")
572
- .replace(/__([^_]+)__/g, "$1")
573
- .replace(/\*([^*]+)\*/g, "$1")
574
- .replace(/[_~]/g, "")
575
- .trim();
378
+ // ── Intro ───────────────────────────────────────────────────────────────
379
+
380
+ /** Sections that have taken their colors; the rest are drawn in dim ink until the intro reaches them. */
381
+ #colorReveal(sectionCount: number): number {
382
+ if (this.#animStart == null) return sectionCount;
383
+ const elapsed = performance.now() - this.#animStart;
384
+ return Math.min(sectionCount, Math.floor((elapsed - SECTION_SETTLE_MS) / SECTION_STAGGER_MS) + 1);
576
385
  }
577
386
 
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
- )}`;
387
+ #inkSection(lines: string[], order: number, colored: number): string[] {
388
+ return order < colored ? lines : lines.map(line => theme.fg("dim", Bun.stripANSI(line)));
583
389
  }
584
390
 
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];
588
- 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;
391
+ // ── Layout helpers ──────────────────────────────────────────────────────
392
+
393
+ /** `left` and `right` on one row, `right` flush with the card's right edge. */
394
+ #spread(left: string, right: string, width: number): string {
395
+ const room = width - visibleWidth(left) - visibleWidth(right);
396
+ return room >= 2 ? `${left}${padding(room)}${right}` : this.#truncate(left, width);
606
397
  }
607
- }
608
398
 
609
- // biome-ignore format: preserve ASCII art layout
610
- const BRAND_LOGO = [
611
- "╔═╗╔═╗╦ ╦╦╔═╔╗╔╔═╗╦ ╦",
612
- "╚═╗╠═╣╚╦╝╠╩╗║║║║ ║║║║",
613
- "╚═╝╩ ╩ ╩ ╩ ╩╝╚╝╚═╝╚╩╝",
614
- ];
615
-
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
- ];
624
-
625
- /** 256-color ramp fallback when truecolor isn't available. */
626
- const GRADIENT_RAMP_256 = [17, 18, 24, 31, 38, 75, 117];
627
-
628
- /** Half-width of the shine highlight band, expressed in gradient-t units. */
629
- const SHINE_HALF_WIDTH = 0.18;
630
-
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;
699
- }
700
- return result;
701
- });
399
+ #truncate(text: string, width: number): string {
400
+ return visibleWidth(text) > width ? truncateToWidth(text, width) : text;
401
+ }
402
+
403
+ /** Keep the tail of a long path — the project directory is the part that identifies it. */
404
+ #shortenFromLeft(value: string, width: number): string {
405
+ if (visibleWidth(value) <= width) return value;
406
+ if (width <= 1) return "…";
407
+ let tail = value;
408
+ while (tail.length > 0 && visibleWidth(tail) > width - 1) tail = tail.slice(1);
409
+ return `…${tail}`;
410
+ }
411
+
412
+ /** Fit a string to exactly `width` columns (native ANSI/wide-glyph aware). */
413
+ #fit(str: string, width: number): string {
414
+ const visLen = visibleWidth(str);
415
+ if (visLen > width) return truncateToWidth(str, width, null, true);
416
+ return str + padding(width - visLen);
417
+ }
418
+
419
+ #rightGutterWidth(termWidth: number): number {
420
+ const configured = this.options.rightGutterWidth ?? 0;
421
+ if (!Number.isFinite(configured) || configured <= 0) return 0;
422
+ return Math.min(Math.floor(configured), Math.max(0, termWidth - 4));
423
+ }
424
+
425
+ #withRightGutter(lines: string[], rightGutterWidth: number): string[] {
426
+ if (rightGutterWidth <= 0) return lines;
427
+ const gutter = padding(rightGutterWidth);
428
+ return lines.map(line => line + gutter);
429
+ }
430
+
431
+ #targetRows(termWidth: number): number | undefined {
432
+ const viewportRows = this.options.getViewportRows?.();
433
+ if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) return undefined;
434
+ const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
435
+ return Math.max(0, Math.floor(viewportRows) - reservedRows);
436
+ }
702
437
  }
703
438
 
704
- /** Total length of the intro animation. */
705
- const INTRO_MS = 3000;
706
439
  /** Resolve the intro cadence without making tests mutate global process state. */
707
440
  export function resolveWelcomeIntroTickMs(
708
441
  platform: NodeJS.Platform = process.platform,
@@ -713,14 +446,3 @@ export function resolveWelcomeIntroTickMs(
713
446
 
714
447
  /** Render at 30fps directly, but cap native Windows multiplexers at 10fps to avoid ConPTY output backpressure. */
715
448
  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
- };