@sayknow-cli/coding-agent 0.6.5 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/i18n/messages/en.d.ts +4 -29
- package/dist/types/modes/components/welcome.d.ts +17 -40
- package/dist/types/modes/interactive-mode.d.ts +7 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +2 -2
- package/package.json +7 -7
- package/src/config/keybindings.ts +7 -0
- package/src/i18n/messages/de.ts +2 -27
- package/src/i18n/messages/en.ts +4 -29
- package/src/i18n/messages/es.ts +2 -27
- package/src/i18n/messages/fr.ts +2 -27
- package/src/i18n/messages/ja.ts +2 -27
- package/src/i18n/messages/ko.ts +3 -28
- package/src/i18n/messages/zh.ts +2 -27
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/action-registry.ts +1 -0
- package/src/modes/components/welcome.ts +222 -396
- package/src/modes/controllers/input-controller.ts +13 -0
- package/src/modes/interactive-mode.ts +52 -94
- package/src/modes/types.ts +2 -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";
|
|
@@ -10,21 +18,9 @@ export interface RecentSession {
|
|
|
10
18
|
timeAgo: string;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
|
-
export interface LspServerInfo {
|
|
14
|
-
name: string;
|
|
15
|
-
status: "idle" | "ready" | "error" | "connecting";
|
|
16
|
-
fileTypes: string[];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface WelcomeRoleBinding {
|
|
20
|
-
role: string;
|
|
21
|
-
model: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
21
|
/**
|
|
25
|
-
* Workspace
|
|
26
|
-
*
|
|
27
|
-
* asynchronous probes (git status, context files, MCP) settle.
|
|
22
|
+
* Workspace facts on the launch card. Every field is optional: the card renders
|
|
23
|
+
* what is known and fills in git state once the asynchronous probe settles.
|
|
28
24
|
*/
|
|
29
25
|
export interface WelcomeSnapshot {
|
|
30
26
|
/** Display path of the project directory (already home-shortened). */
|
|
@@ -32,16 +28,7 @@ export interface WelcomeSnapshot {
|
|
|
32
28
|
/** Current branch, `"detached"`, or `null` outside a git repository. */
|
|
33
29
|
branch?: string | null;
|
|
34
30
|
gitChanges?: { staged: number; unstaged: number; untracked: number } | null;
|
|
35
|
-
/** Latest commits as `<short-sha> <subject>` onelines, newest first. */
|
|
36
|
-
recentCommits?: readonly string[];
|
|
37
31
|
thinkingLevel?: ThinkingLevel | string;
|
|
38
|
-
/** Display name of the active model preset. */
|
|
39
|
-
profile?: string;
|
|
40
|
-
roles?: readonly WelcomeRoleBinding[];
|
|
41
|
-
mcp?: { connected: number; total: number };
|
|
42
|
-
skills?: number;
|
|
43
|
-
/** Display names of the loaded project instruction files (AGENTS.md, …). */
|
|
44
|
-
contextFiles?: readonly string[];
|
|
45
32
|
}
|
|
46
33
|
|
|
47
34
|
export type WelcomeLogoMode = "unicode" | "square" | "ascii";
|
|
@@ -50,65 +37,56 @@ export interface WelcomeComponentOptions {
|
|
|
50
37
|
getReservedBottomRows?: (termWidth: number) => number;
|
|
51
38
|
changelogMarkdown?: string;
|
|
52
39
|
rightGutterWidth?: number;
|
|
40
|
+
/** Show only the version on the release line, without its first note. */
|
|
53
41
|
collapseChangelog?: boolean;
|
|
54
42
|
buildLabel?: string;
|
|
55
43
|
keyDisplayContext?: KeyDisplayContext;
|
|
56
44
|
skipLogoAnimation?: boolean;
|
|
57
45
|
snapshot?: WelcomeSnapshot;
|
|
58
|
-
/** Key bound to the resume picker (`app.session.resume`); the
|
|
46
|
+
/** Key bound to the resume picker (`app.session.resume`); the card names it. */
|
|
59
47
|
resumeKey?: string;
|
|
48
|
+
/** Key bound to `app.session.continue`, which resumes the first session on the card. */
|
|
49
|
+
continueKey?: string;
|
|
50
|
+
/** Which Sayknow pet stands on the card (default red). */
|
|
51
|
+
petSkin?: PetSkinId;
|
|
60
52
|
}
|
|
61
53
|
|
|
62
|
-
/**
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
/**
|
|
54
|
+
/** Left margin of the card, and the widest the card grows on wide terminals. */
|
|
55
|
+
const MARGIN = 2;
|
|
56
|
+
const MAX_CARD_WIDTH = 76;
|
|
57
|
+
/** Gap between the mark and the identity lines. */
|
|
58
|
+
const MARK_GAP = 3;
|
|
59
|
+
/** Below this card width the mark is dropped and only the identity lines remain. */
|
|
60
|
+
const MIN_WIDTH_FOR_MARK = 44;
|
|
61
|
+
/** Recent sessions on the card; the resume picker has the rest. */
|
|
68
62
|
const SESSION_ROWS = 3;
|
|
69
|
-
const MAX_LSP_ROWS = 3;
|
|
70
|
-
const MAX_COMMIT_ROWS = 3;
|
|
71
63
|
|
|
72
|
-
/**
|
|
64
|
+
/** ASCII-safe stand-in for the pet when the banner must avoid block glyphs. */
|
|
65
|
+
const MARK_ASCII = [" .---. ", " ( o o ) ", " ) ( ", " /\\/\\/\\/ "] as const;
|
|
66
|
+
const MARK_ASCII_WAVE = " \\/\\/\\/\\ ";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Intro: the pet does a short para-para (tentacles sway left, right, left) and
|
|
70
|
+
* lands on its resting pose while color spreads down the card.
|
|
71
|
+
*/
|
|
72
|
+
const INTRO_POSES: readonly SayknowPixelFrameName[] = ["danceL", "danceR", "danceL", "base"];
|
|
73
73
|
const SECTION_STAGGER_MS = 55;
|
|
74
|
-
/** How long the whole ledger stays in dim ink before the first section takes its colors. */
|
|
75
74
|
const SECTION_SETTLE_MS = 90;
|
|
75
|
+
const WAVE_STEP_MS = 140;
|
|
76
|
+
const INTRO_MS = INTRO_POSES.length * WAVE_STEP_MS;
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
const newlineKey = context.platform === "win32" ? "alt+enter" : "ctrl+j";
|
|
79
|
-
return [
|
|
80
|
-
{ key: "/", label: "commands" },
|
|
81
|
-
{ key: "#", label: "actions" },
|
|
82
|
-
{ key: "!", label: "shell" },
|
|
83
|
-
{ key: "$", label: "python" },
|
|
84
|
-
{ key: "?", label: "keymap" },
|
|
85
|
-
{ key: "ctrl+l", label: "model" },
|
|
86
|
-
{ key: "shift+tab", label: "reasoning" },
|
|
87
|
-
{ key: "tab", label: "complete" },
|
|
88
|
-
{ key: newlineKey, label: "newline" },
|
|
89
|
-
{ key: "ctrl+c", label: "clear" },
|
|
90
|
-
];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const WORKFLOWS: ReadonlyArray<{ command: string; key: MsgKey }> = [
|
|
94
|
-
{ command: "/deep-interview", key: "welcome.wf.deepInterview" },
|
|
95
|
-
{ command: "/ralplan", key: "welcome.wf.ralplan" },
|
|
96
|
-
{ command: "/ultragoal", key: "welcome.wf.ultragoal" },
|
|
97
|
-
{ command: "/team", key: "welcome.wf.team" },
|
|
98
|
-
];
|
|
99
|
-
|
|
100
|
-
/** A block of rows revealed together during the launch intro. */
|
|
78
|
+
/** A block of rows that takes its colors together during the intro. */
|
|
101
79
|
interface Section {
|
|
102
80
|
lines: string[];
|
|
81
|
+
/** When the viewport is short, the section with the highest rank is dropped first. */
|
|
82
|
+
dropRank: number;
|
|
103
83
|
}
|
|
104
84
|
|
|
105
85
|
/**
|
|
106
|
-
* Sayknow-CLI launch
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* and keys. No enclosing box and no hero wordmark: the octopus mark and the
|
|
111
|
-
* facts carry the identity.
|
|
86
|
+
* Sayknow-CLI launch card: the octopus mark beside who and where you are
|
|
87
|
+
* (version, model and reasoning, project and branch), then the three most recent
|
|
88
|
+
* sessions and a single row of keys. Everything else — the full session list,
|
|
89
|
+
* workflows, the keymap, release notes — is one key away, not on the card.
|
|
112
90
|
*/
|
|
113
91
|
export class WelcomeComponent implements Component {
|
|
114
92
|
#animStart: number | null = null;
|
|
@@ -120,7 +98,6 @@ export class WelcomeComponent implements Component {
|
|
|
120
98
|
private modelName: string,
|
|
121
99
|
private providerName: string,
|
|
122
100
|
private recentSessions: RecentSession[] = [],
|
|
123
|
-
private lspServers: LspServerInfo[] = [],
|
|
124
101
|
private readonly logoMode: WelcomeLogoMode = "unicode",
|
|
125
102
|
private readonly options: WelcomeComponentOptions = {},
|
|
126
103
|
) {
|
|
@@ -130,10 +107,8 @@ export class WelcomeComponent implements Component {
|
|
|
130
107
|
invalidate(): void {}
|
|
131
108
|
|
|
132
109
|
/**
|
|
133
|
-
* Play
|
|
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.
|
|
110
|
+
* Play the short launch intro. Content is fully readable from the first frame;
|
|
111
|
+
* only color and the tentacles move. Safe to call again — it restarts.
|
|
137
112
|
*/
|
|
138
113
|
playIntro(requestRender: () => void): void {
|
|
139
114
|
this.#stopAnimation();
|
|
@@ -145,9 +120,7 @@ export class WelcomeComponent implements Component {
|
|
|
145
120
|
requestRender();
|
|
146
121
|
this.#animTimer = setInterval(() => {
|
|
147
122
|
const elapsed = performance.now() - (this.#animStart ?? 0);
|
|
148
|
-
if (elapsed >= INTRO_MS)
|
|
149
|
-
this.#stopAnimation();
|
|
150
|
-
}
|
|
123
|
+
if (elapsed >= INTRO_MS) this.#stopAnimation();
|
|
151
124
|
requestRender();
|
|
152
125
|
}, INTRO_TICK_MS);
|
|
153
126
|
this.#animTimer.unref?.();
|
|
@@ -174,11 +147,7 @@ export class WelcomeComponent implements Component {
|
|
|
174
147
|
this.recentSessions = sessions;
|
|
175
148
|
}
|
|
176
149
|
|
|
177
|
-
|
|
178
|
-
this.lspServers = servers;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Merge newly probed workspace facts into the ledger. */
|
|
150
|
+
/** Merge newly probed workspace facts into the card. */
|
|
182
151
|
setSnapshot(patch: WelcomeSnapshot): void {
|
|
183
152
|
this.#snapshot = { ...this.#snapshot, ...patch };
|
|
184
153
|
}
|
|
@@ -190,168 +159,121 @@ export class WelcomeComponent implements Component {
|
|
|
190
159
|
|
|
191
160
|
const targetRows = this.#targetRows(termWidth);
|
|
192
161
|
if (targetRows !== undefined && targetRows <= 0) return [];
|
|
162
|
+
if (targetRows === 1) return this.#withRightGutter([this.#fit(this.#titleLine(true), width)], gutterWidth);
|
|
193
163
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const leftWidth = twoColumn
|
|
202
|
-
? Math.min(Math.max(44, Math.floor(width * 0.52)), width - COLUMN_GAP - MIN_RIGHT_COLUMN)
|
|
203
|
-
: width;
|
|
204
|
-
const rightWidth = twoColumn ? width - leftWidth - COLUMN_GAP : width;
|
|
205
|
-
|
|
206
|
-
const ledger = this.#ledgerSections(leftWidth);
|
|
207
|
-
const activity = this.#activitySections(rightWidth);
|
|
208
|
-
|
|
209
|
-
const reveal = this.#revealState(ledger.length + activity.length);
|
|
210
|
-
const leftLines = this.#revealLines(ledger, 0, reveal);
|
|
211
|
-
const rightLines = this.#revealLines(activity, ledger.length, reveal);
|
|
212
|
-
|
|
213
|
-
const body: string[] = [];
|
|
214
|
-
if (twoColumn) {
|
|
215
|
-
const rows = bodyRows ?? Math.max(leftLines.length, rightLines.length);
|
|
216
|
-
const left = this.#clip(leftLines, rows);
|
|
217
|
-
const right = this.#clip(rightLines, rows);
|
|
218
|
-
const gap = padding(COLUMN_GAP);
|
|
219
|
-
for (let i = 0; i < rows; i++) {
|
|
220
|
-
body.push(this.#fitToWidth(left[i] ?? "", leftWidth) + gap + this.#fitToWidth(right[i] ?? "", rightWidth));
|
|
221
|
-
}
|
|
222
|
-
} else {
|
|
223
|
-
const stacked = [...leftLines, "", ...rightLines];
|
|
224
|
-
const rows = bodyRows ?? stacked.length;
|
|
225
|
-
for (const line of this.#clip(stacked, rows)) body.push(this.#fitToWidth(line, width));
|
|
226
|
-
while (body.length < rows) body.push(padding(width));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return this.#withRightGutter([header, rule, ...body], gutterWidth);
|
|
164
|
+
const cardWidth = Math.max(1, Math.min(MAX_CARD_WIDTH, width - MARGIN));
|
|
165
|
+
const sections = this.#sections(cardWidth);
|
|
166
|
+
const lines = this.#fitRows(sections, targetRows);
|
|
167
|
+
const margin = padding(MARGIN);
|
|
168
|
+
const out = lines.map(line => this.#fit(line ? margin + line : "", width));
|
|
169
|
+
if (targetRows !== undefined) while (out.length < targetRows) out.push(padding(width));
|
|
170
|
+
return this.#withRightGutter(out, gutterWidth);
|
|
230
171
|
}
|
|
231
172
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Lay the sections out with a blank row above and between them. When the
|
|
175
|
+
* viewport is short, whole sections go by rank — keys, then the release
|
|
176
|
+
* note, then sessions — and the identity block is clipped last.
|
|
177
|
+
*/
|
|
178
|
+
#fitRows(sections: { identity: string[]; others: Section[] }, targetRows: number | undefined): string[] {
|
|
179
|
+
const colored = this.#colorReveal(1 + sections.others.length);
|
|
180
|
+
const identity = this.#inkSection(sections.identity, 0, colored);
|
|
181
|
+
let others = sections.others.map((section, index) => ({
|
|
182
|
+
...section,
|
|
183
|
+
lines: this.#inkSection(section.lines, index + 1, colored),
|
|
184
|
+
}));
|
|
185
|
+
const assemble = (): string[] => {
|
|
186
|
+
const out = ["", ...identity];
|
|
187
|
+
for (const section of others) out.push("", ...section.lines);
|
|
188
|
+
return out;
|
|
189
|
+
};
|
|
190
|
+
let lines = assemble();
|
|
191
|
+
if (targetRows === undefined) return lines;
|
|
192
|
+
while (lines.length > targetRows && others.length > 0) {
|
|
193
|
+
const drop = others.reduce((worst, section) => (section.dropRank > worst.dropRank ? section : worst));
|
|
194
|
+
others = others.filter(section => section !== drop);
|
|
195
|
+
lines = assemble();
|
|
196
|
+
}
|
|
197
|
+
return lines.slice(0, targetRows);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#sections(cardWidth: number): { identity: string[]; others: Section[] } {
|
|
201
|
+
const others: Section[] = [];
|
|
202
|
+
const note = this.#releaseNoteLine(cardWidth);
|
|
203
|
+
if (note) others.push({ lines: [note], dropRank: 2 });
|
|
204
|
+
others.push({ lines: this.#sessionLines(cardWidth), dropRank: 1 });
|
|
205
|
+
others.push({ lines: [this.#keysLine(cardWidth)], dropRank: 3 });
|
|
206
|
+
return { identity: this.#identityLines(cardWidth), others };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Identity: mark + who and where ──────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
#identityLines(cardWidth: number): string[] {
|
|
212
|
+
const withMark = cardWidth >= MIN_WIDTH_FOR_MARK;
|
|
213
|
+
const mark = withMark ? this.#markRows() : [];
|
|
214
|
+
const markWidth = Math.max(0, ...mark.map(row => visibleWidth(row)));
|
|
215
|
+
const infoWidth = Math.max(1, withMark ? cardWidth - markWidth - MARK_GAP : cardWidth);
|
|
216
|
+
const info = [
|
|
217
|
+
this.#truncate(this.#titleLine(!withMark), infoWidth),
|
|
218
|
+
this.#truncate(theme.fg("muted", t("welcome.tagline")), infoWidth),
|
|
219
|
+
this.#truncate(this.#modelLine(), infoWidth),
|
|
220
|
+
this.#truncate(this.#whereLine(infoWidth), infoWidth),
|
|
221
|
+
];
|
|
222
|
+
if (!withMark) return info;
|
|
223
|
+
// Center the four identity lines against the pet's height.
|
|
224
|
+
const offset = Math.max(0, Math.floor((mark.length - info.length) / 2));
|
|
225
|
+
const rows = Math.max(mark.length, info.length + offset);
|
|
226
|
+
const out: string[] = [];
|
|
227
|
+
for (let row = 0; row < rows; row++) {
|
|
228
|
+
const art = mark[row] ?? padding(markWidth);
|
|
229
|
+
const text = info[row - offset] ?? "";
|
|
230
|
+
out.push(text ? `${art}${padding(MARK_GAP)}${text}` : art);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
241
233
|
}
|
|
242
234
|
|
|
243
|
-
|
|
244
|
-
|
|
235
|
+
/** The Sayknow pet itself, in half blocks; the pose follows the intro. */
|
|
236
|
+
#markRows(): string[] {
|
|
237
|
+
const step = this.#animStart == null ? -1 : Math.floor((performance.now() - this.#animStart) / WAVE_STEP_MS);
|
|
238
|
+
if (this.logoMode === "ascii") {
|
|
239
|
+
const rows: string[] = [...MARK_ASCII];
|
|
240
|
+
if (step >= 0 && step < INTRO_POSES.length - 1 && step % 2 === 1) rows[3] = MARK_ASCII_WAVE;
|
|
241
|
+
return rows.map(row => theme.fg("accent", row));
|
|
242
|
+
}
|
|
243
|
+
const pose = step >= 0 && step < INTRO_POSES.length ? INTRO_POSES[step]! : "base";
|
|
244
|
+
return renderPetHalfBlocks(pose, this.options.petSkin ?? "red", theme.getColorMode());
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
/** `withIcon` adds the 🐙 glyph for layouts that have no room for the drawn mark. */
|
|
248
|
+
#titleLine(withIcon = false): string {
|
|
249
|
+
const buildLabel = this.options.buildLabel ?? formatBuildLabel();
|
|
250
|
+
const mark = withIcon && this.logoMode !== "ascii" && theme.icon.pi ? `${theme.icon.pi} ` : "";
|
|
251
|
+
return `${mark}${theme.bold(theme.fg("text", "Sayknow-CLI"))}${theme.fg("dim", ` v${this.version} · ${buildLabel}`)}`;
|
|
252
|
+
}
|
|
248
253
|
|
|
249
|
-
#
|
|
250
|
-
const labels = {
|
|
251
|
-
workspace: t("welcome.label.workspace"),
|
|
252
|
-
branch: t("welcome.label.branch"),
|
|
253
|
-
commits: t("welcome.label.commits"),
|
|
254
|
-
model: t("welcome.label.model"),
|
|
255
|
-
reasoning: t("welcome.label.reasoning"),
|
|
256
|
-
preset: t("welcome.label.preset"),
|
|
257
|
-
roles: t("welcome.label.roles"),
|
|
258
|
-
tools: t("welcome.label.tools"),
|
|
259
|
-
};
|
|
260
|
-
const labelWidth = Math.max(...Object.values(labels).map(label => visibleWidth(label))) + 2;
|
|
261
|
-
const valueWidth = Math.max(1, width - labelWidth - 1);
|
|
262
|
-
const row = (label: string, value: string): string =>
|
|
263
|
-
` ${theme.fg("dim", label)}${padding(Math.max(0, labelWidth - visibleWidth(label)))}${this.#truncate(value, valueWidth)}`;
|
|
264
|
-
const continuation = (value: string): string => ` ${padding(labelWidth)}${this.#truncate(value, valueWidth)}`;
|
|
265
|
-
const snapshot = this.#snapshot;
|
|
254
|
+
#modelLine(): string {
|
|
266
255
|
const sep = theme.fg("dim", " · ");
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const where: string[] = [];
|
|
270
|
-
if (snapshot.cwd)
|
|
271
|
-
where.push(row(labels.workspace, theme.fg("statusLinePath", this.#shortenFromLeft(snapshot.cwd, valueWidth))));
|
|
272
|
-
if (snapshot.branch !== undefined) {
|
|
273
|
-
const branch =
|
|
274
|
-
snapshot.branch === null
|
|
275
|
-
? theme.fg("dim", t("welcome.noGit"))
|
|
276
|
-
: `${theme.fg(this.#isDirty() ? "statusLineGitDirty" : "statusLineGitClean", snapshot.branch)}${this.#gitChangeSummary(sep)}`;
|
|
277
|
-
where.push(row(labels.branch, branch));
|
|
256
|
+
if (this.modelName === "Unknown" || this.modelName.length === 0) {
|
|
257
|
+
return `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`;
|
|
278
258
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
: theme.fg("muted", oneline);
|
|
285
|
-
where.push(index === 0 ? row(labels.commits, value) : continuation(value));
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// Brain
|
|
289
|
-
const brain: string[] = [];
|
|
290
|
-
const hasModel = this.modelName !== "Unknown" && this.modelName.length > 0;
|
|
291
|
-
brain.push(
|
|
292
|
-
row(
|
|
293
|
-
labels.model,
|
|
294
|
-
hasModel
|
|
295
|
-
? `${theme.bold(theme.fg("statusLineModel", this.modelName))}${sep}${theme.fg("muted", this.providerName)}`
|
|
296
|
-
: `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`,
|
|
297
|
-
),
|
|
298
|
-
);
|
|
299
|
-
if (snapshot.thinkingLevel) {
|
|
300
|
-
const level = String(snapshot.thinkingLevel);
|
|
301
|
-
brain.push(row(labels.reasoning, theme.getThinkingBorderColor(level as ThinkingLevel)(level)));
|
|
302
|
-
}
|
|
303
|
-
brain.push(row(labels.preset, snapshot.profile ? theme.fg("text", snapshot.profile) : theme.fg("dim", "—")));
|
|
304
|
-
const roles = snapshot.roles ?? [];
|
|
305
|
-
if (roles.length === 0) {
|
|
306
|
-
brain.push(row(labels.roles, theme.fg("dim", t("welcome.rolesInherit"))));
|
|
307
|
-
} else {
|
|
308
|
-
const roleWidth = Math.max(...roles.map(role => visibleWidth(role.role))) + 2;
|
|
309
|
-
roles.forEach((binding, index) => {
|
|
310
|
-
const value = `${theme.fg("muted", binding.role)}${padding(roleWidth - visibleWidth(binding.role))}${theme.fg("text", binding.model)}`;
|
|
311
|
-
brain.push(index === 0 ? row(labels.roles, value) : continuation(value));
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Hands
|
|
316
|
-
const hands: string[] = [];
|
|
317
|
-
const toolFacts: string[] = [];
|
|
318
|
-
if (snapshot.mcp) {
|
|
319
|
-
const { connected, total } = snapshot.mcp;
|
|
320
|
-
const color = total === 0 ? "dim" : connected === total ? "success" : "warning";
|
|
321
|
-
toolFacts.push(`${theme.fg("muted", "MCP")} ${theme.fg(color, total === 0 ? "0" : `${connected}/${total}`)}`);
|
|
322
|
-
}
|
|
323
|
-
if (snapshot.skills !== undefined) {
|
|
324
|
-
toolFacts.push(`${theme.fg("muted", t("welcome.skills"))} ${theme.fg("text", String(snapshot.skills))}`);
|
|
325
|
-
}
|
|
326
|
-
if (snapshot.contextFiles !== undefined) {
|
|
327
|
-
const files = snapshot.contextFiles;
|
|
328
|
-
const shown = files.length === 0 ? theme.fg("dim", "—") : theme.fg("text", files[0]!);
|
|
329
|
-
const more = files.length > 1 ? theme.fg("dim", ` +${files.length - 1}`) : "";
|
|
330
|
-
toolFacts.push(`${theme.fg("muted", t("welcome.rules"))} ${shown}${more}`);
|
|
331
|
-
}
|
|
332
|
-
const lspLines = this.#lspLines();
|
|
333
|
-
const toolRows = [...(toolFacts.length > 0 ? [toolFacts.join(sep)] : []), ...lspLines];
|
|
334
|
-
toolRows.forEach((value, index) => {
|
|
335
|
-
hands.push(index === 0 ? row(labels.tools, value) : continuation(value));
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
return [where, brain, hands].filter(lines => lines.length > 0).map(lines => ({ lines: [...lines, ""] }));
|
|
259
|
+
const parts = [theme.bold(theme.fg("statusLineModel", this.modelName))];
|
|
260
|
+
const level = this.#snapshot.thinkingLevel;
|
|
261
|
+
if (level) parts.push(theme.getThinkingBorderColor(String(level) as ThinkingLevel)(String(level)));
|
|
262
|
+
else if (this.providerName) parts.push(theme.fg("muted", this.providerName));
|
|
263
|
+
return parts.join(sep);
|
|
339
264
|
}
|
|
340
265
|
|
|
341
|
-
#
|
|
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;
|
|
266
|
+
#whereLine(width: number): string {
|
|
267
|
+
const { cwd, branch } = this.#snapshot;
|
|
268
|
+
const sep = theme.fg("dim", " · ");
|
|
269
|
+
const branchPart =
|
|
270
|
+
typeof branch === "string"
|
|
271
|
+
? `${theme.fg(this.#isDirty() ? "statusLineGitDirty" : "statusLineGitClean", branch)}${this.#changeSummary()}`
|
|
272
|
+
: "";
|
|
273
|
+
if (!cwd) return branchPart;
|
|
274
|
+
const pathBudget = Math.max(8, width - (branchPart ? visibleWidth(branchPart) + 3 : 0));
|
|
275
|
+
const path = theme.fg("statusLinePath", this.#shortenFromLeft(cwd, pathBudget));
|
|
276
|
+
return branchPart ? `${path}${sep}${branchPart}` : path;
|
|
355
277
|
}
|
|
356
278
|
|
|
357
279
|
#isDirty(): boolean {
|
|
@@ -359,144 +281,72 @@ export class WelcomeComponent implements Component {
|
|
|
359
281
|
return !!changes && changes.staged + changes.unstaged + changes.untracked > 0;
|
|
360
282
|
}
|
|
361
283
|
|
|
362
|
-
#
|
|
284
|
+
#changeSummary(): string {
|
|
363
285
|
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"))}`;
|
|
286
|
+
if (!changes || !this.#isDirty()) return "";
|
|
367
287
|
const parts: string[] = [];
|
|
368
288
|
if (changes.staged > 0) parts.push(theme.fg("statusLineStaged", `+${changes.staged}`));
|
|
369
289
|
if (changes.unstaged > 0) parts.push(theme.fg("statusLineDirty", `~${changes.unstaged}`));
|
|
370
290
|
if (changes.untracked > 0) parts.push(theme.fg("statusLineUntracked", `?${changes.untracked}`));
|
|
371
|
-
return
|
|
291
|
+
return ` ${parts.join(" ")}`;
|
|
372
292
|
}
|
|
373
293
|
|
|
374
|
-
// ──
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* The launch screen stays short on purpose: three recent sessions and three
|
|
378
|
-
* notes, never more. The full session list lives in the resume picker, and the
|
|
379
|
-
* sessions heading names the key that opens it.
|
|
380
|
-
*/
|
|
381
|
-
#activitySections(width: number): Section[] {
|
|
382
|
-
const keyRows = this.#flowKeyRows(width);
|
|
383
|
-
const heading = (label: string, note?: string): string =>
|
|
384
|
-
` ${theme.bold(theme.fg("accent", label))}${note ? theme.fg("dim", ` ${note}`) : ""}`;
|
|
385
|
-
const whatsNew = this.#whatsNewLines(width, WHATS_NEW_ROWS);
|
|
294
|
+
// ── Release note, sessions, keys ────────────────────────────────────────
|
|
386
295
|
|
|
296
|
+
/** One line, only after an update: the new version, its first note, and a pointer to /changelog. */
|
|
297
|
+
#releaseNoteLine(width: number): string | undefined {
|
|
387
298
|
const changelog = this.options.changelogMarkdown?.trim();
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
` ${theme.fg("accent", item.command)}${padding(workflowWidth - visibleWidth(item.command))}${theme.fg("muted", t(item.key))}`,
|
|
403
|
-
),
|
|
404
|
-
"",
|
|
405
|
-
],
|
|
406
|
-
},
|
|
407
|
-
{ lines: [heading(t("welcome.flowKeys")), ...keyRows] },
|
|
408
|
-
];
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
#flowKeyItemText(item: { key: string; label: string }): string {
|
|
299
|
+
if (!changelog) return undefined;
|
|
300
|
+
const version = this.#latestChangelogVersion(changelog);
|
|
301
|
+
const lead = theme.bold(theme.fg("accent", `v${version}`));
|
|
302
|
+
const pointer = theme.fg("dim", "/changelog");
|
|
303
|
+
const first = this.options.collapseChangelog ? undefined : this.#changelogItems(changelog)[0];
|
|
304
|
+
if (!first) return this.#spread(lead, pointer, width);
|
|
305
|
+
const room = width - visibleWidth(lead) - visibleWidth(pointer) - 4;
|
|
306
|
+
const body = room >= 12 ? ` ${theme.fg("muted", this.#truncate(first, room))}` : "";
|
|
307
|
+
return this.#spread(`${lead}${body}`, pointer, width);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#sessionLines(width: number): string[] {
|
|
311
|
+
const heading = theme.bold(theme.fg("accent", t("welcome.sessionTrail")));
|
|
312
|
+
if (this.recentSessions.length === 0) return [heading, theme.fg("dim", t("welcome.noSessions"))];
|
|
412
313
|
const context = this.options.keyDisplayContext ?? { platform: process.platform };
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
case "actions":
|
|
421
|
-
return t("welcome.actions");
|
|
422
|
-
case "shell":
|
|
423
|
-
return t("welcome.shell");
|
|
424
|
-
case "python":
|
|
425
|
-
return t("welcome.python");
|
|
426
|
-
case "keymap":
|
|
427
|
-
return t("welcome.keymap");
|
|
428
|
-
case "model":
|
|
429
|
-
return t("welcome.model");
|
|
430
|
-
case "reasoning":
|
|
431
|
-
return t("welcome.reasoning");
|
|
432
|
-
default:
|
|
433
|
-
return label;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
#flowKeyRows(width: number): string[] {
|
|
438
|
-
const contentWidth = Math.max(1, width - 2);
|
|
439
|
-
const separator = theme.fg("dim", " ");
|
|
440
|
-
const rows: string[] = [];
|
|
441
|
-
let current = "";
|
|
442
|
-
for (const item of flowKeyItems(this.options.keyDisplayContext ?? { platform: process.platform })) {
|
|
443
|
-
const segment = this.#flowKeyItemText(item);
|
|
444
|
-
const next = current ? `${current}${separator}${segment}` : segment;
|
|
445
|
-
if (current && visibleWidth(next) > contentWidth) {
|
|
446
|
-
rows.push(` ${current}`);
|
|
447
|
-
current = segment;
|
|
448
|
-
} else {
|
|
449
|
-
current = next;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
if (current) rows.push(` ${current}`);
|
|
453
|
-
return rows;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
#sessionTrailLines(width: number): string[] {
|
|
457
|
-
if (this.recentSessions.length === 0) {
|
|
458
|
-
return [` ${theme.fg("dim", t("welcome.noSessions"))}`];
|
|
459
|
-
}
|
|
460
|
-
const lines: string[] = [];
|
|
461
|
-
for (const session of this.recentSessions.slice(0, SESSION_ROWS)) {
|
|
314
|
+
const hints = [t("welcome.allSessions", { key: this.#resumeKey() })];
|
|
315
|
+
if (this.options.continueKey)
|
|
316
|
+
hints.unshift(t("welcome.continue", { key: formatKeyHint(this.options.continueKey, context) }));
|
|
317
|
+
const lines = [this.#spread(heading, theme.fg("dim", hints.join(" · ")), width)];
|
|
318
|
+
this.recentSessions.slice(0, SESSION_ROWS).forEach((session, index) => {
|
|
319
|
+
// The first row is the one the continue key resumes; mark it.
|
|
320
|
+
const lead = index === 0 && this.options.continueKey ? theme.fg("accent", "› ") : " ";
|
|
462
321
|
const time = theme.fg("dim", session.timeAgo);
|
|
463
|
-
const
|
|
464
|
-
const name = this.#truncate(session.name,
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
322
|
+
const nameWidth = Math.max(1, width - visibleWidth(lead) - visibleWidth(time) - 2);
|
|
323
|
+
const name = theme.fg(index === 0 ? "text" : "muted", this.#truncate(session.name, nameWidth));
|
|
324
|
+
lines.push(this.#spread(`${lead}${name}`, time, width));
|
|
325
|
+
});
|
|
468
326
|
return lines;
|
|
469
327
|
}
|
|
470
328
|
|
|
471
|
-
#
|
|
472
|
-
const
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
329
|
+
#keysLine(width: number): string {
|
|
330
|
+
const context = this.options.keyDisplayContext ?? { platform: process.platform };
|
|
331
|
+
const items: ReadonlyArray<{ key: string; label: MsgKey }> = [
|
|
332
|
+
{ key: "/", label: "welcome.commands" },
|
|
333
|
+
{ key: this.#resumeKey(), label: "welcome.sessions" },
|
|
334
|
+
{ key: formatKeyHint("ctrl+l", context), label: "welcome.model" },
|
|
335
|
+
{ key: "?", label: "welcome.keymap" },
|
|
336
|
+
];
|
|
337
|
+
const text = items
|
|
338
|
+
.map(item => `${theme.fg("text", item.key)} ${theme.fg("dim", t(item.label))}`)
|
|
339
|
+
.join(theme.fg("dim", " "));
|
|
340
|
+
return this.#truncate(text, width);
|
|
341
|
+
}
|
|
484
342
|
|
|
485
|
-
|
|
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;
|
|
343
|
+
#resumeKey(): string {
|
|
344
|
+
const context = this.options.keyDisplayContext ?? { platform: process.platform };
|
|
345
|
+
return this.options.resumeKey ? formatKeyHint(this.options.resumeKey, context) : "/resume";
|
|
495
346
|
}
|
|
496
347
|
|
|
497
348
|
#latestChangelogVersion(markdown: string): string {
|
|
498
|
-
|
|
499
|
-
return versionMatch?.[1] ?? this.version;
|
|
349
|
+
return markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/)?.[1] ?? this.version;
|
|
500
350
|
}
|
|
501
351
|
|
|
502
352
|
#changelogItems(markdown: string): string[] {
|
|
@@ -509,60 +359,41 @@ export class WelcomeComponent implements Component {
|
|
|
509
359
|
continue;
|
|
510
360
|
}
|
|
511
361
|
if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) continue;
|
|
512
|
-
const
|
|
362
|
+
const cleaned = line
|
|
513
363
|
.replace(/^[-*]\s+/, "")
|
|
514
364
|
.replace(/^\d+\.\s+/, "")
|
|
515
|
-
.replace(/^>\s*/, "")
|
|
516
|
-
|
|
365
|
+
.replace(/^>\s*/, "")
|
|
366
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
367
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
368
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
369
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
370
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
371
|
+
.replace(/[_~]/g, "")
|
|
372
|
+
.trim();
|
|
517
373
|
if (cleaned) items.push(cleaned);
|
|
518
374
|
}
|
|
519
375
|
return items;
|
|
520
376
|
}
|
|
521
377
|
|
|
522
|
-
|
|
523
|
-
return text
|
|
524
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
525
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
526
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
527
|
-
.replace(/__([^_]+)__/g, "$1")
|
|
528
|
-
.replace(/\*([^*]+)\*/g, "$1")
|
|
529
|
-
.replace(/[_~]/g, "")
|
|
530
|
-
.trim();
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// ── Launch reveal ───────────────────────────────────────────────────────
|
|
378
|
+
// ── Intro ───────────────────────────────────────────────────────────────
|
|
534
379
|
|
|
535
|
-
/**
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (this.#animStart == null) return { colored: sectionCount };
|
|
380
|
+
/** Sections that have taken their colors; the rest are drawn in dim ink until the intro reaches them. */
|
|
381
|
+
#colorReveal(sectionCount: number): number {
|
|
382
|
+
if (this.#animStart == null) return sectionCount;
|
|
539
383
|
const elapsed = performance.now() - this.#animStart;
|
|
540
|
-
return
|
|
384
|
+
return Math.min(sectionCount, Math.floor((elapsed - SECTION_SETTLE_MS) / SECTION_STAGGER_MS) + 1);
|
|
541
385
|
}
|
|
542
386
|
|
|
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;
|
|
387
|
+
#inkSection(lines: string[], order: number, colored: number): string[] {
|
|
388
|
+
return order < colored ? lines : lines.map(line => theme.fg("dim", Bun.stripANSI(line)));
|
|
557
389
|
}
|
|
558
390
|
|
|
559
391
|
// ── Layout helpers ──────────────────────────────────────────────────────
|
|
560
392
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
return [...lines.slice(0, rows - 1), theme.fg("dim", " …")];
|
|
393
|
+
/** `left` and `right` on one row, `right` flush with the card's right edge. */
|
|
394
|
+
#spread(left: string, right: string, width: number): string {
|
|
395
|
+
const room = width - visibleWidth(left) - visibleWidth(right);
|
|
396
|
+
return room >= 2 ? `${left}${padding(room)}${right}` : this.#truncate(left, width);
|
|
566
397
|
}
|
|
567
398
|
|
|
568
399
|
#truncate(text: string, width: number): string {
|
|
@@ -578,8 +409,8 @@ export class WelcomeComponent implements Component {
|
|
|
578
409
|
return `…${tail}`;
|
|
579
410
|
}
|
|
580
411
|
|
|
581
|
-
/** Fit string to
|
|
582
|
-
#
|
|
412
|
+
/** Fit a string to exactly `width` columns (native ANSI/wide-glyph aware). */
|
|
413
|
+
#fit(str: string, width: number): string {
|
|
583
414
|
const visLen = visibleWidth(str);
|
|
584
415
|
if (visLen > width) return truncateToWidth(str, width, null, true);
|
|
585
416
|
return str + padding(width - visLen);
|
|
@@ -588,8 +419,7 @@ export class WelcomeComponent implements Component {
|
|
|
588
419
|
#rightGutterWidth(termWidth: number): number {
|
|
589
420
|
const configured = this.options.rightGutterWidth ?? 0;
|
|
590
421
|
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
591
|
-
|
|
592
|
-
return Math.min(gutterWidth, Math.max(0, termWidth - 4));
|
|
422
|
+
return Math.min(Math.floor(configured), Math.max(0, termWidth - 4));
|
|
593
423
|
}
|
|
594
424
|
|
|
595
425
|
#withRightGutter(lines: string[], rightGutterWidth: number): string[] {
|
|
@@ -600,16 +430,12 @@ export class WelcomeComponent implements Component {
|
|
|
600
430
|
|
|
601
431
|
#targetRows(termWidth: number): number | undefined {
|
|
602
432
|
const viewportRows = this.options.getViewportRows?.();
|
|
603
|
-
if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0)
|
|
604
|
-
return undefined;
|
|
605
|
-
}
|
|
433
|
+
if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) return undefined;
|
|
606
434
|
const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
|
|
607
435
|
return Math.max(0, Math.floor(viewportRows) - reservedRows);
|
|
608
436
|
}
|
|
609
437
|
}
|
|
610
438
|
|
|
611
|
-
/** Upper bound on the reveal: enough for every section to land and settle. */
|
|
612
|
-
const INTRO_MS = 12 * SECTION_STAGGER_MS + SECTION_SETTLE_MS;
|
|
613
439
|
/** Resolve the intro cadence without making tests mutate global process state. */
|
|
614
440
|
export function resolveWelcomeIntroTickMs(
|
|
615
441
|
platform: NodeJS.Platform = process.platform,
|