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