@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.
- package/CHANGELOG.md +35 -1
- package/README.md +2 -2
- package/dist/types/config/keybindings.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +7 -3
- package/dist/types/i18n/messages/en.d.ts +5 -14
- package/dist/types/modes/components/session-selector.d.ts +7 -3
- package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
- package/dist/types/modes/components/user-message-selector.d.ts +5 -2
- package/dist/types/modes/components/user-message.d.ts +3 -1
- package/dist/types/modes/components/welcome.d.ts +34 -14
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +14 -2
- package/dist/types/modes/theme/defaults/index.d.ts +342 -0
- package/dist/types/modes/theme/theme.d.ts +5 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/session/session-manager.d.ts +9 -0
- package/dist/types/session/session-stars.d.ts +15 -0
- package/dist/types/tools/debug.d.ts +2 -2
- package/dist/types/tui/output-block.d.ts +12 -1
- package/dist/types/tui/utils.d.ts +1 -3
- package/dist/types/utils/session-title-generation.d.ts +6 -0
- package/dist/types/utils/title-generator.d.ts +11 -2
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +8 -0
- package/src/cli/session-picker.ts +4 -0
- package/src/config/keybindings.ts +8 -1
- package/src/config/settings-schema.ts +4 -3
- package/src/i18n/messages/de.messages.ts +4 -1
- package/src/i18n/messages/de.settings.ts +2 -0
- package/src/i18n/messages/de.ts +3 -12
- package/src/i18n/messages/en.ts +5 -14
- package/src/i18n/messages/es.messages.ts +4 -1
- package/src/i18n/messages/es.settings.ts +2 -0
- package/src/i18n/messages/es.ts +3 -12
- package/src/i18n/messages/fr.messages.ts +4 -1
- package/src/i18n/messages/fr.settings.ts +2 -0
- package/src/i18n/messages/fr.ts +3 -12
- package/src/i18n/messages/ja.messages.ts +4 -1
- package/src/i18n/messages/ja.settings.ts +2 -0
- package/src/i18n/messages/ja.ts +3 -12
- package/src/i18n/messages/ko.messages.ts +4 -1
- package/src/i18n/messages/ko.settings.ts +2 -0
- package/src/i18n/messages/ko.ts +4 -13
- package/src/i18n/messages/zh.messages.ts +2 -1
- package/src/i18n/messages/zh.settings.ts +2 -0
- package/src/i18n/messages/zh.ts +3 -12
- package/src/internal-urls/docs-index.generated.ts +10 -10
- package/src/lsp/render.ts +0 -1
- package/src/modes/action-registry.ts +1 -0
- package/src/modes/components/session-selector.ts +71 -21
- package/src/modes/components/sessions-dashboard.ts +8 -5
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/presets.ts +5 -5
- package/src/modes/components/status-line/separators.ts +4 -0
- package/src/modes/components/tool-execution.ts +6 -19
- package/src/modes/components/tool-status-header.ts +103 -12
- package/src/modes/components/user-message-selector.ts +57 -15
- package/src/modes/components/user-message.ts +25 -15
- package/src/modes/components/welcome.ts +298 -576
- package/src/modes/controllers/command-controller.ts +56 -7
- package/src/modes/controllers/input-controller.ts +17 -0
- package/src/modes/controllers/selector-controller.ts +91 -10
- package/src/modes/interactive-mode.ts +85 -38
- package/src/modes/theme/defaults/glow-octopus.json +114 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/ink-octopus.json +114 -0
- package/src/modes/theme/defaults/violet-octopus.json +114 -0
- package/src/modes/theme/theme.ts +18 -2
- package/src/modes/types.ts +3 -1
- package/src/prompts/system/title-system.md +1 -1
- package/src/sdk/protocol/operation-inventory.generated.json +40 -0
- package/src/session/agent-session.ts +12 -0
- package/src/session/internal/managed-session-scope.ts +10 -0
- package/src/session/session-manager.ts +26 -1
- package/src/session/session-stars.ts +104 -0
- package/src/slash-commands/builtin-registry.ts +104 -4
- package/src/tools/debug.ts +0 -1
- package/src/tools/fetch.ts +0 -1
- package/src/tui/output-block.ts +29 -61
- package/src/tui/utils.ts +1 -8
- package/src/utils/session-title-generation.ts +29 -0
- package/src/utils/title-generator.ts +46 -2
|
@@ -1,19 +1,34 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
74
|
-
*
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
const rightPad = width - visLen - leftPad;
|
|
316
|
-
return padding(leftPad) + text + padding(rightPad);
|
|
232
|
+
return out;
|
|
317
233
|
}
|
|
318
234
|
|
|
319
|
-
/**
|
|
320
|
-
#
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
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
|
-
#
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
343
|
-
return
|
|
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
|
|
346
|
-
|
|
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
|
-
#
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
#
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
#
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
#
|
|
579
|
-
return
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
};
|