@linxiraos/pi-tui 1.2.0 → 1.3.0
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 +188 -120
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/app-keybindings.d.ts +18 -8
- package/dist/types/apps/git/diff-pane.d.ts +5 -0
- package/dist/types/apps/git/git-tui.d.ts +16 -2
- package/dist/types/apps/git/state.d.ts +11 -0
- package/dist/types/autocomplete.d.ts +2 -0
- package/dist/types/charts/chart-svg.d.ts +7 -13
- package/dist/types/chat/advisor-message.d.ts +4 -1
- package/dist/types/chat/assistant-message.d.ts +9 -4
- package/dist/types/chat/figure-markdown.d.ts +7 -2
- package/dist/types/chat/messages.d.ts +3 -1
- package/dist/types/chat/read-tool-group.d.ts +2 -2
- package/dist/types/chat/table-chart.d.ts +11 -15
- package/dist/types/chat/tool-execution.d.ts +32 -20
- package/dist/types/chat/user-message.d.ts +3 -2
- package/dist/types/chrome/countdown-timer.d.ts +3 -2
- package/dist/types/chrome/diff.d.ts +2 -3
- package/dist/types/chrome/transcript-container.d.ts +2 -0
- package/dist/types/components/image.d.ts +2 -0
- package/dist/types/components/markdown.d.ts +49 -3
- package/dist/types/components/text.d.ts +2 -0
- package/dist/types/debug-server.d.ts +2 -0
- package/dist/types/keybinding-matchers.d.ts +6 -0
- package/dist/types/multiplexers/tmux.d.ts +1 -0
- package/dist/types/multiplexers/types.d.ts +6 -0
- package/dist/types/native/backend.d.ts +5 -4
- package/dist/types/native/blobs.d.ts +24 -12
- package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
- package/dist/types/overlays/annotation-overlay.d.ts +19 -0
- package/dist/types/overlays/approval-dialog.d.ts +79 -0
- package/dist/types/overlays/ask-dialog.d.ts +11 -6
- package/dist/types/overlays/btw-history-panel.d.ts +2 -6
- package/dist/types/overlays/hook-editor.d.ts +7 -2
- package/dist/types/overlays/hook-input.d.ts +12 -4
- package/dist/types/overlays/job-tree.d.ts +50 -0
- package/dist/types/overlays/jobs-panel.d.ts +25 -11
- package/dist/types/overlays/login-dialog.d.ts +8 -3
- package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
- package/dist/types/overlays/model-browser.d.ts +6 -1
- package/dist/types/overlays/model-picker.d.ts +2 -0
- package/dist/types/overlays/pause-screen.d.ts +5 -0
- package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
- package/dist/types/overlays/report-panel.d.ts +5 -1
- package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
- package/dist/types/overlays/session-account-selector.d.ts +4 -0
- package/dist/types/overlays/session-info-overlay.d.ts +7 -3
- package/dist/types/overlays/session-selector.d.ts +3 -1
- package/dist/types/overlays/usage-dashboard.d.ts +6 -6
- package/dist/types/prompt/composer.d.ts +22 -26
- package/dist/types/prompt/custom-editor.d.ts +20 -10
- package/dist/types/prompt/image-references.d.ts +22 -0
- package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
- package/dist/types/prompt/macos-spelling.d.ts +2 -1
- package/dist/types/prompt/prose-gate.d.ts +3 -0
- package/dist/types/prompt/queued-messages.d.ts +1 -1
- package/dist/types/prompt/typing-word.d.ts +25 -0
- package/dist/types/prompt/welcome.d.ts +35 -43
- package/dist/types/render/hanging-wrap.d.ts +7 -0
- package/dist/types/render/hyperlink.d.ts +2 -0
- package/dist/types/render/render-utils.d.ts +9 -1
- package/dist/types/render/utils.d.ts +1 -1
- package/dist/types/setup/scenes/splash.d.ts +6 -5
- package/dist/types/status-line/context-usage.d.ts +4 -1
- package/dist/types/status-line/host.d.ts +6 -4
- package/dist/types/status-line/startup.d.ts +6 -6
- package/dist/types/status-line/types.d.ts +3 -0
- package/dist/types/stdin-buffer.d.ts +7 -0
- package/dist/types/terminal-capabilities.d.ts +16 -1
- package/dist/types/terminal-multiplexer.d.ts +7 -0
- package/dist/types/terminal.d.ts +8 -4
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/theme/theme.d.ts +11 -0
- package/dist/types/thinking.d.ts +5 -0
- package/dist/types/tmux.d.ts +39 -4
- package/dist/types/tools/ast-edit.d.ts +2 -2
- package/dist/types/tools/ast-grep.d.ts +2 -2
- package/dist/types/tools/bash.d.ts +6 -1
- package/dist/types/tools/edit.d.ts +2 -0
- package/dist/types/tools/eval.d.ts +53 -4
- package/dist/types/tools/find.d.ts +14 -4
- package/dist/types/tools/glob.d.ts +3 -3
- package/dist/types/tools/grep.d.ts +2 -2
- package/dist/types/tools/grouped-file-output.d.ts +9 -4
- package/dist/types/tools/native-view.d.ts +1 -1
- package/dist/types/tools/read.d.ts +32 -10
- package/dist/types/tools/renderer.d.ts +2 -0
- package/dist/types/tools/task.d.ts +31 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/tui.d.ts +20 -3
- package/package.json +10 -10
- package/src/app-keybindings.d.ts +18 -8
- package/src/app-keybindings.ts +20 -8
- package/src/apps/autoresearch-dashboard.ts +444 -27
- package/src/apps/debug/raw-sse.ts +2 -2
- package/src/apps/git/diff-pane.ts +39 -41
- package/src/apps/git/git-tui.ts +80 -14
- package/src/apps/git/sidebar.ts +10 -1
- package/src/apps/git/state.ts +12 -0
- package/src/autocomplete.d.ts +2 -0
- package/src/autocomplete.ts +21 -19
- package/src/bracketed-paste.ts +9 -2
- package/src/charts/chart-svg.d.ts +7 -13
- package/src/charts/chart-svg.ts +19 -22
- package/src/chat/advisor-message.d.ts +4 -1
- package/src/chat/advisor-message.ts +77 -24
- package/src/chat/assistant-message.d.ts +9 -4
- package/src/chat/assistant-message.ts +127 -31
- package/src/chat/bash-execution.ts +7 -2
- package/src/chat/figure-markdown.d.ts +7 -2
- package/src/chat/figure-markdown.ts +10 -3
- package/src/chat/image-loading.ts +9 -6
- package/src/chat/messages.d.ts +3 -1
- package/src/chat/messages.ts +4 -1
- package/src/chat/read-tool-group.d.ts +2 -2
- package/src/chat/read-tool-group.ts +97 -42
- package/src/chat/svg-figure.ts +3 -1
- package/src/chat/svg-source.ts +6 -3
- package/src/chat/table-chart.d.ts +11 -15
- package/src/chat/table-chart.ts +24 -57
- package/src/chat/tool-execution.d.ts +32 -20
- package/src/chat/tool-execution.ts +103 -36
- package/src/chat/user-message.d.ts +3 -2
- package/src/chat/user-message.ts +54 -34
- package/src/chrome/countdown-timer.ts +3 -3
- package/src/chrome/diff.d.ts +2 -3
- package/src/chrome/diff.ts +4 -9
- package/src/chrome/transcript-container.d.ts +2 -0
- package/src/chrome/transcript-container.ts +212 -28
- package/src/components/editor.ts +12 -12
- package/src/components/image.d.ts +2 -0
- package/src/components/image.ts +54 -20
- package/src/components/markdown.d.ts +49 -3
- package/src/components/markdown.ts +461 -11
- package/src/components/settings-list.ts +2 -4
- package/src/components/text.d.ts +2 -0
- package/src/components/text.ts +11 -1
- package/src/debug-server.d.ts +2 -0
- package/src/debug-server.ts +51 -69
- package/src/hotkeys-markdown.ts +2 -0
- package/src/keybinding-matchers.d.ts +6 -0
- package/src/keybinding-matchers.ts +19 -0
- package/src/multiplexers/tmux.d.ts +1 -0
- package/src/multiplexers/tmux.ts +3 -0
- package/src/multiplexers/types.d.ts +6 -0
- package/src/multiplexers/types.ts +6 -0
- package/src/native/backend.d.ts +5 -4
- package/src/native/backend.ts +13 -5
- package/src/native/blobs.d.ts +24 -12
- package/src/native/blobs.ts +56 -7
- package/src/native/reconcile.ts +43 -21
- package/src/overlays/advisor-config.ts +259 -8
- package/src/overlays/agent-hub-projection.d.ts +10 -5
- package/src/overlays/agent-hub-projection.ts +63 -12
- package/src/overlays/agent-hub.ts +2 -2
- package/src/overlays/annotation-overlay.ts +422 -168
- package/src/overlays/approval-dialog.ts +389 -0
- package/src/overlays/ask-dialog.ts +244 -105
- package/src/overlays/btw-history-panel.ts +208 -147
- package/src/overlays/extensions/extension-list.ts +4 -6
- package/src/overlays/hook-editor.ts +90 -32
- package/src/overlays/hook-input.ts +105 -19
- package/src/overlays/job-tree.ts +177 -0
- package/src/overlays/jobs-panel.ts +222 -142
- package/src/overlays/login-dialog.ts +201 -91
- package/src/overlays/mcp-add-wizard.ts +562 -279
- package/src/overlays/model-browser.ts +32 -7
- package/src/overlays/model-hub.ts +4 -10
- package/src/overlays/model-picker.ts +58 -21
- package/src/overlays/pause-screen.ts +81 -23
- package/src/overlays/plan-review-overlay.ts +16 -0
- package/src/overlays/plugin-selector.ts +17 -1
- package/src/overlays/plugin-settings.ts +8 -2
- package/src/overlays/queue-mode-selector.ts +10 -0
- package/src/overlays/report-panel.ts +6 -2
- package/src/overlays/reset-usage-selector.ts +130 -21
- package/src/overlays/session-account-selector.ts +36 -0
- package/src/overlays/session-info-overlay.ts +199 -76
- package/src/overlays/session-selector.ts +15 -10
- package/src/overlays/settings-selector.ts +3 -0
- package/src/overlays/show-images-selector.ts +1 -0
- package/src/overlays/snapcompact-shape-preview.ts +13 -6
- package/src/overlays/theme-selector.ts +6 -1
- package/src/overlays/thinking-selector.ts +8 -4
- package/src/overlays/tree-selector.ts +117 -2
- package/src/overlays/usage-dashboard.ts +498 -144
- package/src/prompt/composer-cache.ts +4 -6
- package/src/prompt/composer.ts +196 -96
- package/src/prompt/custom-editor.ts +168 -65
- package/src/prompt/image-references.d.ts +22 -0
- package/src/prompt/image-references.ts +79 -2
- package/src/prompt/internal-url-autocomplete.ts +1 -0
- package/src/prompt/macos-spelling.ts +28 -5
- package/src/prompt/prose-gate.ts +3 -0
- package/src/prompt/queued-messages.ts +9 -2
- package/src/prompt/tips.txt +1 -2
- package/src/prompt/typing-word.ts +59 -0
- package/src/prompt/welcome.ts +256 -423
- package/src/render/code-cell.ts +18 -5
- package/src/render/hanging-wrap.d.ts +7 -0
- package/src/render/hanging-wrap.ts +58 -0
- package/src/render/hyperlink.d.ts +2 -0
- package/src/render/hyperlink.ts +5 -0
- package/src/render/render-utils.d.ts +9 -1
- package/src/render/render-utils.ts +16 -2
- package/src/render/utils.d.ts +1 -1
- package/src/render/utils.ts +1 -1
- package/src/render/width-aware-text.ts +6 -5
- package/src/setup/scenes/splash.ts +137 -60
- package/src/status-line/component.ts +23 -10
- package/src/status-line/context-usage.ts +176 -73
- package/src/status-line/footer.ts +18 -35
- package/src/status-line/host.ts +6 -1
- package/src/status-line/segments.ts +16 -5
- package/src/status-line/startup.ts +6 -6
- package/src/status-line/types.ts +1 -0
- package/src/stdin-buffer.d.ts +7 -0
- package/src/stdin-buffer.ts +11 -1
- package/src/terminal-capabilities.d.ts +16 -1
- package/src/terminal-capabilities.ts +66 -28
- package/src/terminal-multiplexer.d.ts +7 -0
- package/src/terminal-multiplexer.ts +14 -0
- package/src/terminal.d.ts +8 -4
- package/src/terminal.ts +160 -46
- package/src/theme/symbols.d.ts +1 -1
- package/src/theme/symbols.ts +1 -0
- package/src/theme/theme-class.ts +1 -0
- package/src/theme/theme.d.ts +11 -0
- package/src/theme/theme.ts +46 -14
- package/src/thinking.d.ts +5 -0
- package/src/thinking.ts +9 -1
- package/src/tmux.d.ts +39 -4
- package/src/tmux.ts +110 -16
- package/src/tools/ask.ts +20 -9
- package/src/tools/ast-edit.d.ts +2 -2
- package/src/tools/ast-edit.ts +30 -7
- package/src/tools/ast-grep.d.ts +2 -2
- package/src/tools/ast-grep.ts +39 -35
- package/src/tools/bash.d.ts +6 -1
- package/src/tools/bash.ts +15 -9
- package/src/tools/debug.ts +2 -1
- package/src/tools/default-renderer.ts +3 -2
- package/src/tools/edit.d.ts +2 -0
- package/src/tools/edit.ts +100 -21
- package/src/tools/eval.d.ts +53 -4
- package/src/tools/eval.ts +173 -137
- package/src/tools/find.d.ts +14 -4
- package/src/tools/find.ts +71 -18
- package/src/tools/glob.d.ts +3 -3
- package/src/tools/glob.ts +51 -17
- package/src/tools/grep.d.ts +2 -2
- package/src/tools/grep.ts +99 -22
- package/src/tools/grouped-file-output.d.ts +9 -4
- package/src/tools/grouped-file-output.ts +21 -6
- package/src/tools/lsp.ts +30 -18
- package/src/tools/mcp.ts +2 -2
- package/src/tools/native-view.d.ts +1 -1
- package/src/tools/native-view.ts +2 -2
- package/src/tools/read.d.ts +32 -10
- package/src/tools/read.ts +89 -43
- package/src/tools/renderer.d.ts +2 -0
- package/src/tools/renderer.ts +2 -0
- package/src/tools/task.d.ts +31 -0
- package/src/tools/task.ts +40 -7
- package/src/tools/todo.ts +3 -2
- package/src/tools/wait.ts +16 -30
- package/src/tools/web-search.ts +2 -1
- package/src/tools/write.d.ts +2 -0
- package/src/tools/write.ts +36 -12
- package/src/tools/yield.ts +2 -2
- package/src/tui.d.ts +20 -3
- package/src/tui.ts +140 -36
- package/src/utils.ts +5 -3
- package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
- package/src/tools/eval-format/code-mode-declarations.ts +0 -61
package/src/prompt/welcome.ts
CHANGED
|
@@ -2,57 +2,95 @@ import { APP_NAME } from "@linxiraos/pi-utils/dirs";
|
|
|
2
2
|
import type { TspSpan } from "@linxiraos/pi-wire";
|
|
3
3
|
import { formatDoubleTap, formatKeyHint, formatKeyHints, type KeyName } from "../app-keybindings";
|
|
4
4
|
import { editorKey } from "../chrome/keybinding-hints";
|
|
5
|
+
import { tuiText } from "../i18n";
|
|
5
6
|
import { getKeybindings, type Keybinding } from "../keybindings";
|
|
6
|
-
import { card, col,
|
|
7
|
-
import type { DescribeContext, NativeChild, NativeNode
|
|
8
|
-
import {
|
|
9
|
-
import { compactText, plainLine, plainText } from "../native/spans";
|
|
10
|
-
|
|
7
|
+
import { card, col, keyed, node, row, span, text } from "../native/describe";
|
|
8
|
+
import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
|
|
9
|
+
import { compactText, plainText } from "../native/spans";
|
|
11
10
|
import { isNativeRendering } from "../native/state";
|
|
12
11
|
import { isHyperlinkEnabled, urlHyperlink, urlLinkSpan } from "../render/hyperlink";
|
|
13
12
|
import { TERMINAL } from "../terminal-capabilities";
|
|
14
13
|
import { theme } from "../theme/theme";
|
|
15
14
|
import type { Component } from "../tui";
|
|
16
|
-
import { padding, replaceTabs,
|
|
15
|
+
import { padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
17
16
|
import tipsText from "./tips.txt" with { type: "text" };
|
|
18
17
|
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
|
|
18
|
+
/** Trailing marker that flags a tip as a "what's new" callout. Stripped before
|
|
19
|
+
* wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT}
|
|
20
|
+
* painted as a shimmering rainbow. Non-global so `.test` stays stateless. */
|
|
21
|
+
const NEW_TIP_MARKER = /\s*\[NEW\]\s*$/;
|
|
22
|
+
|
|
23
|
+
/** A tips.txt line. The `[NEW]` marker stays in the source line — it drives
|
|
24
|
+
* pool selection and the weighting — while the user-visible text is
|
|
25
|
+
* resolved from the host catalogue under `tipN`, N = the 1-based line number. */
|
|
26
|
+
interface TipLine {
|
|
27
|
+
/** 1-based tips.txt line number; the catalogue key is `tip${line}`. */
|
|
28
|
+
readonly line: number;
|
|
29
|
+
/** Raw line, marker intact — what the weighted picker selects from. */
|
|
30
|
+
readonly raw: string;
|
|
31
|
+
/** English body with the `[NEW]` marker stripped: the `tuiText` fallback. */
|
|
32
|
+
readonly english: string;
|
|
33
|
+
/** "What's new" callout; carries the heavier selection weight. */
|
|
34
|
+
readonly isNew: boolean;
|
|
35
|
+
}
|
|
22
36
|
|
|
23
37
|
/** tips.txt lines embedded at build time; blanks dropped. Key placeholders
|
|
24
|
-
* (see {@link expandTipKeys}) stay raw until render time. */
|
|
25
|
-
const TIP_LINES: readonly
|
|
26
|
-
.
|
|
27
|
-
.
|
|
28
|
-
|
|
38
|
+
* (see {@link expandTipKeys}) and markdown links stay raw until render time. */
|
|
39
|
+
const TIP_LINES: readonly TipLine[] = tipsText.split("\n").flatMap((line, index) => {
|
|
40
|
+
const raw = line.trim();
|
|
41
|
+
if (raw.length === 0) return [];
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
line: index + 1,
|
|
45
|
+
raw,
|
|
46
|
+
english: raw.replace(NEW_TIP_MARKER, ""),
|
|
47
|
+
isNew: NEW_TIP_MARKER.test(raw),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Every tip's raw line, so {@link pickWeightedTip} can still read the
|
|
53
|
+
* trailing {@link NEW_TIP_MARKER} for its selection weight. */
|
|
54
|
+
const TIPS: readonly string[] = TIP_LINES.map(tip => tip.raw);
|
|
55
|
+
|
|
56
|
+
/** Catalogue keys (`tip${line}`) of every tips.txt line, in file order. The
|
|
57
|
+
* host needs them to gate a tip by the product feature it advertises. */
|
|
58
|
+
export const TIP_CATALOGUE_KEYS: readonly string[] = TIP_LINES.map(tip => `tip${tip.line}`);
|
|
29
59
|
|
|
30
60
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
61
|
+
* Host-installable tip gate: receives the tip's catalogue key and its raw
|
|
62
|
+
* tips.txt line; returning false removes it from the splash pool.
|
|
33
63
|
*/
|
|
34
|
-
export
|
|
64
|
+
export type TuiTipFilter = (key: string, raw: string) => boolean;
|
|
65
|
+
|
|
66
|
+
let tipFilter: TuiTipFilter | undefined;
|
|
35
67
|
|
|
36
68
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
69
|
+
* Install the host's tip gate (pass `undefined` to reset). Consulted for every
|
|
70
|
+
* candidate on every pick, so no roll can surface a rejected tip — used when a
|
|
71
|
+
* feature a tip advertises is switched off by configuration.
|
|
39
72
|
*/
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const TIPS: readonly string[] = TIP_LINES.map(line => line.replace(NO_TERN_TIP_MARKER, ""));
|
|
44
|
-
|
|
45
|
-
/** Tips for a live TSP surface: those marked {@link NO_TERN_TIP_MARKER} dropped. */
|
|
46
|
-
const TSP_TIPS: readonly string[] = TIP_LINES.filter(line => !NO_TERN_TIP_MARKER.test(line));
|
|
73
|
+
export function setTuiTipFilter(filter: TuiTipFilter | undefined): void {
|
|
74
|
+
tipFilter = filter;
|
|
75
|
+
}
|
|
47
76
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
77
|
+
/** The pool {@link WelcomeComponent} picks from, with the host gate applied. */
|
|
78
|
+
function tipPool(): readonly string[] {
|
|
79
|
+
// Captured so the narrowing survives into the filter callback.
|
|
80
|
+
const filter = tipFilter;
|
|
81
|
+
if (!filter) return TIPS;
|
|
82
|
+
return TIPS.filter(tip => {
|
|
83
|
+
const entry = TIP_LINES.find(candidate => candidate.raw === tip);
|
|
84
|
+
return entry === undefined || filter(`tip${entry.line}`, tip);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
52
87
|
|
|
53
88
|
/** Visible text rendered in place of {@link NEW_TIP_MARKER}. */
|
|
54
89
|
const NEW_TAG_TEXT = "NEW!";
|
|
55
90
|
|
|
91
|
+
/** Inline English for the nerdfont nudge tip (`welcomeNerdFontJoke`). */
|
|
92
|
+
const NERDFONT_TIP = "Please use nerdfont 😭.";
|
|
93
|
+
|
|
56
94
|
/** Milliseconds for one full hue rotation of the rainbow "NEW!" tag. */
|
|
57
95
|
const NEW_GLOW_PERIOD_MS = 1500;
|
|
58
96
|
|
|
@@ -75,16 +113,33 @@ export function pickWeightedTip(tips: readonly string[], r: number): string {
|
|
|
75
113
|
return tips[tips.length - 1] ?? "";
|
|
76
114
|
}
|
|
77
115
|
|
|
116
|
+
/** A tip resolved through the host catalogue: the localised body plus the
|
|
117
|
+
* structural "[NEW]" flag, which lives in tips.txt rather than the translated
|
|
118
|
+
* text (so a translation never has to carry the marker). */
|
|
119
|
+
interface ResolvedTip {
|
|
120
|
+
readonly text: string;
|
|
121
|
+
readonly isNew: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Localise a picked tip. `raw` is a {@link TIPS} member (or the
|
|
125
|
+
* nerdfont nudge); the catalogue is consulted live, so `/language` applies. */
|
|
126
|
+
function resolveTip(raw: string): ResolvedTip {
|
|
127
|
+
if (raw === NERDFONT_TIP) return { text: tuiText("welcomeNerdFontJoke", NERDFONT_TIP), isNew: false };
|
|
128
|
+
const entry = TIP_LINES.find(tip => tip.raw === raw);
|
|
129
|
+
if (entry === undefined) return { text: raw, isNew: NEW_TIP_MARKER.test(raw) };
|
|
130
|
+
return { text: tuiText(`tip${entry.line}`, entry.english), isNew: entry.isNew };
|
|
131
|
+
}
|
|
132
|
+
|
|
78
133
|
type ColorEncoding = "ansi-16m" | "ansi-256";
|
|
79
134
|
|
|
80
|
-
/** Paint each glyph of
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
function renderNewTag(phase: number, encoding: ColorEncoding): string {
|
|
135
|
+
/** Paint each glyph of `tagText` on a moving HSL rainbow. `phase` rotates the
|
|
136
|
+
* hue offset cyclically; successive renders with increasing phase shimmer,
|
|
137
|
+
* while a fixed phase yields a still rainbow. */
|
|
138
|
+
function renderNewTag(tagText: string, phase: number, encoding: ColorEncoding): string {
|
|
84
139
|
const bold = "\x1b[1m";
|
|
85
140
|
const reset = "\x1b[0m";
|
|
86
141
|
const wrapped = ((phase % 1) + 1) % 1;
|
|
87
|
-
const chars = [...
|
|
142
|
+
const chars = [...tagText];
|
|
88
143
|
let out = bold;
|
|
89
144
|
let prev = "";
|
|
90
145
|
for (let i = 0; i < chars.length; i++) {
|
|
@@ -132,7 +187,7 @@ function expandTipKeys(tip: string): string {
|
|
|
132
187
|
});
|
|
133
188
|
}
|
|
134
189
|
|
|
135
|
-
/** Markdown-style links in tips.txt: `[
|
|
190
|
+
/** Markdown-style links in tips.txt: `[docs](https://example.com)` shows the label, linked to the URL. */
|
|
136
191
|
const TIP_LINK = /\[([^\]]+)\]\(([^\s)]+)\)/g;
|
|
137
192
|
|
|
138
193
|
/** Tip links as underlined OSC 8 labels, or `label (url)` where the terminal cannot link. */
|
|
@@ -158,101 +213,87 @@ function tipSpans(tip: string): TspSpan[] {
|
|
|
158
213
|
}
|
|
159
214
|
|
|
160
215
|
/**
|
|
161
|
-
* The welcome tip as lines of at most `width` columns:
|
|
162
|
-
* wrapped together, with no indent, so the banner can center
|
|
163
|
-
*
|
|
216
|
+
* The welcome tip as lines of at most `width` columns: the `welcomeTipLabel`
|
|
217
|
+
* label and the body wrapped together, with no indent, so the banner can center
|
|
218
|
+
* each line. `isNew` drives the rainbow "welcomeNewTag" suffix; it is passed in
|
|
219
|
+
* because the marker lives in tips.txt, not in the resolved body. `[]` when
|
|
220
|
+
* `width` leaves no room for a useful line.
|
|
164
221
|
*/
|
|
165
|
-
export function renderWelcomeTip(tip: string, width: number, phase = 0): string[] {
|
|
166
|
-
const label = "Tip: ";
|
|
167
|
-
|
|
168
|
-
const bodyBudget = width - labelWidth;
|
|
169
|
-
if (bodyBudget < 8) return [];
|
|
222
|
+
export function renderWelcomeTip(tip: string, width: number, phase = 0, isNew = NEW_TIP_MARKER.test(tip)): string[] {
|
|
223
|
+
const label = tuiText("welcomeTipLabel", "Tip: ");
|
|
224
|
+
if (width - visibleWidth(label) < 8) return [];
|
|
170
225
|
|
|
171
|
-
const isNew = NEW_TIP_MARKER.test(tip);
|
|
172
226
|
const body = linkTipText(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip));
|
|
173
227
|
|
|
174
|
-
|
|
175
|
-
|
|
228
|
+
// Trailing spaces left by the wrap would count toward the width the banner centers.
|
|
229
|
+
const wrapped = wrapTextWithAnsi(replaceTabs(`${label}${body}`), width).map(line => line.trimEnd());
|
|
230
|
+
if (wrapped.length === 0) return [];
|
|
176
231
|
|
|
177
232
|
// Pull both colors from the active theme so the line stays readable on light
|
|
178
233
|
// themes; the previous hardcoded `#b48cff` / `#9ccfff` pastels (plus a manual
|
|
179
234
|
// `\x1b[2m` dim on the body) dropped to ~1.5:1 contrast on a white background.
|
|
180
|
-
const continuationIndent = padding(labelWidth);
|
|
181
235
|
const styledLabel = theme.fg("customMessageLabel", label);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
236
|
+
const lines = wrapped.map((line, index) =>
|
|
237
|
+
theme.italic(
|
|
238
|
+
index === 0 && line.startsWith(label)
|
|
239
|
+
? `${styledLabel}${theme.fg("muted", line.slice(label.length))}`
|
|
240
|
+
: theme.fg("muted", line),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
188
243
|
|
|
189
244
|
if (isNew) {
|
|
190
|
-
// Append the rainbow tag to the final
|
|
191
|
-
//
|
|
192
|
-
// styled glyphs never overflow or reflow the wrapped body.
|
|
245
|
+
// Append the rainbow tag to the final line when it fits; otherwise give it
|
|
246
|
+
// a line of its own so the styled glyphs never overflow the width.
|
|
193
247
|
const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
|
|
194
|
-
const
|
|
195
|
-
const
|
|
248
|
+
const tagText = tuiText("welcomeNewTag", NEW_TAG_TEXT);
|
|
249
|
+
const tag = renderNewTag(tagText, phase, encoding);
|
|
250
|
+
const tagWidth = 1 + visibleWidth(tagText); // 1 = space separator
|
|
196
251
|
const lastLine = lines[lines.length - 1];
|
|
197
252
|
if (lastLine !== undefined && visibleWidth(lastLine) + tagWidth <= width) {
|
|
198
253
|
lines[lines.length - 1] = `${lastLine} ${tag}`;
|
|
199
254
|
} else {
|
|
200
|
-
lines.push(
|
|
255
|
+
lines.push(tag);
|
|
201
256
|
}
|
|
202
257
|
}
|
|
203
258
|
|
|
204
259
|
return lines;
|
|
205
260
|
}
|
|
206
261
|
|
|
207
|
-
export interface RecentSession {
|
|
208
|
-
name: string;
|
|
209
|
-
timeAgo: string;
|
|
210
|
-
/** Session file; a native click on the row resumes it. */
|
|
211
|
-
path?: string;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export interface LspServerInfo {
|
|
215
|
-
name: string;
|
|
216
|
-
status: "ready" | "error" | "connecting" | "available";
|
|
217
|
-
fileTypes: string[];
|
|
218
|
-
}
|
|
219
|
-
|
|
220
262
|
/**
|
|
221
|
-
*
|
|
263
|
+
* The session's welcome banner. In a terminal: the gradient mark beside the
|
|
264
|
+
* `Zeta` wordmark, with the version under the wordmark and the `Linxira-Zeta`
|
|
265
|
+
* identity subline centered below the pair (the mark alone when the lockup does
|
|
266
|
+
* not fit) and the tip of the session (dropped below {@link TIP_MIN_COLUMNS}
|
|
267
|
+
* columns). Natively: a card with the same mark, wordmark, version and tip
|
|
268
|
+
* ({@link WelcomeComponent.describe}).
|
|
222
269
|
*/
|
|
223
270
|
export class WelcomeComponent implements Component {
|
|
224
271
|
#animStart: number | null = null;
|
|
225
272
|
#animTimer: Timer | null = null;
|
|
226
273
|
#requestRender: (() => void) | null = null;
|
|
227
274
|
// Tip randomness is latched once so the tip is stable across renders, but
|
|
228
|
-
// the nerdfont-nag gate re-reads the live preset
|
|
229
|
-
//
|
|
230
|
-
// preset before settings resolve the real one, and an optimistic Tern start
|
|
231
|
-
// falls back to the classic renderer when `hello` never confirms.
|
|
275
|
+
// the nerdfont-nag gate re-reads the live preset: the startup prepaint can
|
|
276
|
+
// run under the default "unicode" preset before settings resolve the real one.
|
|
232
277
|
#nagRoll: number | undefined;
|
|
233
278
|
#tipRoll: number | undefined;
|
|
234
279
|
// Render cache: the welcome box is the first transcript-area component, so
|
|
235
280
|
// returning a stable array reference keeps the whole frame prefix stable.
|
|
236
|
-
// Bypassed while the intro animation runs (every frame differs).
|
|
281
|
+
// Bypassed while the intro animation runs (every frame differs). The
|
|
282
|
+
// `#cachedLang` sentinel retires the cache when `/language` swaps the
|
|
283
|
+
// catalogue, so a live switch repaints instead of serving stale text.
|
|
237
284
|
#cachedWidth = -1;
|
|
238
285
|
#cachedLines: string[] | undefined;
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
private modelName: string = "",
|
|
244
|
-
private providerName: string = "",
|
|
245
|
-
private recentSessions: RecentSession[] = [],
|
|
246
|
-
/** Detected project servers; `null` means LSP is disabled and hides the section. */
|
|
247
|
-
private lspServers: LspServerInfo[] | null = [],
|
|
248
|
-
) {}
|
|
286
|
+
#cachedLang = "";
|
|
287
|
+
#native: { lang: string; tip: string | undefined; node: NativeNode } | undefined;
|
|
288
|
+
|
|
289
|
+
constructor(private version: string) {}
|
|
249
290
|
get tip(): string | undefined {
|
|
250
291
|
this.#nagRoll ??= Math.random();
|
|
251
292
|
this.#tipRoll ??= Math.random();
|
|
252
293
|
if (theme.getSymbolPreset() === "unicode" && this.#nagRoll < 0.1) {
|
|
253
|
-
return
|
|
294
|
+
return NERDFONT_TIP;
|
|
254
295
|
}
|
|
255
|
-
return pickWeightedTip(
|
|
296
|
+
return pickWeightedTip(tipPool(), this.#tipRoll) || undefined;
|
|
256
297
|
}
|
|
257
298
|
|
|
258
299
|
invalidate(): void {
|
|
@@ -262,25 +303,24 @@ export class WelcomeComponent implements Component {
|
|
|
262
303
|
}
|
|
263
304
|
|
|
264
305
|
/**
|
|
265
|
-
* A `card` (`zeta.welcome`)
|
|
266
|
-
* (`zeta.welcome.
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* carry the look (gradient logo, type scale
|
|
306
|
+
* A `card` (`zeta.welcome`) mirroring the terminal banner: the lockup
|
|
307
|
+
* (`zeta.welcome.lockup`: the terminal's builtin `zeta` mark, which it animates,
|
|
308
|
+
* beside the `Zeta` wordmark with the version and the `Linxira-Zeta`
|
|
309
|
+
* subline under it) and the tip of the session.
|
|
310
|
+
* Roles carry the look (gradient logo, type scale); a "[NEW]" tip
|
|
270
311
|
* carries a terminal-clocked shimmering tag.
|
|
271
312
|
*/
|
|
272
313
|
describe(_cx: DescribeContext): NativeNode {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
//
|
|
314
|
+
const lang = tuiText("welcomeBack", "Welcome back!");
|
|
315
|
+
const rawTip = this.tip;
|
|
316
|
+
const tip = rawTip === undefined ? undefined : resolveTip(rawTip);
|
|
317
|
+
if (this.#native && this.#native.lang === lang && this.#native.tip === tip?.text) return this.#native.node;
|
|
318
|
+
// Brand lines are short and fixed; never wrap or truncate them.
|
|
278
319
|
const art = (spans: readonly TspSpan[], role: string): NativeNode =>
|
|
279
320
|
keyed(text(spans, { wrap: "none", role }), role);
|
|
280
|
-
const
|
|
281
|
-
|
|
321
|
+
const lockupRow = keyed(
|
|
322
|
+
row(
|
|
282
323
|
[
|
|
283
|
-
art([span("Welcome back!", "strong")], "zeta.welcome.greeting"),
|
|
284
324
|
node(
|
|
285
325
|
"image",
|
|
286
326
|
{
|
|
@@ -292,115 +332,39 @@ export class WelcomeComponent implements Component {
|
|
|
292
332
|
undefined,
|
|
293
333
|
"logo",
|
|
294
334
|
),
|
|
295
|
-
],
|
|
296
|
-
{ align: "center", role: "zeta.welcome.brand" },
|
|
297
|
-
),
|
|
298
|
-
"brand",
|
|
299
|
-
);
|
|
300
|
-
const section = (key: string, label: string, rows: readonly NativeChild[]): NativeNode =>
|
|
301
|
-
keyed(
|
|
302
|
-
col([line([span(label, "dim")], "zeta.welcome.heading"), ...rows], {
|
|
303
|
-
gap: "xs",
|
|
304
|
-
role: `zeta.welcome.${key}`,
|
|
305
|
-
}),
|
|
306
|
-
key,
|
|
307
|
-
);
|
|
308
|
-
const shortcut = (key: string, label: string): NativeNode =>
|
|
309
|
-
keyed(row([kbd(key), line([span(label, "muted")])], { gap: "sm" }), label);
|
|
310
|
-
const info: NativeChild[] = [
|
|
311
|
-
line([span(this.version, "dim mono")], "zeta.welcome.version"),
|
|
312
|
-
section("tips", "Tips", [
|
|
313
|
-
shortcut("#", "prompt actions"),
|
|
314
|
-
shortcut("/", "commands"),
|
|
315
|
-
shortcut("!", "run bash"),
|
|
316
|
-
shortcut("$", "run python"),
|
|
317
|
-
]),
|
|
318
|
-
];
|
|
319
|
-
if (this.lspServers !== null) {
|
|
320
|
-
const lsp: NativeChild[] = [];
|
|
321
|
-
if (this.lspServers.length === 0) lsp.push(line([span("No LSP servers", "dim")], undefined, "none"));
|
|
322
|
-
for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
|
|
323
|
-
const [symbol, token] =
|
|
324
|
-
server.status === "ready"
|
|
325
|
-
? (["status.enabled", "success"] as const)
|
|
326
|
-
: server.status === "available"
|
|
327
|
-
? (["status.enabled", "dim"] as const)
|
|
328
|
-
: server.status === "connecting"
|
|
329
|
-
? (["status.pending", "muted"] as const)
|
|
330
|
-
: (["status.error", "error"] as const);
|
|
331
|
-
lsp.push(
|
|
332
335
|
keyed(
|
|
333
|
-
|
|
336
|
+
col(
|
|
334
337
|
[
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
.slice(0, 3)
|
|
339
|
-
.map(type => node("badge", { text: type, role: "zeta.welcome.lsp-type" }, undefined, type)),
|
|
338
|
+
art([span("Zeta", "strong")], "zeta.welcome.wordmark"),
|
|
339
|
+
art([span(`v${this.version}`, "dim mono")], "zeta.welcome.version"),
|
|
340
|
+
art([span("Linxira-Zeta", "dim")], "zeta.welcome.subline"),
|
|
340
341
|
],
|
|
341
|
-
{
|
|
342
|
+
{ role: "zeta.welcome.mark" },
|
|
342
343
|
),
|
|
343
|
-
|
|
344
|
+
"mark",
|
|
344
345
|
),
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
info.push(section("lsp", "LSP servers", lsp));
|
|
348
|
-
}
|
|
349
|
-
const recents: NativeChild[] = [];
|
|
350
|
-
if (this.recentSessions.length === 0) recents.push(line([span("No recent sessions", "dim")], undefined, "none"));
|
|
351
|
-
for (const [index, session] of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS).entries()) {
|
|
352
|
-
recents.push(
|
|
353
|
-
keyed(
|
|
354
|
-
row(
|
|
355
|
-
[
|
|
356
|
-
line([span(plainLine(session.name))], "zeta.welcome.session"),
|
|
357
|
-
line([span(session.timeAgo, "dim")], "zeta.welcome.age"),
|
|
358
|
-
],
|
|
359
|
-
{
|
|
360
|
-
gap: "md",
|
|
361
|
-
justify: "between",
|
|
362
|
-
role: "zeta.welcome.recent",
|
|
363
|
-
actions: session.path ? { click: "resume" } : undefined,
|
|
364
|
-
title: session.path ? `Resume ${plainLine(session.name)}` : undefined,
|
|
365
|
-
},
|
|
366
|
-
),
|
|
367
|
-
`s${index}`,
|
|
368
|
-
),
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
info.push(section("recents", "Recent sessions", recents));
|
|
372
|
-
const body: NativeChild[] = [
|
|
373
|
-
keyed(
|
|
374
|
-
row([brand, keyed(col(info, { gap: "md", role: "zeta.welcome.info" }), "info")], {
|
|
375
|
-
align: "start",
|
|
376
|
-
wrap: true,
|
|
377
|
-
role: "zeta.welcome.grid",
|
|
378
|
-
}),
|
|
379
|
-
"grid",
|
|
346
|
+
],
|
|
347
|
+
{ align: "center", gap: "md", role: "zeta.welcome.lockup" },
|
|
380
348
|
),
|
|
381
|
-
|
|
349
|
+
"lockup",
|
|
350
|
+
);
|
|
351
|
+
const body: NativeChild[] = [lockupRow];
|
|
382
352
|
if (tip) {
|
|
383
|
-
const
|
|
384
|
-
const tipText = compactText(tipSpans(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip)));
|
|
353
|
+
const tipText = compactText(tipSpans(expandTipKeys(tip.text)));
|
|
385
354
|
const tipRow: NativeChild[] = [
|
|
386
355
|
node("icon", { name: "lightbulb", role: "zeta.welcome.tip-icon" }),
|
|
387
356
|
text(tipText, { wrap: "word", role: "zeta.welcome.tip-text" }),
|
|
388
357
|
];
|
|
389
|
-
if (isNew)
|
|
358
|
+
if (tip.isNew) {
|
|
359
|
+
tipRow.push(node("shimmer", { text: tuiText("welcomeNewTag", NEW_TAG_TEXT), role: "zeta.welcome.new" }));
|
|
360
|
+
}
|
|
390
361
|
body.push(node("row", { gap: "sm", align: "start", role: "zeta.welcome.tip" }, tipRow, "tip"));
|
|
391
362
|
}
|
|
392
|
-
// No head row or chevron: the card is the hero; the version sits
|
|
363
|
+
// No head row or chevron: the card is the hero; the version sits under the wordmark.
|
|
393
364
|
const described = card({ role: "zeta.welcome" }, body);
|
|
394
|
-
this.#native = { tip, node: described };
|
|
365
|
+
this.#native = { lang, tip: tip?.text, node: described };
|
|
395
366
|
return described;
|
|
396
367
|
}
|
|
397
|
-
/** A click on a recent session resumes it. */
|
|
398
|
-
handleNativeEvent(event: NativeUiEvent): void {
|
|
399
|
-
if (event.type !== "action" || event.act !== "resume") return;
|
|
400
|
-
const index = Number(/\/s(\d+)$/.exec(event.key)?.[1]);
|
|
401
|
-
const path = this.recentSessions[index]?.path;
|
|
402
|
-
if (path) runTranscriptAction({ act: "resume", path });
|
|
403
|
-
}
|
|
404
368
|
|
|
405
369
|
/** The intro keeps the welcome block mutable; settling lets it retire to history. */
|
|
406
370
|
isTranscriptBlockFinalized(): boolean {
|
|
@@ -468,25 +432,10 @@ export class WelcomeComponent implements Component {
|
|
|
468
432
|
this.invalidate();
|
|
469
433
|
}
|
|
470
434
|
|
|
471
|
-
setModel(modelName: string, providerName: string): void {
|
|
472
|
-
this.modelName = modelName;
|
|
473
|
-
this.providerName = providerName;
|
|
474
|
-
this.invalidate();
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
setRecentSessions(sessions: RecentSession[]): void {
|
|
478
|
-
this.recentSessions = sessions;
|
|
479
|
-
this.invalidate();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
setLspServers(servers: LspServerInfo[] | null): void {
|
|
483
|
-
this.lspServers = servers;
|
|
484
|
-
this.invalidate();
|
|
485
|
-
}
|
|
486
|
-
|
|
487
435
|
render(termWidth: number): readonly string[] {
|
|
488
436
|
const animating = this.#animStart != null;
|
|
489
|
-
|
|
437
|
+
const lang = tuiText("welcomeBack", "Welcome back!");
|
|
438
|
+
if (!animating && this.#cachedLines && this.#cachedWidth === termWidth && this.#cachedLang === lang) {
|
|
490
439
|
return this.#cachedLines;
|
|
491
440
|
}
|
|
492
441
|
const lines = this.#renderLines(termWidth);
|
|
@@ -496,223 +445,39 @@ export class WelcomeComponent implements Component {
|
|
|
496
445
|
} else {
|
|
497
446
|
this.#cachedLines = lines;
|
|
498
447
|
this.#cachedWidth = termWidth;
|
|
448
|
+
this.#cachedLang = lang;
|
|
499
449
|
}
|
|
500
450
|
return lines;
|
|
501
451
|
}
|
|
502
452
|
|
|
503
453
|
#renderLines(termWidth: number): string[] {
|
|
504
|
-
//
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
// Dynamic model/provider labels are truncated inside the fixed column.
|
|
515
|
-
// Letting them influence the responsive breakpoint changes the box height
|
|
516
|
-
// when authoritative session data replaces the empty prepaint labels.
|
|
517
|
-
const leftMinContentWidth = Math.max(minLeftCol, visibleWidth("Welcome back!"));
|
|
518
|
-
const desiredLeftCol = Math.max(
|
|
519
|
-
Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35))),
|
|
520
|
-
leftMinContentWidth,
|
|
521
|
-
);
|
|
522
|
-
const dualLeftCol =
|
|
523
|
-
dualContentWidth >= minRightCol + 1
|
|
524
|
-
? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
|
|
525
|
-
: Math.max(1, dualContentWidth - 1);
|
|
526
|
-
const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
|
|
527
|
-
const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
|
|
528
|
-
const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
|
|
529
|
-
const rightCol = showRightColumn ? dualRightCol : 0;
|
|
530
|
-
|
|
531
|
-
// Logo: pick a frame from the intro animation if active, else the resting frame.
|
|
532
|
-
const logoColored = this.#currentLogoFrame();
|
|
533
|
-
|
|
534
|
-
// Left column - centered content
|
|
535
|
-
const leftLines = [
|
|
536
|
-
"",
|
|
537
|
-
this.#centerText(theme.bold("Welcome back!"), leftCol),
|
|
538
|
-
"",
|
|
539
|
-
...logoColored.map(l => this.#centerText(l, leftCol)),
|
|
540
|
-
"",
|
|
541
|
-
this.#centerText(theme.fg("muted", this.modelName), leftCol),
|
|
542
|
-
this.#centerText(theme.fg("borderMuted", this.providerName), leftCol),
|
|
543
|
-
];
|
|
544
|
-
|
|
545
|
-
// Right column separator
|
|
546
|
-
const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
|
|
547
|
-
const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
|
|
548
|
-
|
|
549
|
-
// Recent sessions content
|
|
550
|
-
const sessionLines: string[] = [];
|
|
551
|
-
if (this.recentSessions.length === 0) {
|
|
552
|
-
sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
|
|
553
|
-
} else {
|
|
554
|
-
// Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
|
|
555
|
-
// so the relative time is never the part that gets truncated. The name
|
|
556
|
-
// absorbs whatever space is left.
|
|
557
|
-
const bulletPrefix = ` ${theme.md.bullet} `;
|
|
558
|
-
const prefixWidth = visibleWidth(bulletPrefix);
|
|
559
|
-
for (const session of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS)) {
|
|
560
|
-
const timeSuffixRaw = ` (${session.timeAgo})`;
|
|
561
|
-
const timeWidth = visibleWidth(timeSuffixRaw);
|
|
562
|
-
const nameBudget = Math.max(1, rightCol - prefixWidth - timeWidth);
|
|
563
|
-
const nameVis = visibleWidth(session.name);
|
|
564
|
-
const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
|
|
565
|
-
sessionLines.push(
|
|
566
|
-
`${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`,
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
// Pad to the fixed slot count so the box height doesn't depend on session count.
|
|
571
|
-
while (sessionLines.length < WELCOME_SESSION_SLOTS) {
|
|
572
|
-
sessionLines.push("");
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// Right column
|
|
576
|
-
const rightLines = [
|
|
577
|
-
` ${theme.bold(theme.fg("accent", "Tips"))}`,
|
|
578
|
-
` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
|
|
579
|
-
` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
|
|
580
|
-
` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
|
|
581
|
-
` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
|
|
582
|
-
...this.#renderLspSection(separator),
|
|
583
|
-
separator,
|
|
584
|
-
` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
|
|
585
|
-
...sessionLines,
|
|
586
|
-
"",
|
|
587
|
-
];
|
|
588
|
-
|
|
589
|
-
// Border characters (dim)
|
|
590
|
-
const hChar = theme.boxRound.horizontal;
|
|
591
|
-
const h = theme.fg("dim", hChar);
|
|
592
|
-
const v = theme.fg("dim", theme.boxRound.vertical);
|
|
593
|
-
const tl = theme.fg("dim", theme.boxRound.topLeft);
|
|
594
|
-
const tr = theme.fg("dim", theme.boxRound.topRight);
|
|
595
|
-
const bl = theme.fg("dim", theme.boxRound.bottomLeft);
|
|
596
|
-
const br = theme.fg("dim", theme.boxRound.bottomRight);
|
|
597
|
-
|
|
598
|
-
const lines: string[] = [];
|
|
599
|
-
|
|
600
|
-
// Top border with embedded title
|
|
601
|
-
const title = ` ${APP_NAME} v${this.version} `;
|
|
602
|
-
const titlePrefixRaw = hChar.repeat(3);
|
|
603
|
-
const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
|
|
604
|
-
const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
|
|
605
|
-
const titleSpace = boxWidth - 2;
|
|
606
|
-
if (titleVisLen >= titleSpace) {
|
|
607
|
-
lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
|
|
608
|
-
} else {
|
|
609
|
-
const afterTitle = titleSpace - titleVisLen;
|
|
610
|
-
lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Content rows
|
|
614
|
-
const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
|
|
615
|
-
for (let i = 0; i < maxRows; i++) {
|
|
616
|
-
const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
|
|
617
|
-
if (showRightColumn) {
|
|
618
|
-
const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
|
|
619
|
-
lines.push(v + left + v + right + v);
|
|
620
|
-
} else {
|
|
621
|
-
lines.push(v + left + v);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
// Bottom border
|
|
625
|
-
if (showRightColumn) {
|
|
626
|
-
lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br);
|
|
627
|
-
} else {
|
|
628
|
-
lines.push(bl + h.repeat(leftCol) + br);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Randomly picked tip, rendered directly beneath the box.
|
|
632
|
-
// The banner centers every tip line, so the tip drops out entirely on
|
|
633
|
-
// narrow boxes rather than wrapping into unreadable slivers.
|
|
634
|
-
if (termWidth >= 16) lines.push(...this.#renderTip(boxWidth));
|
|
635
|
-
|
|
454
|
+
// Content keeps a column clear on each side; everything centers in the full width.
|
|
455
|
+
const room = termWidth - 2;
|
|
456
|
+
if (room < 4) return [];
|
|
457
|
+
const logo = this.#currentLogoFrame();
|
|
458
|
+
const version = theme.fg("dim", `v${this.version}`);
|
|
459
|
+
const lockupWidth = LOGO_WIDTH + LOCKUP_GAP + Math.max(WORDMARK_WIDTH, visibleWidth(version));
|
|
460
|
+
const art = room >= lockupWidth ? lockup(logo, version) : room >= LOGO_WIDTH ? logo : [];
|
|
461
|
+
const lines = centerBlock(art, termWidth);
|
|
462
|
+
const tip = termWidth >= TIP_MIN_COLUMNS ? this.#renderTip(room) : [];
|
|
463
|
+
if (tip.length > 0) lines.push("", ...tip.flatMap(line => centerBlock([line], termWidth)));
|
|
636
464
|
return lines;
|
|
637
465
|
}
|
|
638
466
|
|
|
639
|
-
/** Right-column LSP rows padded to a fixed height; empty when LSP is disabled. */
|
|
640
|
-
#renderLspSection(separator: string): string[] {
|
|
641
|
-
if (this.lspServers === null) return [];
|
|
642
|
-
const lspLines: string[] = [];
|
|
643
|
-
if (this.lspServers.length === 0) {
|
|
644
|
-
lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
|
|
645
|
-
} else {
|
|
646
|
-
for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
|
|
647
|
-
const icon =
|
|
648
|
-
server.status === "ready"
|
|
649
|
-
? theme.styledSymbol("status.enabled", "success")
|
|
650
|
-
: server.status === "available"
|
|
651
|
-
? theme.styledSymbol("status.enabled", "dim")
|
|
652
|
-
: server.status === "connecting"
|
|
653
|
-
? theme.styledSymbol("status.pending", "muted")
|
|
654
|
-
: theme.styledSymbol("status.error", "error");
|
|
655
|
-
const exts = server.fileTypes.slice(0, 3).join(" ");
|
|
656
|
-
lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
// Pad to the fixed slot count so the box height doesn't depend on server count.
|
|
660
|
-
while (lspLines.length < WELCOME_LSP_SLOTS) {
|
|
661
|
-
lspLines.push("");
|
|
662
|
-
}
|
|
663
|
-
return [separator, ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`, ...lspLines];
|
|
664
|
-
}
|
|
665
|
-
|
|
666
467
|
/**
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* when no tip is available or the box is too narrow to be useful.
|
|
468
|
+
* The tip of the session wrapped to {@link TIP_MEASURE} columns (fewer when
|
|
469
|
+
* `room` is narrower); `[]` when there is none or no room for it.
|
|
670
470
|
*/
|
|
671
|
-
#renderTip(
|
|
672
|
-
const
|
|
673
|
-
if (!
|
|
674
|
-
// A trailing "[NEW]" marker paints an animated rainbow
|
|
675
|
-
//
|
|
676
|
-
//
|
|
471
|
+
#renderTip(room: number): string[] {
|
|
472
|
+
const raw = this.tip;
|
|
473
|
+
if (!raw) return [];
|
|
474
|
+
// A trailing "[NEW]" marker paints an animated rainbow tag. Derive its
|
|
475
|
+
// hue phase from wall-clock time so it shimmers across the welcome intro's
|
|
476
|
+
// re-render frames, then settles into a still rainbow once the banner
|
|
677
477
|
// caches its resting frame. Non-"[NEW]" tips ignore the phase entirely.
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
/** Center text within a given width */
|
|
683
|
-
#centerText(text: string, width: number): string {
|
|
684
|
-
const visLen = visibleWidth(text);
|
|
685
|
-
if (visLen >= width) {
|
|
686
|
-
return truncateToWidth(text, width);
|
|
687
|
-
}
|
|
688
|
-
const leftPad = Math.floor((width - visLen) / 2);
|
|
689
|
-
const rightPad = width - visLen - leftPad;
|
|
690
|
-
return padding(leftPad) + text + padding(rightPad);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/** Fit string to exact width with ANSI-aware truncation/padding */
|
|
694
|
-
#fitToWidth(str: string, width: number): string {
|
|
695
|
-
const visLen = visibleWidth(str);
|
|
696
|
-
if (visLen > width) {
|
|
697
|
-
const ellipsis = "…";
|
|
698
|
-
const ellipsisWidth = visibleWidth(ellipsis);
|
|
699
|
-
const maxWidth = Math.max(0, width - ellipsisWidth);
|
|
700
|
-
let truncated = "";
|
|
701
|
-
let currentWidth = 0;
|
|
702
|
-
let inEscape = false;
|
|
703
|
-
for (const char of str) {
|
|
704
|
-
if (char === "\x1b") inEscape = true;
|
|
705
|
-
if (inEscape) {
|
|
706
|
-
truncated += char;
|
|
707
|
-
if (char === "m") inEscape = false;
|
|
708
|
-
} else if (currentWidth < maxWidth) {
|
|
709
|
-
truncated += char;
|
|
710
|
-
currentWidth++;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
return `${truncated}${ellipsis}`;
|
|
714
|
-
}
|
|
715
|
-
return str + padding(width - visLen);
|
|
478
|
+
const tip = resolveTip(raw);
|
|
479
|
+
const phase = tip.isNew ? performance.now() / NEW_GLOW_PERIOD_MS : 0;
|
|
480
|
+
return renderWelcomeTip(tip.text, Math.min(room, TIP_MEASURE), phase, tip.isNew);
|
|
716
481
|
}
|
|
717
482
|
|
|
718
483
|
/** Pick the logo frame for the current intro phase, or the resting frame. */
|
|
@@ -724,10 +489,78 @@ export class WelcomeComponent implements Component {
|
|
|
724
489
|
}
|
|
725
490
|
}
|
|
726
491
|
|
|
727
|
-
|
|
728
|
-
|
|
492
|
+
// The three arrays below are GENERATED by the standard FIGlet tool, never
|
|
493
|
+
// hand-drawn: `bun scripts/brand/generate-logo-art.ts` rewrites them from the
|
|
494
|
+
// FIGlet fonts vendored under `scripts/brand/fonts/` (mark + wordmark in the
|
|
495
|
+
// standard "ANSI Shadow" font, the `Linxira-Zeta` subline in "mini"), and
|
|
496
|
+
// `bun scripts/brand/brand-check.ts` fails on any hand-edited glyph. Standard
|
|
497
|
+
// FIGlet fonts carry no Greek block, so the mark is the Latin `Z` — the same
|
|
498
|
+
// glyph as `Ζ` (see `scripts/brand/logo-art.ts`).
|
|
499
|
+
|
|
500
|
+
/** Brand mark: FIGlet `ANSI Shadow` render of `Z` (generated). */
|
|
501
|
+
export const ZETA_LOGO = ["███████╗", "╚══███╔╝", " ███╔╝ ", " ███╔╝ ", "███████╗", "╚══════╝"];
|
|
502
|
+
|
|
503
|
+
/** Primary product name: FIGlet `ANSI Shadow` render of `Zeta` (generated). */
|
|
504
|
+
export const ZETA_WORDMARK = [
|
|
505
|
+
"███████╗███████╗████████╗ █████╗ ",
|
|
506
|
+
"╚══███╔╝██╔════╝╚══██╔══╝██╔══██╗",
|
|
507
|
+
" ███╔╝ █████╗ ██║ ███████║",
|
|
508
|
+
" ███╔╝ ██╔══╝ ██║ ██╔══██║",
|
|
509
|
+
"███████╗███████╗ ██║ ██║ ██║",
|
|
510
|
+
"╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝",
|
|
511
|
+
];
|
|
512
|
+
|
|
513
|
+
/** Secondary identity line: FIGlet `mini` render of `Linxira-Zeta` (generated). */
|
|
514
|
+
export const ZETA_SUBLINE = [
|
|
515
|
+
" __ ",
|
|
516
|
+
" | o ._ o ._ _. __ / _ _|_ _. ",
|
|
517
|
+
" |_ | | | >< | | (_| /_ (/_ |_ (_| ",
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
/** Columns of {@link ZETA_LOGO}. */
|
|
521
|
+
const LOGO_WIDTH = Math.max(...ZETA_LOGO.map(row => visibleWidth(row)));
|
|
522
|
+
|
|
523
|
+
/** Columns of {@link ZETA_WORDMARK}. */
|
|
524
|
+
const WORDMARK_WIDTH = Math.max(...ZETA_WORDMARK.map(row => visibleWidth(row)));
|
|
525
|
+
|
|
526
|
+
/** Columns between the logo and the wordmark. */
|
|
527
|
+
const LOCKUP_GAP = 4;
|
|
528
|
+
|
|
529
|
+
/** Widest a welcome tip wraps, so a long one stays a centered paragraph. */
|
|
530
|
+
const TIP_MEASURE = 72;
|
|
531
|
+
|
|
532
|
+
/** Narrowest terminal that still shows the tip; below it the banner is the logo alone. */
|
|
533
|
+
const TIP_MIN_COLUMNS = 50;
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The lockup block: the mark beside the wordmark, `version` (styled) under the
|
|
537
|
+
* wordmark, and the `Linxira-Zeta` subline centered under the pair.
|
|
538
|
+
*/
|
|
539
|
+
function lockup(logo: readonly string[], version: string): string[] {
|
|
540
|
+
const beside = [...ZETA_WORDMARK.map(row => theme.bold(theme.fg("text", row))), version];
|
|
541
|
+
const rows = Math.max(logo.length, beside.length);
|
|
542
|
+
const art = Array.from({ length: rows }, (_, index) => {
|
|
543
|
+
const mark = logo[index] ?? "";
|
|
544
|
+
return `${mark}${padding(LOGO_WIDTH - visibleWidth(mark) + LOCKUP_GAP)}${beside[index] ?? ""}`;
|
|
545
|
+
});
|
|
546
|
+
const blockWidth = Math.max(...art.map(line => visibleWidth(line)));
|
|
547
|
+
for (const row of ZETA_SUBLINE) {
|
|
548
|
+
art.push(padding(Math.max(0, Math.floor((blockWidth - visibleWidth(row)) / 2))) + theme.fg("dim", row));
|
|
549
|
+
}
|
|
550
|
+
return art;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* `lines` indented as one block whose widest line is centered in `width`
|
|
555
|
+
* columns; pass a single line to center it on its own.
|
|
556
|
+
*/
|
|
557
|
+
function centerBlock(lines: readonly string[], width: number): string[] {
|
|
558
|
+
const widest = lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
|
559
|
+
const indent = padding(Math.max(0, Math.floor((width - widest) / 2)));
|
|
560
|
+
return lines.map(line => indent + line);
|
|
561
|
+
}
|
|
729
562
|
|
|
730
|
-
/** The
|
|
563
|
+
/** The generated brand mark as accent lines; `shimmer` declares the terminal-clocked shine sweep. */
|
|
731
564
|
export function logoNode(lines: readonly string[], shimmer: boolean): NativeNode {
|
|
732
565
|
return col(
|
|
733
566
|
lines.map(line =>
|