@sayknow-cli/coding-agent 0.6.5 → 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.
@@ -1,5 +1,13 @@
1
1
  import type { ThinkingLevel } from "@sayknow-cli/agent-core";
2
- import { type Component, padding, truncateToWidth, visibleWidth } from "@sayknow-cli/tui";
2
+ import {
3
+ type Component,
4
+ type PetSkinId,
5
+ padding,
6
+ renderPetHalfBlocks,
7
+ type SayknowPixelFrameName,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ } from "@sayknow-cli/tui";
3
11
  import { formatBuildLabel } from "../../build-metadata";
4
12
  import { formatKeyHint, type KeyDisplayContext } from "../../config/keybindings";
5
13
  import { type MsgKey, t } from "../../i18n";
@@ -10,21 +18,9 @@ export interface RecentSession {
10
18
  timeAgo: string;
11
19
  }
12
20
 
13
- export interface LspServerInfo {
14
- name: string;
15
- status: "idle" | "ready" | "error" | "connecting";
16
- fileTypes: string[];
17
- }
18
-
19
- export interface WelcomeRoleBinding {
20
- role: string;
21
- model: string;
22
- }
23
-
24
21
  /**
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.
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.
28
24
  */
29
25
  export interface WelcomeSnapshot {
30
26
  /** Display path of the project directory (already home-shortened). */
@@ -32,16 +28,7 @@ export interface WelcomeSnapshot {
32
28
  /** Current branch, `"detached"`, or `null` outside a git repository. */
33
29
  branch?: string | null;
34
30
  gitChanges?: { staged: number; unstaged: number; untracked: number } | null;
35
- /** Latest commits as `<short-sha> <subject>` onelines, newest first. */
36
- recentCommits?: readonly string[];
37
31
  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
32
  }
46
33
 
47
34
  export type WelcomeLogoMode = "unicode" | "square" | "ascii";
@@ -50,65 +37,56 @@ export interface WelcomeComponentOptions {
50
37
  getReservedBottomRows?: (termWidth: number) => number;
51
38
  changelogMarkdown?: string;
52
39
  rightGutterWidth?: number;
40
+ /** Show only the version on the release line, without its first note. */
53
41
  collapseChangelog?: boolean;
54
42
  buildLabel?: string;
55
43
  keyDisplayContext?: KeyDisplayContext;
56
44
  skipLogoAnimation?: boolean;
57
45
  snapshot?: WelcomeSnapshot;
58
- /** Key bound to the resume picker (`app.session.resume`); the sessions heading names it. */
46
+ /** Key bound to the resume picker (`app.session.resume`); the card names it. */
59
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;
60
52
  }
61
53
 
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. */
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. */
68
62
  const SESSION_ROWS = 3;
69
- const MAX_LSP_ROWS = 3;
70
- const MAX_COMMIT_ROWS = 3;
71
63
 
72
- /** Stagger between two ledger sections appearing during the launch reveal. */
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
73
  const SECTION_STAGGER_MS = 55;
74
- /** How long the whole ledger stays in dim ink before the first section takes its colors. */
75
74
  const SECTION_SETTLE_MS = 90;
75
+ const WAVE_STEP_MS = 140;
76
+ const INTRO_MS = INTRO_POSES.length * WAVE_STEP_MS;
76
77
 
77
- function flowKeyItems(context: KeyDisplayContext): ReadonlyArray<{ key: string; label: string }> {
78
- const newlineKey = context.platform === "win32" ? "alt+enter" : "ctrl+j";
79
- return [
80
- { key: "/", label: "commands" },
81
- { key: "#", label: "actions" },
82
- { key: "!", label: "shell" },
83
- { key: "$", label: "python" },
84
- { key: "?", label: "keymap" },
85
- { key: "ctrl+l", label: "model" },
86
- { key: "shift+tab", label: "reasoning" },
87
- { key: "tab", label: "complete" },
88
- { key: newlineKey, label: "newline" },
89
- { key: "ctrl+c", label: "clear" },
90
- ];
91
- }
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. */
78
+ /** A block of rows that takes its colors together during the intro. */
101
79
  interface Section {
102
80
  lines: string[];
81
+ /** When the viewport is short, the section with the highest rank is dropped first. */
82
+ dropRank: number;
103
83
  }
104
84
 
105
85
  /**
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.
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.
112
90
  */
113
91
  export class WelcomeComponent implements Component {
114
92
  #animStart: number | null = null;
@@ -120,7 +98,6 @@ export class WelcomeComponent implements Component {
120
98
  private modelName: string,
121
99
  private providerName: string,
122
100
  private recentSessions: RecentSession[] = [],
123
- private lspServers: LspServerInfo[] = [],
124
101
  private readonly logoMode: WelcomeLogoMode = "unicode",
125
102
  private readonly options: WelcomeComponentOptions = {},
126
103
  ) {
@@ -130,10 +107,8 @@ export class WelcomeComponent implements Component {
130
107
  invalidate(): void {}
131
108
 
132
109
  /**
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.
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.
137
112
  */
138
113
  playIntro(requestRender: () => void): void {
139
114
  this.#stopAnimation();
@@ -145,9 +120,7 @@ export class WelcomeComponent implements Component {
145
120
  requestRender();
146
121
  this.#animTimer = setInterval(() => {
147
122
  const elapsed = performance.now() - (this.#animStart ?? 0);
148
- if (elapsed >= INTRO_MS) {
149
- this.#stopAnimation();
150
- }
123
+ if (elapsed >= INTRO_MS) this.#stopAnimation();
151
124
  requestRender();
152
125
  }, INTRO_TICK_MS);
153
126
  this.#animTimer.unref?.();
@@ -174,11 +147,7 @@ export class WelcomeComponent implements Component {
174
147
  this.recentSessions = sessions;
175
148
  }
176
149
 
177
- setLspServers(servers: LspServerInfo[]): void {
178
- this.lspServers = servers;
179
- }
180
-
181
- /** Merge newly probed workspace facts into the ledger. */
150
+ /** Merge newly probed workspace facts into the card. */
182
151
  setSnapshot(patch: WelcomeSnapshot): void {
183
152
  this.#snapshot = { ...this.#snapshot, ...patch };
184
153
  }
@@ -190,168 +159,121 @@ export class WelcomeComponent implements Component {
190
159
 
191
160
  const targetRows = this.#targetRows(termWidth);
192
161
  if (targetRows !== undefined && targetRows <= 0) return [];
162
+ if (targetRows === 1) return this.#withRightGutter([this.#fit(this.#titleLine(true), width)], gutterWidth);
193
163
 
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));
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));
227
- }
228
-
229
- return this.#withRightGutter([header, rule, ...body], gutterWidth);
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);
230
171
  }
231
172
 
232
- // ── Header ──────────────────────────────────────────────────────────────
233
-
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;
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;
189
+ };
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();
196
+ }
197
+ return lines.slice(0, targetRows);
198
+ }
199
+
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 };
207
+ }
208
+
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);
231
+ }
232
+ return out;
241
233
  }
242
234
 
243
- #ruleGlyph(): string {
244
- return this.logoMode === "ascii" ? "-" : "─";
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));
242
+ }
243
+ const pose = step >= 0 && step < INTRO_POSES.length ? INTRO_POSES[step]! : "base";
244
+ return renderPetHalfBlocks(pose, this.options.petSkin ?? "red", theme.getColorMode());
245
245
  }
246
246
 
247
- // ── Left column: workspace ledger ───────────────────────────────────────
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}`)}`;
252
+ }
248
253
 
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;
254
+ #modelLine(): string {
266
255
  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));
256
+ if (this.modelName === "Unknown" || this.modelName.length === 0) {
257
+ return `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`;
278
258
  }
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
- });
287
-
288
- // Brain
289
- const brain: string[] = [];
290
- const hasModel = this.modelName !== "Unknown" && this.modelName.length > 0;
291
- brain.push(
292
- row(
293
- labels.model,
294
- hasModel
295
- ? `${theme.bold(theme.fg("statusLineModel", this.modelName))}${sep}${theme.fg("muted", this.providerName)}`
296
- : `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`,
297
- ),
298
- );
299
- if (snapshot.thinkingLevel) {
300
- const level = String(snapshot.thinkingLevel);
301
- brain.push(row(labels.reasoning, theme.getThinkingBorderColor(level as ThinkingLevel)(level)));
302
- }
303
- brain.push(row(labels.preset, snapshot.profile ? theme.fg("text", snapshot.profile) : theme.fg("dim", "—")));
304
- const roles = snapshot.roles ?? [];
305
- if (roles.length === 0) {
306
- brain.push(row(labels.roles, theme.fg("dim", t("welcome.rolesInherit"))));
307
- } else {
308
- const roleWidth = Math.max(...roles.map(role => visibleWidth(role.role))) + 2;
309
- roles.forEach((binding, index) => {
310
- const value = `${theme.fg("muted", binding.role)}${padding(roleWidth - visibleWidth(binding.role))}${theme.fg("text", binding.model)}`;
311
- brain.push(index === 0 ? row(labels.roles, value) : continuation(value));
312
- });
313
- }
314
-
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}`);
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
- });
337
-
338
- return [where, brain, hands].filter(lines => lines.length > 0).map(lines => ({ lines: [...lines, ""] }));
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);
339
264
  }
340
265
 
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;
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
279
  #isDirty(): boolean {
@@ -359,144 +281,72 @@ export class WelcomeComponent implements Component {
359
281
  return !!changes && changes.staged + changes.unstaged + changes.untracked > 0;
360
282
  }
361
283
 
362
- #gitChangeSummary(sep: string): string {
284
+ #changeSummary(): string {
363
285
  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"))}`;
286
+ if (!changes || !this.#isDirty()) return "";
367
287
  const parts: string[] = [];
368
288
  if (changes.staged > 0) parts.push(theme.fg("statusLineStaged", `+${changes.staged}`));
369
289
  if (changes.unstaged > 0) parts.push(theme.fg("statusLineDirty", `~${changes.unstaged}`));
370
290
  if (changes.untracked > 0) parts.push(theme.fg("statusLineUntracked", `?${changes.untracked}`));
371
- return `${sep}${parts.join(" ")}`;
291
+ return ` ${parts.join(" ")}`;
372
292
  }
373
293
 
374
- // ── Right column: activity ──────────────────────────────────────────────
375
-
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);
294
+ // ── Release note, sessions, keys ────────────────────────────────────────
386
295
 
296
+ /** One line, only after an update: the new version, its first note, and a pointer to /changelog. */
297
+ #releaseNoteLine(width: number): string | undefined {
387
298
  const changelog = this.options.changelogMarkdown?.trim();
388
- const version = changelog ? this.#latestChangelogVersion(changelog) : undefined;
389
- const context = this.options.keyDisplayContext ?? { platform: process.platform };
390
- const resumeKey = this.options.resumeKey ? formatKeyHint(this.options.resumeKey, context) : "/resume";
391
- const sessionsNote = this.recentSessions.length > 0 ? t("welcome.allSessions", { key: resumeKey }) : undefined;
392
-
393
- const workflowWidth = Math.max(...WORKFLOWS.map(item => visibleWidth(item.command))) + 2;
394
- return [
395
- { lines: [heading(t("welcome.sessionTrail"), sessionsNote), ...this.#sessionTrailLines(width), ""] },
396
- { lines: [heading(t("welcome.whatsNew"), version ? `v${version}` : undefined), ...whatsNew, ""] },
397
- {
398
- lines: [
399
- heading(t("welcome.workflows")),
400
- ...WORKFLOWS.map(
401
- item =>
402
- ` ${theme.fg("accent", item.command)}${padding(workflowWidth - visibleWidth(item.command))}${theme.fg("muted", t(item.key))}`,
403
- ),
404
- "",
405
- ],
406
- },
407
- { lines: [heading(t("welcome.flowKeys")), ...keyRows] },
408
- ];
409
- }
410
-
411
- #flowKeyItemText(item: { key: string; label: string }): string {
299
+ if (!changelog) return undefined;
300
+ const version = this.#latestChangelogVersion(changelog);
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
+ }
309
+
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"))];
412
313
  const context = this.options.keyDisplayContext ?? { platform: process.platform };
413
- return `${theme.fg("text", formatKeyHint(item.key, context))}${theme.fg("dim", ` ${this.#flowKeyLabel(item.label)}`)}`;
414
- }
415
-
416
- #flowKeyLabel(label: string): string {
417
- switch (label) {
418
- case "commands":
419
- return t("welcome.commands");
420
- case "actions":
421
- return t("welcome.actions");
422
- case "shell":
423
- return t("welcome.shell");
424
- case "python":
425
- return t("welcome.python");
426
- case "keymap":
427
- return t("welcome.keymap");
428
- case "model":
429
- return t("welcome.model");
430
- case "reasoning":
431
- return t("welcome.reasoning");
432
- default:
433
- return label;
434
- }
435
- }
436
-
437
- #flowKeyRows(width: number): string[] {
438
- const contentWidth = Math.max(1, width - 2);
439
- const separator = theme.fg("dim", " ");
440
- const rows: string[] = [];
441
- let current = "";
442
- for (const item of flowKeyItems(this.options.keyDisplayContext ?? { platform: process.platform })) {
443
- const segment = this.#flowKeyItemText(item);
444
- const next = current ? `${current}${separator}${segment}` : segment;
445
- if (current && visibleWidth(next) > contentWidth) {
446
- rows.push(` ${current}`);
447
- current = segment;
448
- } else {
449
- current = next;
450
- }
451
- }
452
- if (current) rows.push(` ${current}`);
453
- return rows;
454
- }
455
-
456
- #sessionTrailLines(width: number): string[] {
457
- if (this.recentSessions.length === 0) {
458
- return [` ${theme.fg("dim", t("welcome.noSessions"))}`];
459
- }
460
- const lines: string[] = [];
461
- for (const session of this.recentSessions.slice(0, SESSION_ROWS)) {
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", "› ") : " ";
462
321
  const time = theme.fg("dim", session.timeAgo);
463
- const nameBudget = Math.max(1, width - 2 - visibleWidth(session.timeAgo) - 2);
464
- const name = this.#truncate(session.name, nameBudget);
465
- const pad = padding(Math.max(1, width - 2 - visibleWidth(name) - visibleWidth(session.timeAgo)));
466
- lines.push(` ${theme.fg("muted", name)}${pad}${time}`);
467
- }
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));
325
+ });
468
326
  return lines;
469
327
  }
470
328
 
471
- #whatsNewLines(width: number, maxRows: number): string[] {
472
- const rowLimit = Math.max(1, Math.floor(maxRows));
473
- const changelog = this.options.changelogMarkdown?.trim();
474
- if (!changelog) return [` ${theme.fg("dim", t("welcome.readyPrompt"))}`];
475
-
476
- const version = this.#latestChangelogVersion(changelog);
477
- const items = this.options.collapseChangelog ? [] : this.#changelogItems(changelog);
478
- if (items.length === 0) {
479
- return [
480
- ` ${theme.fg("muted", `Updated to v${version}`)}`,
481
- ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
482
- ].slice(0, rowLimit);
483
- }
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
+ }
484
342
 
485
- const bullet = ` ${theme.md.bullet} `;
486
- const textWidth = Math.max(1, width - visibleWidth(bullet));
487
- const visibleCount = items.length > rowLimit ? Math.max(1, rowLimit - 1) : rowLimit;
488
- const lines = items
489
- .slice(0, visibleCount)
490
- .map(item => `${theme.fg("accent", bullet)}${theme.fg("muted", this.#truncate(item, textWidth))}`);
491
- if (items.length > lines.length && lines.length < rowLimit) {
492
- lines.push(` ${theme.fg("dim", `… ${theme.bold("/changelog")} for full notes`)}`);
493
- }
494
- return lines;
343
+ #resumeKey(): string {
344
+ const context = this.options.keyDisplayContext ?? { platform: process.platform };
345
+ return this.options.resumeKey ? formatKeyHint(this.options.resumeKey, context) : "/resume";
495
346
  }
496
347
 
497
348
  #latestChangelogVersion(markdown: string): string {
498
- const versionMatch = markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
499
- return versionMatch?.[1] ?? this.version;
349
+ return markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/)?.[1] ?? this.version;
500
350
  }
501
351
 
502
352
  #changelogItems(markdown: string): string[] {
@@ -509,60 +359,41 @@ export class WelcomeComponent implements Component {
509
359
  continue;
510
360
  }
511
361
  if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) continue;
512
- const withoutBullet = line
362
+ const cleaned = line
513
363
  .replace(/^[-*]\s+/, "")
514
364
  .replace(/^\d+\.\s+/, "")
515
- .replace(/^>\s*/, "");
516
- 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();
517
373
  if (cleaned) items.push(cleaned);
518
374
  }
519
375
  return items;
520
376
  }
521
377
 
522
- #stripMarkdown(text: string): string {
523
- return text
524
- .replace(/`([^`]+)`/g, "$1")
525
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
526
- .replace(/\*\*([^*]+)\*\*/g, "$1")
527
- .replace(/__([^_]+)__/g, "$1")
528
- .replace(/\*([^*]+)\*/g, "$1")
529
- .replace(/[_~]/g, "")
530
- .trim();
531
- }
532
-
533
- // ── Launch reveal ───────────────────────────────────────────────────────
378
+ // ── Intro ───────────────────────────────────────────────────────────────
534
379
 
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 };
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;
539
383
  const elapsed = performance.now() - this.#animStart;
540
- return { colored: Math.min(sectionCount, Math.floor((elapsed - SECTION_SETTLE_MS) / SECTION_STAGGER_MS) + 1) };
384
+ return Math.min(sectionCount, Math.floor((elapsed - SECTION_SETTLE_MS) / SECTION_STAGGER_MS) + 1);
541
385
  }
542
386
 
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;
387
+ #inkSection(lines: string[], order: number, colored: number): string[] {
388
+ return order < colored ? lines : lines.map(line => theme.fg("dim", Bun.stripANSI(line)));
557
389
  }
558
390
 
559
391
  // ── Layout helpers ──────────────────────────────────────────────────────
560
392
 
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", " …")];
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);
566
397
  }
567
398
 
568
399
  #truncate(text: string, width: number): string {
@@ -578,8 +409,8 @@ export class WelcomeComponent implements Component {
578
409
  return `…${tail}`;
579
410
  }
580
411
 
581
- /** Fit string to exact width with native ANSI/wide-glyph truncation and padding. */
582
- #fitToWidth(str: string, width: number): string {
412
+ /** Fit a string to exactly `width` columns (native ANSI/wide-glyph aware). */
413
+ #fit(str: string, width: number): string {
583
414
  const visLen = visibleWidth(str);
584
415
  if (visLen > width) return truncateToWidth(str, width, null, true);
585
416
  return str + padding(width - visLen);
@@ -588,8 +419,7 @@ export class WelcomeComponent implements Component {
588
419
  #rightGutterWidth(termWidth: number): number {
589
420
  const configured = this.options.rightGutterWidth ?? 0;
590
421
  if (!Number.isFinite(configured) || configured <= 0) return 0;
591
- const gutterWidth = Math.floor(configured);
592
- return Math.min(gutterWidth, Math.max(0, termWidth - 4));
422
+ return Math.min(Math.floor(configured), Math.max(0, termWidth - 4));
593
423
  }
594
424
 
595
425
  #withRightGutter(lines: string[], rightGutterWidth: number): string[] {
@@ -600,16 +430,12 @@ export class WelcomeComponent implements Component {
600
430
 
601
431
  #targetRows(termWidth: number): number | undefined {
602
432
  const viewportRows = this.options.getViewportRows?.();
603
- if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) {
604
- return undefined;
605
- }
433
+ if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) return undefined;
606
434
  const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
607
435
  return Math.max(0, Math.floor(viewportRows) - reservedRows);
608
436
  }
609
437
  }
610
438
 
611
- /** Upper bound on the reveal: enough for every section to land and settle. */
612
- const INTRO_MS = 12 * SECTION_STAGGER_MS + SECTION_SETTLE_MS;
613
439
  /** Resolve the intro cadence without making tests mutate global process state. */
614
440
  export function resolveWelcomeIntroTickMs(
615
441
  platform: NodeJS.Platform = process.platform,