first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
/* global Page, wx, require */
const { getSettings } = require("../../utils/storage");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const {
getAboutBrand,
getAboutDetailContent,
getAboutFooterLinks,
getAboutUiCopy
} = require("../../utils/aboutContent");
const { normalizeUiLanguage } = require("../../utils/i18n");
const { buildButtonIconThemeMaps } = require("../../utils/themedIcons");
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
// “关于”页分享出去后应回到小程序首页,而不是再次落到 about 详情页。
const ABOUT_APP_SHARE_HOME_PATH = "/pages/connect/index";
/**
* 将“关于”详情页按 Figma Frame 2223 落地:
* 1. 保留关于首页的 5 个入口结构不变;
* 2. 当前页只重排品牌区、信息卡、分享按钮和底部跳转;
* 3. 中间信息继续复用统一数据源,避免文案在多个页面分叉。
*/
function buildInfoRows(section) {
const bullets = Array.isArray(section && section.bullets) ? section.bullets : [];
return bullets.map((line, index) => {
const text = String(line || "").trim();
const matched = text.match(/^([^:]+)[:]\s*(.+)$/);
if (!matched) {
return {
key: `row-${index}`,
label: "",
value: text
};
}
return {
key: `row-${index}`,
label: `${matched[1]}`,
value: matched[2]
};
});
}
Page({
data: {
...buildSvgButtonPressData(),
brand: getAboutBrand("zh-Hans"),
pageContent: getAboutDetailContent("app", "zh-Hans"),
infoRows: [],
versionLine: "",
themeStyle: "",
footerLinks: getAboutFooterLinks("zh-Hans"),
uiCopy: getAboutUiCopy("zh-Hans"),
icons: {},
accentIcons: {}
},
onLoad() {
const brand = getAboutBrand("zh-Hans");
const pageContent = getAboutDetailContent("app", "zh-Hans");
const primarySection = Array.isArray(pageContent.sections) ? pageContent.sections[0] : null;
wx.setNavigationBarTitle({ title: pageContent.title || "关于" });
this.setData({
brand,
pageContent,
infoRows: buildInfoRows(primarySection),
versionLine: `${brand.version}·wechat·${brand.updatedAtCompact}`,
footerLinks: getAboutFooterLinks("zh-Hans"),
uiCopy: getAboutUiCopy("zh-Hans")
});
this.applyThemeStyle();
},
onShow() {
this.applyThemeStyle();
},
applyThemeStyle() {
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const brand = getAboutBrand(language);
const pageContent = getAboutDetailContent("app", language);
const primarySection = Array.isArray(pageContent.sections) ? pageContent.sections[0] : null;
const { icons, accentIcons } = buildButtonIconThemeMaps(settings);
applyNavigationBarTheme(settings);
wx.setNavigationBarTitle({ title: pageContent.title || "关于" });
this.setData({
brand,
pageContent,
infoRows: buildInfoRows(primarySection),
versionLine: `${brand.version}·wechat·${brand.updatedAtCompact}`,
footerLinks: getAboutFooterLinks(language),
uiCopy: getAboutUiCopy(language),
icons,
accentIcons,
themeStyle: buildThemeStyle(settings)
});
},
onOpenLink(event) {
const path = String(event.currentTarget.dataset.path || "").trim();
if (!path) return;
wx.navigateTo({ url: path });
},
onShareAppMessage() {
const brand = this.data.brand || getAboutBrand("zh-Hans");
return {
title: `${brand.productName} ${brand.version}`,
path: ABOUT_APP_SHARE_HOME_PATH
};
},
...createSvgButtonPressMethods()
});

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "关于",
"navigationBarBackgroundColor": "#f4f3ef",
"navigationBarTextStyle": "black",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type SharePayload = {
path: string;
title: string;
};
type AboutAppPageOptions = {
onShareAppMessage?: () => SharePayload;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: AboutAppPageOptions) => void;
wx?: {
setNavigationBarTitle?: (options: { title: string }) => void;
};
};
describe("about-app page", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
let capturedPageOptions: AboutAppPageOptions | null = null;
beforeEach(() => {
capturedPageOptions = null;
vi.resetModules();
globalState.Page = vi.fn((options: AboutAppPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
setNavigationBarTitle: vi.fn()
};
});
afterEach(() => {
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("分享后应落到首页而不是 about 详情页", () => {
require("./index.js");
expect(capturedPageOptions).toBeTruthy();
expect(capturedPageOptions?.onShareAppMessage).toBeTypeOf("function");
expect(capturedPageOptions?.onShareAppMessage?.()).toEqual({
title: "RemoteConn v3.0.0",
path: "/pages/connect/index"
});
});
});

View File

@@ -0,0 +1,61 @@
<view class="about-app-page" style="{{themeStyle}}">
<scroll-view class="about-app-scroll" scroll-y="true">
<view class="about-app-shell">
<view class="about-bg-orb about-bg-orb-left"></view>
<view class="about-bg-orb about-bg-orb-right"></view>
<view class="about-app-brand">
<image class="about-app-logo" src="/assets/icons/logo.svg" mode="aspectFit" />
<image class="about-app-wordmark" src="/assets/icons/remoteconn.svg" mode="widthFix" />
<image class="about-app-submark" src="/assets/icons/ai矩连.svg" mode="widthFix" />
<text class="about-app-version">{{versionLine}}</text>
</view>
<view class="about-app-card">
<view class="about-app-card-inner">
<text class="about-app-card-title">{{pageContent.sections[0].title}}</text>
<text wx:if="{{pageContent.lead}}" class="about-app-card-lead">{{pageContent.lead}}</text>
<view class="about-app-info-list">
<view wx:for="{{infoRows}}" wx:key="key" class="about-app-info-row">
<text wx:if="{{item.label}}" class="about-app-info-label">{{item.label}}</text>
<text class="about-app-info-value">{{item.value}}</text>
</view>
</view>
</view>
</view>
<button
class="about-app-share svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
open-type="share"
data-press-key="about-app:share"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
>
<image
class="about-app-share-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'about-app:share' ? (accentIcons.share || icons.share || '/assets/icons/share.svg') : (icons.share || '/assets/icons/share.svg')}}"
mode="aspectFit"
/>
<text class="about-app-share-text">{{uiCopy.shareButton}}</text>
</button>
<view class="about-app-footer">
<text
wx:for="{{footerLinks}}"
wx:key="key"
class="about-app-footer-link"
data-path="{{item.path}}"
bindtap="onOpenLink"
>
{{item.title}}
</text>
</view>
</view>
</scroll-view>
<bottom-nav page="about-app" />
</view>

View File

@@ -0,0 +1,185 @@
@import "../about/common.wxss";
/**
* 当前页沿用 about 共用色板,只保留“关于”详情页自身的布局差异,
* 避免再维护第二套颜色常量。
*/
.about-app-page {
height: 100vh;
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.about-app-scroll {
flex: 1;
min-height: 0;
}
.about-app-shell {
position: relative;
min-height: 100vh;
padding: 40rpx 28rpx 136rpx;
box-sizing: border-box;
overflow: hidden;
}
.about-app-brand {
position: relative;
z-index: 1;
padding: 0 0 34rpx 16rpx;
}
.about-app-logo {
width: 96rpx;
height: 96rpx;
display: block;
}
.about-app-wordmark {
width: 563rpx;
display: block;
margin-top: -96rpx;
margin-left: 112rpx;
}
.about-app-submark {
width: 88rpx;
display: block;
margin-top: 14rpx;
margin-left: 112rpx;
}
.about-app-version {
display: block;
margin-top: 14rpx;
margin-left: 112rpx;
font-size: 20rpx;
line-height: 1;
font-weight: 700;
color: var(--about-text);
}
.about-app-card {
position: relative;
z-index: 1;
width: 100%;
min-height: 622rpx;
border-radius: 28rpx;
background: var(--about-surface);
border: 1rpx solid var(--about-surface-border);
box-shadow: 0 16rpx 38rpx var(--about-glow);
box-sizing: border-box;
}
.about-app-card-inner {
padding: 30rpx 28rpx 34rpx;
}
.about-app-card-title {
display: block;
font-size: 32rpx;
line-height: 1.2;
font-weight: 700;
color: var(--about-text-strong);
}
.about-app-card-lead {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
line-height: 1.65;
color: var(--about-text);
}
.about-app-info-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 26rpx;
}
.about-app-info-row {
display: flex;
align-items: flex-start;
gap: 8rpx;
}
.about-app-info-label {
flex: 0 0 auto;
font-size: 22rpx;
line-height: 1.6;
font-weight: 700;
color: var(--about-text-strong);
}
.about-app-info-value {
flex: 1;
min-width: 0;
font-size: 22rpx;
line-height: 1.6;
color: var(--about-text);
}
.about-app-share {
width: auto !important;
min-width: 0 !important;
position: relative;
z-index: 1;
align-self: flex-start;
margin: 30rpx 0 0 16rpx !important;
padding: 18rpx 24rpx !important;
border: 1rpx solid var(--about-action-border) !important;
border-radius: 999rpx !important;
background: var(--about-action-bg) !important;
display: inline-flex;
align-items: center;
gap: 12rpx;
box-shadow: 0 10rpx 24rpx var(--about-glow);
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: var(--about-action-bg);
--svg-press-active-shadow:
0 14rpx 28rpx var(--about-glow),
inset 0 0 0 1rpx var(--about-action-border);
--svg-press-active-scale: 0.96;
--svg-press-icon-opacity: 0.94;
--svg-press-icon-active-opacity: 1;
--svg-press-icon-active-scale: 1.08;
}
.about-app-share::after {
border: 0 !important;
}
.about-app-share-icon {
width: 31rpx;
height: 31rpx;
flex: 0 0 auto;
}
.about-app-share-text {
font-size: 24rpx;
line-height: 1;
font-weight: 700;
color: var(--about-action-text);
}
.about-app-footer {
position: relative;
z-index: 1;
margin-top: 34rpx;
display: flex;
justify-content: flex-end;
gap: 24rpx;
padding-right: 10rpx;
}
.about-app-footer-link {
padding: 10rpx 0;
font-size: 20rpx;
line-height: 1;
font-weight: 700;
color: var(--about-accent);
}

View File

@@ -0,0 +1,3 @@
const { createAboutDetailPage } = require("../../utils/aboutPageFactory");
Page(createAboutDetailPage("changelog"));

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "变更记录",
"navigationBarBackgroundColor": "#f4f3ef",
"navigationBarTextStyle": "black",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,28 @@
<view class="about-page" style="{{themeStyle}}">
<scroll-view class="about-scroll" scroll-y="true">
<view class="about-shell">
<view class="about-bg-orb about-bg-orb-left"></view>
<view class="about-bg-orb about-bg-orb-right"></view>
<view class="about-stack">
<view class="detail-chip">{{brand.chineseName}}</view>
<view class="detail-card">
<text class="detail-title">{{pageContent.title}}</text>
<text class="detail-lead">{{pageContent.lead}}</text>
</view>
<view wx:for="{{pageContent.sections}}" wx:key="title" class="detail-card detail-section-list">
<text class="detail-section-title">{{item.title}}</text>
<text wx:for="{{item.paragraphs}}" wx:key="index" class="detail-paragraph">{{item}}</text>
<view wx:if="{{item.bullets && item.bullets.length}}" class="detail-bullet-list">
<view wx:for="{{item.bullets}}" wx:key="index" class="detail-bullet-row">
<text class="detail-bullet-dot">•</text>
<text class="detail-bullet-text">{{item}}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<bottom-nav page="about-changelog" />
</view>

View File

@@ -0,0 +1 @@
@import "../about/common.wxss";

View File

@@ -0,0 +1,3 @@
const { createAboutDetailPage } = require("../../utils/aboutPageFactory");
Page(createAboutDetailPage("feedback"));

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "问题反馈",
"navigationBarBackgroundColor": "#f4f3ef",
"navigationBarTextStyle": "black",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,35 @@
<view class="about-page" style="{{themeStyle}}">
<scroll-view class="about-scroll" scroll-y="true">
<view class="about-shell">
<view class="about-bg-orb about-bg-orb-left"></view>
<view class="about-bg-orb about-bg-orb-right"></view>
<view class="about-stack">
<view class="detail-chip">{{brand.chineseName}}</view>
<view class="detail-card">
<text class="detail-title">{{pageContent.title}}</text>
<text class="detail-lead">{{pageContent.lead}}</text>
</view>
<view wx:for="{{pageContent.sections}}" wx:key="title" class="detail-card detail-section-list">
<view class="detail-section-head">
<text class="detail-section-title">{{item.title}}</text>
<button
wx:if="{{item.actionLabel}}"
class="detail-bubble-action"
bindtap="onCopyFeedbackEmail"
>{{item.actionLabel}}</button>
</view>
<text wx:for="{{item.paragraphs}}" wx:key="index" class="detail-paragraph">{{item}}</text>
<view wx:if="{{item.bullets && item.bullets.length}}" class="detail-bullet-list">
<view wx:for="{{item.bullets}}" wx:key="index" class="detail-bullet-row">
<text class="detail-bullet-dot">•</text>
<text class="detail-bullet-text">{{item}}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<bottom-nav page="about-feedback" />
</view>

View File

@@ -0,0 +1 @@
@import "../about/common.wxss";

View File

@@ -0,0 +1,3 @@
const { createAboutDetailPage } = require("../../utils/aboutPageFactory");
Page(createAboutDetailPage("manual"));

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "使用手册",
"navigationBarBackgroundColor": "#f4f3ef",
"navigationBarTextStyle": "black",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,39 @@
<view class="about-page" style="{{themeStyle}}">
<scroll-view class="about-scroll" scroll-y="true">
<view class="about-shell">
<view class="about-bg-orb about-bg-orb-left"></view>
<view class="about-bg-orb about-bg-orb-right"></view>
<view class="about-stack">
<view class="detail-chip">{{brand.chineseName}}</view>
<view class="detail-card">
<text class="detail-title">{{pageContent.title}}</text>
<text class="detail-lead">{{pageContent.lead}}</text>
</view>
<view wx:for="{{pageContent.sections}}" wx:key="title" class="detail-card detail-section-list">
<text class="detail-section-title">{{item.title}}</text>
<view wx:if="{{item.mediaItems && item.mediaItems.length}}" class="detail-media-list">
<view wx:for="{{item.mediaItems}}" wx:for-item="media" wx:key="src" class="detail-media-card">
<image
class="detail-media-image"
src="{{media.src}}"
mode="widthFix"
lazy-load="true"
show-menu-by-longpress="true"
/>
</view>
</view>
<text wx:for="{{item.paragraphs}}" wx:key="index" class="detail-paragraph">{{item}}</text>
<view wx:if="{{item.bullets && item.bullets.length}}" class="detail-bullet-list">
<view wx:for="{{item.bullets}}" wx:key="index" class="detail-bullet-row">
<text class="detail-bullet-dot">•</text>
<text class="detail-bullet-text">{{item}}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<bottom-nav page="about-manual" />
</view>

View File

@@ -0,0 +1 @@
@import "../about/common.wxss";

View File

@@ -0,0 +1,3 @@
const { createAboutDetailPage } = require("../../utils/aboutPageFactory");
Page(createAboutDetailPage("privacy"));

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "隐私政策",
"navigationBarBackgroundColor": "#f4f3ef",
"navigationBarTextStyle": "black",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,28 @@
<view class="about-page" style="{{themeStyle}}">
<scroll-view class="about-scroll" scroll-y="true">
<view class="about-shell">
<view class="about-bg-orb about-bg-orb-left"></view>
<view class="about-bg-orb about-bg-orb-right"></view>
<view class="about-stack">
<view class="detail-chip">{{brand.chineseName}}</view>
<view class="detail-card">
<text class="detail-title">{{pageContent.title}}</text>
<text class="detail-lead">{{pageContent.lead}}</text>
</view>
<view wx:for="{{pageContent.sections}}" wx:key="title" class="detail-card detail-section-list">
<text class="detail-section-title">{{item.title}}</text>
<text wx:for="{{item.paragraphs}}" wx:key="index" class="detail-paragraph">{{item}}</text>
<view wx:if="{{item.bullets && item.bullets.length}}" class="detail-bullet-list">
<view wx:for="{{item.bullets}}" wx:key="index" class="detail-bullet-row">
<text class="detail-bullet-dot">•</text>
<text class="detail-bullet-text">{{item}}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<bottom-nav page="about-privacy" />
</view>

View File

@@ -0,0 +1 @@
@import "../about/common.wxss";

View File

@@ -0,0 +1,332 @@
/**
* About 页仍保留独立编排,但配色必须走界面配置推导出的 token
* 1. 顶层背景、文字、卡片、强调色全部由 themeStyle 下发;
* 2. 不再覆写 page 级固定米白主题,避免和主流程页脱节;
* 3. 光斑只做氛围层,颜色同样从当前主题推导。
*/
.about-page {
position: relative;
height: 100vh;
background: var(--bg);
color: var(--text);
overflow: hidden;
display: flex;
flex-direction: column;
}
.about-scroll {
flex: 1;
min-height: 0;
}
.about-shell {
position: relative;
min-height: 100vh;
padding: 36rpx 28rpx 56rpx;
overflow: hidden;
}
.about-bg-orb {
position: absolute;
border-radius: 999rpx;
pointer-events: none;
animation: about-orb-float 8.8s ease-in-out infinite;
}
.about-bg-orb-left {
width: 630rpx;
height: 630rpx;
left: -150rpx;
bottom: -240rpx;
background: radial-gradient(
circle at 35% 35%,
var(--about-orb-left-start) 0%,
var(--about-orb-left-end) 72%,
transparent 100%
);
opacity: 0.52;
animation-duration: 9.4s;
}
.about-bg-orb-right {
width: 840rpx;
height: 840rpx;
right: -390rpx;
bottom: -500rpx;
background: radial-gradient(
circle at 35% 35%,
var(--about-orb-right-start) 0%,
var(--about-orb-right-end) 62%,
transparent 100%
);
opacity: 0.46;
animation-duration: 7.6s;
animation-delay: -2.2s;
}
.about-stack {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 22rpx;
}
.about-hero {
display: flex;
flex-direction: column;
gap: 10rpx;
padding: 8rpx 8rpx 24rpx;
}
.about-brand-en {
font-size: 86rpx;
line-height: 0.96;
font-weight: 700;
color: var(--about-text-strong);
}
.about-brand-version {
font-size: 28rpx;
line-height: 1.2;
font-weight: 700;
color: var(--about-text);
}
.about-brand-cn {
font-size: 22rpx;
line-height: 1.4;
color: var(--about-text-muted);
}
.about-intro {
font-size: 24rpx;
line-height: 1.7;
color: var(--about-text);
}
.about-card-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.about-entry {
width: 100% !important;
min-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 1rpx solid var(--about-surface-border) !important;
border-radius: 28rpx !important;
background: var(--about-surface) !important;
box-shadow: 0 16rpx 38rpx var(--about-glow);
overflow: hidden;
}
.about-entry.svg-press-btn {
--svg-press-active-radius: 36rpx;
--svg-press-active-bg: var(--about-surface);
--svg-press-active-shadow: 0 22rpx 42rpx var(--about-glow), inset 0 0 0 1rpx var(--about-surface-border);
--svg-press-active-scale: 0.985;
--svg-press-icon-opacity: 0.92;
--svg-press-icon-active-opacity: 1;
--svg-press-icon-active-scale: 1.08;
}
.about-entry::after,
.about-copy-btn::after {
border: 0 !important;
}
.about-entry-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 28rpx 26rpx;
}
.about-entry-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.about-entry-title {
font-size: 30rpx;
line-height: 1.2;
font-weight: 700;
color: var(--about-text-strong);
}
.about-entry-subtitle {
font-size: 22rpx;
line-height: 1.5;
color: var(--about-text-muted);
}
.about-entry-arrow {
width: 30rpx;
height: 30rpx;
flex: 0 0 auto;
}
.detail-chip {
align-self: flex-start;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: var(--about-accent-soft);
color: var(--about-accent);
font-size: 20rpx;
line-height: 1;
}
.detail-card {
border-radius: 28rpx;
background: var(--about-surface);
border: 1rpx solid var(--about-surface-border);
box-shadow: 0 16rpx 38rpx var(--about-glow);
padding: 28rpx 26rpx;
}
.detail-title {
font-size: 38rpx;
line-height: 1.12;
font-weight: 700;
color: var(--about-text-strong);
}
.detail-lead {
margin-top: 16rpx;
display: block;
font-size: 24rpx;
line-height: 1.7;
color: var(--about-text);
}
.detail-section-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.detail-section-title {
font-size: 28rpx;
line-height: 1.2;
font-weight: 700;
color: var(--about-text-strong);
}
.detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.detail-paragraph {
display: block;
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: var(--about-text);
}
.detail-media-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 6rpx;
}
.detail-media-card {
overflow: hidden;
border-radius: 24rpx;
border: 1rpx solid var(--about-surface-border);
background: var(--about-surface);
box-shadow: 0 12rpx 24rpx var(--about-glow);
}
.detail-media-image {
display: block;
width: 100%;
}
.detail-bullet-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 16rpx;
}
.detail-bullet-row {
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.detail-bullet-dot {
flex: 0 0 auto;
font-size: 24rpx;
line-height: 1.6;
color: var(--about-text-strong);
}
.detail-bullet-text {
flex: 1;
min-width: 0;
font-size: 24rpx;
line-height: 1.65;
color: var(--about-text);
}
.about-copy-btn {
width: auto !important;
min-width: 0 !important;
align-self: flex-start;
margin: 0 !important;
padding: 16rpx 22rpx !important;
border-radius: 999rpx !important;
border: 1rpx solid var(--about-action-border) !important;
background: var(--about-action-bg) !important;
color: var(--about-action-text) !important;
font-size: 22rpx !important;
line-height: 1 !important;
}
.detail-bubble-action {
width: 92rpx !important;
height: 92rpx !important;
min-width: 92rpx !important;
margin: 0 !important;
padding: 0 !important;
border: 1rpx solid var(--about-action-border) !important;
border-radius: 999rpx !important;
background: var(--about-action-bg) !important;
color: var(--about-action-text) !important;
font-size: 22rpx !important;
line-height: 92rpx !important;
text-align: center !important;
flex: 0 0 auto;
}
.detail-bubble-action::after {
border: 0 !important;
}
@keyframes about-orb-float {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(14rpx, -18rpx, 0) scale(1.03);
}
100% {
transform: translate3d(0, 0, 0) scale(1);
}
}

View File

@@ -0,0 +1,60 @@
/* global Page, wx, require */
const { getSettings } = require("../../utils/storage");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const { getAboutBrand, getAboutDetailContent, getAboutHomeItems } = require("../../utils/aboutContent");
const { normalizeUiLanguage } = require("../../utils/i18n");
const { buildButtonIconThemeMaps } = require("../../utils/themedIcons");
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
Page({
data: {
...buildSvgButtonPressData(),
brand: getAboutBrand("zh-Hans"),
items: [],
icons: {},
accentIcons: {},
themeStyle: "",
// 首页头部只保留纯版本号,不叠加平台和时间戳。
homeVersionLine: getAboutBrand("zh-Hans").version
},
onLoad() {
this.applyThemeStyle();
},
onShow() {
this.applyThemeStyle();
},
applyThemeStyle() {
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const brand = getAboutBrand(language);
const { icons, accentIcons } = buildButtonIconThemeMaps(settings);
const items = getAboutHomeItems(language).map((item) => ({
...item,
// about 首页入口目前固定 5 项,用业务 key 生成 press key后续增删项也无需改模板判断。
pressKey: `about:${item.key || item.path || item.title || "entry"}`
}));
const homeTitle = getAboutDetailContent("app", language).title || "About";
applyNavigationBarTheme(settings);
wx.setNavigationBarTitle({ title: homeTitle });
this.setData({
brand,
items,
icons,
accentIcons,
themeStyle: buildThemeStyle(settings),
homeVersionLine: brand.version
});
},
onOpenItem(event) {
const path = String(event.currentTarget.dataset.path || "").trim();
if (!path) return;
wx.navigateTo({ url: path });
},
...createSvgButtonPressMethods()
});

View File

@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "关于",
"navigationBarBackgroundColor": "#f4f3ef",
"navigationBarTextStyle": "black",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,49 @@
<view class="about-page" style="{{themeStyle}}">
<scroll-view class="about-scroll" scroll-y="true">
<view class="about-shell">
<view class="about-bg-orb about-bg-orb-left"></view>
<view class="about-bg-orb about-bg-orb-right"></view>
<view class="about-stack">
<view class="about-hero">
<view class="about-home-brand">
<image class="about-home-logo" src="/assets/icons/logo.svg" mode="aspectFit" />
<image class="about-home-wordmark" src="/assets/icons/remoteconn.svg" mode="widthFix" />
<image class="about-home-submark" src="/assets/icons/ai矩连.svg" mode="widthFix" />
<text class="about-home-version">{{homeVersionLine}}</text>
</view>
</view>
<view class="about-card-list">
<button
wx:for="{{items}}"
wx:key="key"
class="about-entry svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-path="{{item.path}}"
data-press-key="{{item.pressKey}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onOpenItem"
>
<view class="about-entry-inner">
<view class="about-entry-main">
<text class="about-entry-title">{{item.title}}</text>
<text class="about-entry-subtitle">{{item.subtitle}}</text>
</view>
<image
class="about-entry-arrow svg-press-icon"
src="{{pressedSvgButtonKey === item.pressKey ? (accentIcons.right || icons.right || '/assets/icons/right.svg') : (icons.right || '/assets/icons/right.svg')}}"
mode="aspectFit"
/>
</view>
</button>
</view>
</view>
</view>
</scroll-view>
<bottom-nav page="about" />
</view>

View File

@@ -0,0 +1,36 @@
@import "./common.wxss";
.about-home-brand {
position: relative;
padding: 0 0 10rpx 8rpx;
}
.about-home-logo {
width: 96rpx;
height: 96rpx;
display: block;
}
.about-home-wordmark {
width: 563rpx;
display: block;
margin-top: -96rpx;
margin-left: 112rpx;
}
.about-home-submark {
width: 88rpx;
display: block;
margin-top: 14rpx;
margin-left: 112rpx;
}
.about-home-version {
display: block;
margin-top: 14rpx;
margin-left: 112rpx;
font-size: 28rpx;
line-height: 1.2;
font-weight: 700;
color: var(--about-text);
}

View File

@@ -0,0 +1,925 @@
/* global Page, wx, require, console, module */
const {
createServerSeed,
listServers,
saveServers,
upsertServer,
removeServer,
markServerConnected,
appendLog,
getSettings
} = require("../../utils/storage");
const { getTerminalSessionSnapshot } = require("../../utils/terminalSession");
const {
isTerminalSessionAiHighlighted,
isTerminalSessionConnecting,
isTerminalSessionHighlighted
} = require("../../utils/terminalSessionState");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const { buildButtonIconThemeMaps, resolveButtonIcon } = require("../../utils/themedIcons");
const { openTerminalPage: navigateTerminalPage } = require("../../utils/terminalNavigation");
const { buildPageCopy, formatTemplate, normalizeUiLanguage } = require("../../utils/i18n");
const { subscribeSyncConfigApplied } = require("../../utils/syncConfigBus");
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
const { getWindowMetrics } = require("../../utils/systemInfoCompat");
const SWIPE_AXIS_LOCK_THRESHOLD_PX = 8;
const SERVER_SWIPE_ACTION_WIDTH_RPX = 240;
const SERVER_SWIPE_FALLBACK_WIDTH_PX = 120;
function resolveTouchClientPoint(event) {
const point =
(event && event.touches && event.touches[0]) ||
(event && event.changedTouches && event.changedTouches[0]) ||
null;
if (!point) return null;
const x = Number(point.clientX);
const y = Number(point.clientY);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
function resolveTouchClientY(event) {
const point = resolveTouchClientPoint(event);
return point ? point.y : null;
}
/**
* 服务器左滑动作区露出“复制 + 删除”两个按钮,整体宽度按设计稿 `240rpx` 换算:
* 1. 统一在 JS 内转成 px便于与 touch `clientX` 直接比较;
* 2. 老环境拿不到窗口宽度时回退到保守值,避免手势完全失效。
*/
function resolveServerSwipeRevealPx(windowWidth) {
const width = Number(windowWidth);
if (!Number.isFinite(width) || width <= 0) {
return SERVER_SWIPE_FALLBACK_WIDTH_PX;
}
return Math.round((width * SERVER_SWIPE_ACTION_WIDTH_RPX) / 750);
}
function clampServerSwipeOffset(offset, revealPx) {
const numeric = Number(offset);
if (!Number.isFinite(numeric)) return 0;
if (numeric < -revealPx) return -revealPx;
if (numeric > 0) return 0;
return numeric;
}
function shouldOpenServerSwipe(offset, revealPx) {
return clampServerSwipeOffset(offset, revealPx) <= -revealPx * 0.45;
}
const loggedRenderProbeKeys = new Set();
function shouldUseTextIcons() {
if (!wx || typeof wx.getAppBaseInfo !== "function") return false;
try {
const info = wx.getAppBaseInfo() || {};
return (
String(info.platform || "")
.trim()
.toLowerCase() === "devtools"
);
} catch {
return false;
}
}
function inspectRenderPayload(payload) {
const stats = {
stringCount: 0,
dataImageCount: 0,
svgPathCount: 0,
urlCount: 0,
maxLength: 0,
samples: []
};
const walk = (value, path, depth) => {
if (depth > 5) return;
if (typeof value === "string") {
stats.stringCount += 1;
if (value.includes("data:image")) stats.dataImageCount += 1;
if (value.includes(".svg")) stats.svgPathCount += 1;
if (value.includes("url(")) stats.urlCount += 1;
if (value.length > stats.maxLength) stats.maxLength = value.length;
if (
stats.samples.length < 6 &&
(value.includes("data:image") ||
value.includes(".svg") ||
value.includes("url(") ||
value.length >= 120)
) {
stats.samples.push({
path,
length: value.length,
preview: value.slice(0, 120)
});
}
return;
}
if (!value || typeof value !== "object") return;
if (Array.isArray(value)) {
value.forEach((item, index) => walk(item, `${path}[${index}]`, depth + 1));
return;
}
Object.keys(value).forEach((key) => walk(value[key], path ? `${path}.${key}` : key, depth + 1));
};
walk(payload, "", 0);
return stats;
}
function logRenderProbeOnce(key, label, payload) {
const normalizedKey = String(key || label || "");
if (!normalizedKey || loggedRenderProbeKeys.has(normalizedKey)) return;
loggedRenderProbeKeys.add(normalizedKey);
console.warn(`[render_probe] ${label}`, inspectRenderPayload(payload));
}
/**
* 服务器列表页(对齐 Web ConnectView
* 1. 顶部三图标:新增、删除已选、全选/取消全选;
* 2. 搜索框 + 单层列表;
* 3. 每行保留 ai/connect 图标位,排序改为长按拖拽。
*/
const connectPageOptions = {
data: {
...buildSvgButtonPressData(),
themeStyle: "",
icons: {},
activeIcons: {},
accentIcons: {},
copy: buildPageCopy("zh-Hans", "connect"),
textIconMode: false,
query: "",
servers: [],
filteredServers: [],
selectedServerIds: [],
isAllSelected: false,
activeServerId: "",
connectingServerId: "",
dragActive: false,
dragServerId: ""
},
dragRuntime: null,
dragTapLockUntil: 0,
syncConfigUnsub: null,
swipeRuntime: null,
swipeOffsets: null,
serverSwipeRevealPx: 0,
onLoad() {
/**
* 首次启动时,云端 bootstrap 可能晚于首页首帧完成:
* 1. 首页先按旧本地快照渲染是正常的;
* 2. 一旦 bootstrap 合并回 storage需要立刻重读服务器列表和主题
* 3. 否则用户会误以为同步没生效,必须手动重进页面才看到更新。
*/
this.syncConfigUnsub = subscribeSyncConfigApplied(() => {
this.applyThemeStyle();
this.reloadServers();
});
},
onShow() {
this.applyThemeStyle();
this.reloadServers();
},
onHide() {
this.swipeRuntime = null;
this.closeAllServerRows();
if (this.data.dragActive) {
this.clearDragState();
}
},
onUnload() {
if (typeof this.syncConfigUnsub === "function") {
this.syncConfigUnsub();
this.syncConfigUnsub = null;
}
},
applyThemeStyle() {
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const copy = buildPageCopy(language, "connect");
const { icons, activeIcons, accentIcons } = buildButtonIconThemeMaps(settings);
applyNavigationBarTheme(settings);
wx.setNavigationBarTitle({ title: copy.navTitle || "服务器" });
const payload = {
themeStyle: buildThemeStyle(settings),
icons,
activeIcons,
accentIcons,
copy,
textIconMode: shouldUseTextIcons() && Object.keys(icons).length === 0
};
logRenderProbeOnce("connect.applyThemeStyle", "connect.applyThemeStyle", payload);
this.setData(payload);
},
reloadServers() {
const rows = listServers();
this.reconcileServerSwipeState(rows);
this.setData({ servers: rows }, () => {
this.applyFilter(this.data.query);
this.syncSelectState();
});
},
applyFilter(query) {
const text = String(query || "")
.trim()
.toLowerCase();
const selected = new Set(this.data.selectedServerIds);
const sessionSnapshot = getTerminalSessionSnapshot();
const fallbackThemeMaps = buildButtonIconThemeMaps(getSettings());
const iconMap =
this.data.icons && Object.keys(this.data.icons).length ? this.data.icons : fallbackThemeMaps.icons;
const activeIconMap =
this.data.activeIcons && Object.keys(this.data.activeIcons).length
? this.data.activeIcons
: fallbackThemeMaps.activeIcons;
const accentIconMap =
this.data.accentIcons && Object.keys(this.data.accentIcons).length
? this.data.accentIcons
: fallbackThemeMaps.accentIcons;
this.reconcileServerSwipeState(this.data.servers);
const next = this.data.servers
.filter((item) => {
if (!text) return true;
return [
item.name,
item.host,
item.username,
String(item.port),
item.authType,
this.resolveDisplayTags(item)
.map((tag) => tag.label)
.join(" ")
]
.join(" ")
.toLowerCase()
.includes(text);
})
.map((item) => {
const tags = this.resolveTags(item);
const displayTags = this.resolveDisplayTags(item);
const isConnected = isTerminalSessionHighlighted(sessionSnapshot, item.id);
const isAiConnected = isTerminalSessionAiHighlighted(sessionSnapshot, item.id);
return {
...item,
selected: selected.has(item.id),
swipeOffsetX:
this.swipeOffsets && Number.isFinite(Number(this.swipeOffsets[item.id]))
? this.clampServerSwipeOffset(this.swipeOffsets[item.id])
: 0,
tags,
displayTags,
lastConnectedText: this.formatLastConnected(item.lastConnectedAt),
authTypeLabel:
(this.data.copy &&
this.data.copy.authTypeLabels &&
this.data.copy.authTypeLabels[item.authType]) ||
item.authType ||
"-",
isConnected,
isAiConnected,
isConnecting:
this.data.connectingServerId === item.id || isTerminalSessionConnecting(sessionSnapshot, item.id),
aiPressKey: `connect-ai:${item.id}`,
connectPressKey: `connect-open:${item.id}`,
aiIcon: resolveButtonIcon("/assets/icons/ai.svg", isAiConnected ? activeIconMap : iconMap),
aiPressedIcon: resolveButtonIcon(
"/assets/icons/ai.svg",
isAiConnected ? activeIconMap : accentIconMap
),
connectIcon: resolveButtonIcon("/assets/icons/connect.svg", isConnected ? activeIconMap : iconMap),
connectPressedIcon: resolveButtonIcon(
"/assets/icons/connect.svg",
isConnected ? activeIconMap : accentIconMap
),
dragOffsetY: 0,
dragging: false
};
});
const payload = { query, filteredServers: next };
logRenderProbeOnce("connect.applyFilter", "connect.applyFilter", payload);
this.setData(payload, () => this.refreshDragVisual());
},
syncSelectState() {
const ids = this.data.servers.map((item) => item.id);
const selected = this.data.selectedServerIds.filter((id) => ids.includes(id));
const isAllSelected = ids.length > 0 && selected.length === ids.length;
this.setData({ selectedServerIds: selected, isAllSelected }, () => this.applyFilter(this.data.query));
},
onQueryInput(event) {
this.closeAllServerRows();
this.applyFilter(event.detail.value || "");
},
onSearchTap() {
this.closeAllServerRows();
this.applyFilter(this.data.query);
},
onCreateServer() {
this.closeAllServerRows();
const seed = createServerSeed();
const prefix =
(this.data.copy && this.data.copy.fallback && this.data.copy.fallback.newServerPrefix) || "server";
upsertServer({ ...seed, name: `${prefix}-${this.data.servers.length + 1}` });
this.reloadServers();
wx.navigateTo({ url: `/pages/server-settings/index?id=${seed.id}` });
},
onToggleServerSelect(event) {
this.closeAllServerRows();
const id = event.currentTarget.dataset.id;
if (!id) return;
const selected = new Set(this.data.selectedServerIds);
if (selected.has(id)) {
selected.delete(id);
} else {
selected.add(id);
}
this.setData({ selectedServerIds: [...selected] }, () => this.syncSelectState());
},
onToggleSelectAll() {
this.closeAllServerRows();
if (this.data.isAllSelected) {
this.setData({ selectedServerIds: [] }, () => this.syncSelectState());
return;
}
const all = this.data.servers.map((item) => item.id);
this.setData({ selectedServerIds: all }, () => this.syncSelectState());
},
onRemoveSelected() {
this.closeAllServerRows();
const targets = this.data.selectedServerIds;
if (!targets.length) return;
const copy = this.data.copy || {};
wx.showModal({
title: copy?.modal?.removeTitle || "删除服务器",
content: formatTemplate(copy?.modal?.removeContent, { count: targets.length }),
success: (res) => {
if (!res.confirm) return;
targets.forEach((id) => removeServer(id));
this.setData({ selectedServerIds: [] });
this.reloadServers();
}
});
},
onOpenSettings(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
this.setData({ activeServerId: serverId || "" });
wx.navigateTo({ url: `/pages/server-settings/index?id=${serverId}` });
},
onConnect(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
const sessionSnapshot = getTerminalSessionSnapshot();
if (
isTerminalSessionHighlighted(sessionSnapshot, serverId) ||
isTerminalSessionConnecting(sessionSnapshot, serverId)
) {
this.setData({ activeServerId: serverId }, () => this.applyFilter(this.data.query));
this.openTerminalPage(serverId, true);
return;
}
this.setData({ activeServerId: serverId, connectingServerId: serverId }, () =>
this.applyFilter(this.data.query)
);
markServerConnected(serverId);
appendLog({
serverId,
status: "connecting",
summary: (this.data.copy && this.data.copy.summary && this.data.copy.summary.connectFromList) || ""
});
this.setData({ connectingServerId: "" });
this.openTerminalPage(serverId, false);
},
/**
* 若当前页下方已经是终端页,优先直接返回,避免重复压入新的终端实例。
*/
openTerminalPage(serverId, reuseExisting, options) {
navigateTerminalPage(serverId, reuseExisting, options);
},
onAiTap(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
const server = this.data.servers.find((item) => item.id === serverId);
if (!server) {
wx.showToast({ title: this.data.copy?.toast?.serverNotFound || "服务器不存在", icon: "none" });
return;
}
const sessionSnapshot = getTerminalSessionSnapshot();
this.setData({ activeServerId: serverId }, () => this.applyFilter(this.data.query));
if (
isTerminalSessionHighlighted(sessionSnapshot, serverId) ||
isTerminalSessionConnecting(sessionSnapshot, serverId)
) {
this.openTerminalPage(serverId, true, { openCodex: true });
return;
}
this.setData({ connectingServerId: serverId }, () => this.applyFilter(this.data.query));
markServerConnected(serverId);
appendLog({
serverId,
status: "connecting",
summary: (this.data.copy && this.data.copy.summary && this.data.copy.summary.aiFromList) || ""
});
this.setData({ connectingServerId: "" });
this.openTerminalPage(serverId, false, { openCodex: true });
},
/**
* 复制服务器配置(含认证信息):
* 1. 基于当前服务器快照复制全部字段;
* 2. 重新生成唯一 ID避免覆盖原记录
* 3. 名称按“原服务器名+copy”落库便于用户二次编辑。
*/
onCopyServer(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
const source = this.data.servers.find((item) => item.id === serverId);
if (!source) {
wx.showToast({
title: this.data.copy?.toast?.serverToCopyNotFound || "未找到待复制服务器",
icon: "none"
});
return;
}
const seed = createServerSeed();
const copySuffix = String(this.data.copy?.labels?.copyNameSuffix || " copy");
const copied = {
...source,
id: seed.id,
name: `${String(source.name || this.data.copy?.unnamedServer || "未命名服务器")}${copySuffix}`,
sortOrder: Date.now(),
lastConnectedAt: ""
};
upsertServer(copied);
this.setData({ activeServerId: copied.id }, () => this.reloadServers());
wx.showToast({ title: this.data.copy?.toast?.serverCopied || "服务器已复制", icon: "none" });
},
onStartDrag(event) {
this.closeAllServerRows();
this.swipeRuntime = null;
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
if (this.data.dragActive) return;
if (String(this.data.query || "").trim()) {
wx.showToast({
title: this.data.copy?.toast?.clearSearchBeforeSort || "请清空搜索后再调整顺序",
icon: "none"
});
return;
}
if (this.data.filteredServers.length <= 1) return;
const fromIndex = this.data.filteredServers.findIndex((item) => item.id === serverId);
if (fromIndex < 0) return;
const query = wx.createSelectorQuery().in(this);
query.selectAll(".server-list-row").boundingClientRect((rects) => {
if (!Array.isArray(rects) || rects.length !== this.data.filteredServers.length) return;
const startRect = rects[fromIndex];
if (!startRect) return;
const startY = resolveTouchClientY(event) || startRect.top + startRect.height / 2;
this.dragRuntime = {
serverId,
fromIndex,
toIndex: fromIndex,
startY,
offsetY: 0,
orderIds: this.data.filteredServers.map((item) => item.id),
rects: rects.map((item) => ({
top: Number(item.top) || 0,
height: Number(item.height) || 0,
center: (Number(item.top) || 0) + (Number(item.height) || 0) / 2
}))
};
this.setData(
{
dragActive: true,
dragServerId: serverId
},
() => this.refreshDragVisual()
);
});
query.exec();
},
onDragTouchMove(event) {
if (!this.data.dragActive || !this.dragRuntime) return;
const touchY = resolveTouchClientY(event);
if (touchY == null) return;
const runtime = this.dragRuntime;
runtime.offsetY = touchY - runtime.startY;
const sourceRect = runtime.rects[runtime.fromIndex];
if (!sourceRect) return;
const center = sourceRect.center + runtime.offsetY;
let targetIndex = runtime.fromIndex;
let minDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < runtime.rects.length; i += 1) {
const distance = Math.abs(center - runtime.rects[i].center);
if (distance < minDistance) {
minDistance = distance;
targetIndex = i;
}
}
runtime.toIndex = targetIndex;
this.refreshDragVisual();
},
onDragTouchEnd() {
if (!this.data.dragActive || !this.dragRuntime) return;
const runtime = this.dragRuntime;
const moved = runtime.toIndex !== runtime.fromIndex;
const dragServerId = runtime.serverId;
this.clearDragState();
if (!moved || !dragServerId) return;
const rows = this.data.servers.slice();
const fromIndex = rows.findIndex((item) => item.id === dragServerId);
if (fromIndex < 0) return;
const [current] = rows.splice(fromIndex, 1);
if (!current) return;
const targetIndex = Math.max(0, Math.min(rows.length, runtime.toIndex));
rows.splice(targetIndex, 0, current);
this.persistServerOrder(rows, dragServerId);
},
clearDragState() {
this.dragRuntime = null;
this.dragTapLockUntil = Date.now() + 240;
const next = this.data.filteredServers.map((item) => {
if (!item.dragOffsetY && !item.dragging) return item;
return {
...item,
dragOffsetY: 0,
dragging: false
};
});
this.setData({
dragActive: false,
dragServerId: "",
filteredServers: next
});
},
buildDragOffsetMap() {
if (!this.data.dragActive || !this.dragRuntime) return {};
const runtime = this.dragRuntime;
const from = runtime.fromIndex;
const to = runtime.toIndex;
const offsets = {
[runtime.serverId]: runtime.offsetY
};
if (to > from) {
for (let i = from + 1; i <= to; i += 1) {
const id = runtime.orderIds[i];
if (!id) continue;
const prev = runtime.rects[i - 1];
const current = runtime.rects[i];
offsets[id] = (prev ? prev.top : 0) - (current ? current.top : 0);
}
} else if (to < from) {
for (let i = to; i < from; i += 1) {
const id = runtime.orderIds[i];
if (!id) continue;
const current = runtime.rects[i];
const next = runtime.rects[i + 1];
offsets[id] = (next ? next.top : 0) - (current ? current.top : 0);
}
}
return offsets;
},
refreshDragVisual() {
if (!this.data.dragActive || !this.dragRuntime) return;
const offsets = this.buildDragOffsetMap();
const dragId = this.data.dragServerId;
const next = this.data.filteredServers.map((item) => {
const dragOffsetY = Number(offsets[item.id] || 0);
const dragging = item.id === dragId;
if (item.dragOffsetY === dragOffsetY && item.dragging === dragging) {
return item;
}
return {
...item,
dragOffsetY,
dragging
};
});
this.setData({ filteredServers: next });
},
persistServerOrder(rows, activeServerId) {
const base = Date.now();
const next = rows.map((item, index) => ({
...item,
sortOrder: base + index
}));
saveServers(next);
this.setData(
{
servers: next,
activeServerId: activeServerId || this.data.activeServerId
},
() => {
this.applyFilter(this.data.query);
this.syncSelectState();
}
);
},
resolveTouchPoint(event) {
return resolveTouchClientPoint(event);
},
getServerSwipeRevealPx() {
if (Number.isFinite(this.serverSwipeRevealPx) && this.serverSwipeRevealPx > 0) {
return this.serverSwipeRevealPx;
}
const metrics = getWindowMetrics(wx);
this.serverSwipeRevealPx = resolveServerSwipeRevealPx(metrics.windowWidth);
return this.serverSwipeRevealPx;
},
clampServerSwipeOffset(offset) {
return clampServerSwipeOffset(offset, this.getServerSwipeRevealPx());
},
reconcileServerSwipeState(rows) {
const list = Array.isArray(rows) ? rows : [];
const validIds = new Set(list.map((item) => item.id));
const nextOffsets = {};
Object.keys(this.swipeOffsets || {}).forEach((id) => {
if (!validIds.has(id)) return;
nextOffsets[id] = this.clampServerSwipeOffset(this.swipeOffsets[id]);
});
this.swipeOffsets = nextOffsets;
if (this.swipeRuntime && !validIds.has(this.swipeRuntime.id)) {
this.swipeRuntime = null;
}
},
findFilteredServerIndexById(id) {
return this.data.filteredServers.findIndex((item) => item.id === id);
},
updateServerSwipeOffset(id, offset) {
const index = this.findFilteredServerIndexById(id);
if (index < 0) return;
const normalized = this.clampServerSwipeOffset(offset);
this.swipeOffsets = this.swipeOffsets || {};
this.swipeOffsets[id] = normalized;
this.setData({ [`filteredServers[${index}].swipeOffsetX`]: normalized });
},
closeOtherServerRows(exceptId) {
this.swipeOffsets = this.swipeOffsets || {};
const updates = {};
this.data.filteredServers.forEach((item, index) => {
if (item.id === exceptId) return;
if (item.swipeOffsetX === 0) return;
this.swipeOffsets[item.id] = 0;
updates[`filteredServers[${index}].swipeOffsetX`] = 0;
});
if (Object.keys(updates).length > 0) {
this.setData(updates);
}
},
closeAllServerRows() {
this.swipeOffsets = this.swipeOffsets || {};
const updates = {};
this.data.filteredServers.forEach((item, index) => {
if (item.swipeOffsetX === 0) return;
this.swipeOffsets[item.id] = 0;
updates[`filteredServers[${index}].swipeOffsetX`] = 0;
});
if (Object.keys(updates).length > 0) {
this.setData(updates);
}
},
onListTap() {
this.closeAllServerRows();
},
/**
* 服务器列表沿用闪念页的横向手势模型:
* 1. `touchstart` 仅记录起点和当前开合态;
* 2. `touchmove` 再做横纵轴锁定,避免和列表纵向滚动打架;
* 3. 只允许向左露出删除按钮,不支持向右拖出正偏移。
*/
onServerTouchStart(event) {
if (this.data.dragActive) return;
const id = String(event.currentTarget.dataset.id || "");
if (!id) return;
const point = this.resolveTouchPoint(event);
if (!point) return;
this.closeOtherServerRows(id);
this.swipeOffsets = this.swipeOffsets || {};
this.swipeRuntime = {
id,
startX: point.x,
startY: point.y,
startOffsetX: this.clampServerSwipeOffset(this.swipeOffsets[id] || 0),
dragging: false,
blocked: false
};
},
onServerTouchMove(event) {
if (this.data.dragActive) return;
const runtime = this.swipeRuntime;
if (!runtime || runtime.blocked) return;
const id = String(event.currentTarget.dataset.id || "");
if (!id || id !== runtime.id) return;
const point = this.resolveTouchPoint(event);
if (!point) return;
const deltaX = point.x - runtime.startX;
const deltaY = point.y - runtime.startY;
if (!runtime.dragging) {
if (
Math.abs(deltaX) < SWIPE_AXIS_LOCK_THRESHOLD_PX &&
Math.abs(deltaY) < SWIPE_AXIS_LOCK_THRESHOLD_PX
) {
return;
}
if (Math.abs(deltaY) > Math.abs(deltaX)) {
runtime.blocked = true;
return;
}
runtime.dragging = true;
}
this.updateServerSwipeOffset(id, runtime.startOffsetX + deltaX);
},
onServerTouchEnd(event) {
if (this.data.dragActive) return;
const runtime = this.swipeRuntime;
this.swipeRuntime = null;
if (!runtime || runtime.blocked) return;
const id = String(event.currentTarget.dataset.id || "");
if (!id || id !== runtime.id) return;
const current = this.clampServerSwipeOffset((this.swipeOffsets && this.swipeOffsets[id]) || 0);
const shouldOpen = shouldOpenServerSwipe(current, this.getServerSwipeRevealPx());
this.updateServerSwipeOffset(id, shouldOpen ? -this.getServerSwipeRevealPx() : 0);
},
onSwipeDeleteServer(event) {
const serverId = String(event.currentTarget.dataset.id || "");
if (!serverId) return;
const server = this.data.servers.find((item) => item.id === serverId);
const serverName = String(
(server && server.name) || this.data.copy?.unnamedServer || event.currentTarget.dataset.name || ""
).trim();
const copy = this.data.copy || {};
wx.showModal({
title: copy?.modal?.removeTitle || "删除服务器",
content: formatTemplate(copy?.modal?.removeSingleContent, {
name: serverName || copy?.unnamedServer || "未命名服务器"
}),
success: (res) => {
if (!res.confirm) return;
removeServer(serverId);
if (this.swipeOffsets) {
delete this.swipeOffsets[serverId];
}
this.setData(
{
activeServerId: this.data.activeServerId === serverId ? "" : this.data.activeServerId,
selectedServerIds: this.data.selectedServerIds.filter((id) => id !== serverId)
},
() => {
this.reloadServers();
wx.showToast({
title: copy?.toast?.serverDeleted || "服务器已删除",
icon: "success"
});
}
);
}
});
},
/**
* 服务器标签对齐 Web 语义:
* 1. 只展示用户明确配置的 tags
* 2. 不再按服务器名称猜测标签,避免与真实标签混淆。
*/
resolveTags(server) {
return Array.isArray(server && server.tags)
? server.tags.map((item) => String(item || "").trim()).filter((item) => !!item)
: [];
},
/**
* 提取项目目录最后一级名称:
* 1. 先清理空白与尾部斜杠;
* 2. 同时兼容 Unix/Windows 路径;
* 3. 列表里只展示短目录名,避免胶囊过长。
*/
resolveProjectDirectoryName(projectPath) {
const normalized = String(projectPath || "")
.trim()
.replace(/[\\/]+$/g, "");
if (!normalized) return "";
const segments = normalized.split(/[\\/]+/).filter(Boolean);
if (!segments.length) {
return normalized === "~" ? "~" : "";
}
return segments[segments.length - 1] || "";
},
/**
* 卡片底部标签组装:
* 1. project 胶囊固定排在最前面;
* 2. 原始 tags 继续跟在后面;
* 3. 返回对象数组,便于模板按类型切换底色。
*/
resolveDisplayTags(server) {
const displayTags = [];
const projectDirectoryName = this.resolveProjectDirectoryName(server && server.projectPath);
if (projectDirectoryName) {
displayTags.push({
type: "project",
label: `${(this.data.copy && this.data.copy.display && this.data.copy.display.projectPrefix) || "pro"}:${projectDirectoryName}`
});
}
this.resolveTags(server).forEach((tag) => {
displayTags.push({
type: "tag",
label: tag
});
});
return displayTags;
},
/**
* 最近连接时间统一格式:
* - 空值显示“无连接”;
* - 非法时间显示“无连接”;
* - 合法时间输出 YYYY-MM-DD HH:mm:ss。
*/
formatLastConnected(input) {
if (!input) return this.data.copy?.fallback?.noConnection || "无连接";
const date = new Date(input);
if (Number.isNaN(+date)) return this.data.copy?.fallback?.noConnection || "无连接";
const pad = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
},
...createSvgButtonPressMethods()
};
Page(connectPageOptions);
module.exports = {
__test__: {
pageOptions: connectPageOptions,
SWIPE_AXIS_LOCK_THRESHOLD_PX,
SERVER_SWIPE_ACTION_WIDTH_RPX,
SERVER_SWIPE_FALLBACK_WIDTH_PX,
resolveServerSwipeRevealPx,
clampServerSwipeOffset,
shouldOpenServerSwipe
}
};

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "服务器",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type ConnectPageOptions = {
data: Record<string, unknown>;
onServerTouchStart?: (event: Record<string, unknown>) => void;
onServerTouchMove?: (event: Record<string, unknown>) => void;
onServerTouchEnd?: (event: Record<string, unknown>) => void;
closeOtherServerRows?: (exceptId: string) => void;
findFilteredServerIndexById?: (id: string) => number;
updateServerSwipeOffset?: (id: string, offset: number) => void;
clampServerSwipeOffset?: (offset: number) => number;
resolveTouchPoint?: (event: Record<string, unknown>) => { x: number; y: number } | null;
};
type ConnectTestHelpers = {
pageOptions: ConnectPageOptions;
resolveServerSwipeRevealPx: (windowWidth: number) => number;
clampServerSwipeOffset: (offset: number, revealPx: number) => number;
shouldOpenServerSwipe: (offset: number, revealPx: number) => boolean;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: ConnectPageOptions) => void;
wx?: {
getWindowInfo?: () => { windowWidth: number; windowHeight: number };
};
};
function createTouchEvent(id: string, x: number, y: number) {
return {
currentTarget: {
dataset: { id }
},
touches: [{ clientX: x, clientY: y }],
changedTouches: [{ clientX: x, clientY: y }]
};
}
/**
* 测试里只需要覆盖当前页面真正会写入的 `setData` 路径:
* 1. 直接字段,如 `dragActive`
* 2. `filteredServers[0].swipeOffsetX` 这类列表项字段。
*/
function applySetData(target: Record<string, unknown>, updates: Record<string, unknown>) {
Object.entries(updates).forEach(([path, value]) => {
const itemMatch = path.match(/^filteredServers\[(\d+)\]\.([a-zA-Z0-9_]+)$/);
if (itemMatch) {
const index = Number(itemMatch[1]);
const key = itemMatch[2];
const rows = target.filteredServers as Array<Record<string, unknown>>;
rows[index][key] = value;
return;
}
target[path] = value;
});
}
function createFakePage(options: ConnectPageOptions) {
const page = {
...options,
data: {
...options.data,
dragActive: false,
filteredServers: [
{
id: "srv-1",
swipeOffsetX: 0
}
]
},
swipeOffsets: { "srv-1": 0 },
swipeRuntime: null as Record<string, unknown> | null,
serverSwipeRevealPx: 120,
setData(updates: Record<string, unknown>) {
applySetData(this.data as Record<string, unknown>, updates);
},
getServerSwipeRevealPx() {
return 120;
}
};
return page;
}
describe("connect page swipe", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
let testHelpers: ConnectTestHelpers | null = null;
beforeEach(() => {
testHelpers = null;
vi.resetModules();
globalState.Page = vi.fn((options: ConnectPageOptions) => {
return options;
});
globalState.wx = {
getWindowInfo: vi.fn(() => ({ windowWidth: 375, windowHeight: 812 }))
};
testHelpers = require("./index.js").__test__;
});
afterEach(() => {
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("应按窗口宽度把删除动作区从 rpx 换算成 px", () => {
expect(testHelpers).toBeTruthy();
expect(testHelpers?.resolveServerSwipeRevealPx(375)).toBe(120);
expect(testHelpers?.resolveServerSwipeRevealPx(0)).toBeGreaterThan(0);
});
it("横向左滑超过阈值后应展开删除按钮", () => {
expect(testHelpers?.pageOptions).toBeTruthy();
const page = createFakePage(testHelpers?.pageOptions as ConnectPageOptions);
testHelpers?.pageOptions.onServerTouchStart?.call(page, createTouchEvent("srv-1", 220, 100));
testHelpers?.pageOptions.onServerTouchMove?.call(page, createTouchEvent("srv-1", 160, 103));
expect((page.data.filteredServers as Array<Record<string, unknown>>)[0].swipeOffsetX).toBe(-60);
testHelpers?.pageOptions.onServerTouchEnd?.call(page, createTouchEvent("srv-1", 160, 103));
expect((page.data.filteredServers as Array<Record<string, unknown>>)[0].swipeOffsetX).toBe(-120);
});
it("纵向滚动手势不应误展开删除按钮", () => {
expect(testHelpers?.pageOptions).toBeTruthy();
const page = createFakePage(testHelpers?.pageOptions as ConnectPageOptions);
testHelpers?.pageOptions.onServerTouchStart?.call(page, createTouchEvent("srv-1", 220, 100));
testHelpers?.pageOptions.onServerTouchMove?.call(page, createTouchEvent("srv-1", 214, 136));
testHelpers?.pageOptions.onServerTouchEnd?.call(page, createTouchEvent("srv-1", 214, 136));
expect((page.data.filteredServers as Array<Record<string, unknown>>)[0].swipeOffsetX).toBe(0);
});
it("开合阈值应与当前动作区宽度一致", () => {
expect(testHelpers?.clampServerSwipeOffset(-160, 120)).toBe(-120);
expect(testHelpers?.clampServerSwipeOffset(12, 120)).toBe(0);
expect(testHelpers?.shouldOpenServerSwipe(-60, 120)).toBe(true);
expect(testHelpers?.shouldOpenServerSwipe(-40, 120)).toBe(false);
});
});

View File

@@ -0,0 +1,220 @@
<view class="page-root server-manager-page" style="{{themeStyle}}">
<view class="page-toolbar server-manager-toolbar">
<view class="toolbar-left">
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:create"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onCreateServer"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:create' ? (accentIcons.create || icons.create || '/assets/icons/create.svg') : (icons.create || '/assets/icons/create.svg')}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text">{{copy.textIcons.create}}</text>
</button>
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:remove"
disabled="{{selectedServerIds.length === 0}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onRemoveSelected"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:remove' ? (accentIcons.delete || icons.delete || '/assets/icons/delete.svg') : (icons.delete || '/assets/icons/delete.svg')}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text">{{copy.textIcons.remove}}</text>
</button>
<button
class="icon-btn toolbar-plain-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:selectall"
disabled="{{servers.length === 0}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onToggleSelectAll"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:selectall' ? (accentIcons.selectall || icons.selectall || '/assets/icons/selectall.svg') : (icons.selectall || '/assets/icons/selectall.svg')}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text">{{copy.textIcons.selectAll}}</text>
</button>
</view>
<view class="toolbar-spacer"></view>
<text class="page-title">{{copy.pageTitle}}</text>
</view>
<view class="page-content server-manager-content">
<view class="server-search-wrap">
<view class="server-search-shell">
<input class="server-search-input" type="text" placeholder="{{copy.searchPlaceholder}}" value="{{query}}" bindinput="onQueryInput" />
<button
class="server-search-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:search"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onSearchTap"
>
<image
wx:if="{{!textIconMode}}"
class="server-search-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:search' ? (accentIcons.search || icons.search || '/assets/icons/search.svg') : (icons.search || '/assets/icons/search.svg')}}"
mode="aspectFit"
/>
<text wx:else class="server-search-text">{{copy.textIcons.search}}</text>
</button>
</view>
</view>
<scroll-view class="surface-scroll server-list-scroll" scroll-y="{{!dragActive}}">
<view
class="server-list-stack {{dragActive ? 'dragging' : ''}}"
bindtap="onListTap"
bindtouchmove="onDragTouchMove"
bindtouchend="onDragTouchEnd"
bindtouchcancel="onDragTouchEnd"
>
<view
wx:for="{{filteredServers}}"
wx:key="id"
class="server-list-row {{activeServerId === item.id ? 'active' : ''}} {{item.dragging ? 'is-dragging' : ''}}"
style="transform: translateY({{item.dragOffsetY || 0}}px); z-index: {{item.dragging ? 20 : 1}};"
>
<view class="server-row-check-wrap">
<view class="server-row-check-hitbox" data-id="{{item.id}}" catchtap="onToggleServerSelect">
<view class="server-row-check">
<view class="server-check-input {{item.selected ? 'checked' : ''}}"></view>
</view>
</view>
</view>
<view
class="server-row-content-shell"
data-id="{{item.id}}"
bindtouchstart="onServerTouchStart"
bindtouchmove="onServerTouchMove"
bindtouchend="onServerTouchEnd"
bindtouchcancel="onServerTouchEnd"
>
<view class="server-row-swipe-actions {{(item.swipeOffsetX || 0) < 0 ? 'opened' : ''}}">
<button
class="server-swipe-copy-btn"
data-id="{{item.id}}"
catchtap="onCopyServer"
>
<text class="server-swipe-btn-text">{{copy.swipeCopy}}</text>
</button>
<button
class="server-swipe-delete-btn"
data-id="{{item.id}}"
data-name="{{item.name || copy.unnamedServer}}"
catchtap="onSwipeDeleteServer"
>
<text class="server-swipe-btn-text">{{copy.swipeDelete}}</text>
</button>
</view>
<view class="server-row-track" style="transform: translateX({{item.swipeOffsetX || 0}}px);">
<view
class="server-info server-info-clickable"
data-id="{{item.id}}"
bindtap="onOpenSettings"
catchlongpress="onStartDrag"
>
<view class="server-info-top">
<text class="server-name">{{item.name || copy.unnamedServer}}</text>
<view class="server-row-actions">
<button
class="server-ai-btn svg-press-btn {{item.isAiConnected ? 'is-connected' : ''}}"
data-id="{{item.id}}"
data-press-key="{{item.aiPressKey}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
catchtap="onAiTap"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img server-ai-icon svg-press-icon"
src="{{pressedSvgButtonKey === item.aiPressKey ? item.aiPressedIcon : item.aiIcon}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text debug-icon-text-small">AI</text>
</button>
<button
class="connect-icon-btn svg-press-btn {{item.isConnected ? 'is-connected' : ''}} {{item.isConnecting ? 'is-connecting' : ''}}"
data-id="{{item.id}}"
data-press-key="{{item.connectPressKey}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
catchtap="onConnect"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === item.connectPressKey ? item.connectPressedIcon : item.connectIcon}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text debug-icon-text-small">{{copy.textIcons.connect}}</text>
</button>
</view>
</view>
<view class="server-info-meta">
<text class="server-main">{{item.username || '-'}}@{{item.host || '-'}}:{{item.port || 22}}</text>
<text class="server-auth">{{item.authTypeLabel || '-'}}</text>
</view>
<text class="server-recent">{{copy.recentConnectionPrefix}}: {{item.lastConnectedText}}</text>
<view wx:if="{{item.displayTags.length > 0}}" class="server-tags">
<text
wx:for="{{item.displayTags}}"
wx:key="label"
class="server-tag {{item.type === 'project' ? 'server-tag-project' : ''}}"
>{{item.label}}</text>
</view>
</view>
</view>
</view>
</view>
<text wx:if="{{filteredServers.length === 0}}" class="server-empty-tip">{{copy.emptyTip}}</text>
</view>
</scroll-view>
</view>
<bottom-nav page="connect" />
</view>

View File

@@ -0,0 +1,499 @@
.server-manager-page {
-webkit-user-select: none;
user-select: none;
}
.server-manager-content {
flex: 1;
min-height: 0;
padding: 16rpx 16rpx 32rpx;
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.server-search-wrap {
flex: 0 0 auto;
padding: 0 0 16rpx;
}
.server-manager-toolbar .icon-btn {
border-radius: 999rpx !important;
background: var(--icon-btn-bg) !important;
background-color: var(--icon-btn-bg) !important;
box-shadow: inset 0 0 0 1rpx var(--btn-border);
}
.server-manager-toolbar .svg-press-btn {
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: var(--icon-btn-bg-strong);
--svg-press-active-shadow: inset 0 0 0 1rpx var(--accent-border), 0 0 0 8rpx var(--accent-ring);
--svg-press-active-scale: 0.92;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.68;
--svg-press-icon-active-scale: 0.88;
}
.server-manager-toolbar .icon-btn:active {
background: var(--icon-btn-bg-strong) !important;
background-color: var(--icon-btn-bg-strong) !important;
}
.server-manager-toolbar .toolbar-plain-btn,
.server-manager-toolbar .toolbar-plain-btn:active {
border-radius: 0 !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
}
.server-search-shell {
display: flex;
align-items: center;
width: 100%;
height: 64rpx;
border: 1rpx solid var(--btn-border);
border-radius: 54rpx;
overflow: hidden;
}
.server-search-input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text);
font-size: 22rpx;
line-height: normal;
padding: 0 16rpx;
}
.server-search-btn {
width: 68rpx !important;
min-width: 68rpx !important;
height: 100% !important;
margin: 0 !important;
border: 0 !important;
border-left: 1rpx solid var(--btn-border-strong);
border-radius: 0 54rpx 54rpx 0;
background: var(--btn-bg-strong) !important;
background-color: var(--btn-bg-strong) !important;
color: inherit !important;
padding: 0 !important;
line-height: 1 !important;
font-size: 0 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.server-search-btn.svg-press-btn {
--svg-press-active-radius: 54rpx;
--svg-press-active-bg: var(--btn-bg-active);
--svg-press-active-shadow: none;
--svg-press-active-scale: 1;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.72;
--svg-press-icon-active-scale: 0.92;
}
.server-search-icon {
width: 26rpx;
height: 26rpx;
}
.server-search-text,
.debug-icon-text {
font-size: 20rpx;
line-height: 1;
font-weight: 600;
color: var(--btn-text);
}
.debug-icon-text-small {
font-size: 18rpx;
}
.server-list-scroll {
flex: 1;
min-height: 0;
}
.server-list-stack {
display: flex;
flex-direction: column;
gap: 32rpx;
padding-bottom: 16rpx;
position: relative;
}
.server-list-stack.dragging {
overflow: visible;
}
.server-list-row {
display: flex;
align-items: flex-start;
padding-bottom: 32rpx;
border-bottom: 1rpx solid rgba(141, 187, 255, 0.35);
position: relative;
transition:
transform 180ms ease,
box-shadow 180ms ease,
opacity 180ms ease;
will-change: transform;
isolation: isolate;
}
.server-list-row::before {
content: "";
position: absolute;
top: -8rpx;
left: -10rpx;
right: -10rpx;
bottom: 10rpx;
border-radius: 18rpx;
background: transparent;
box-shadow: none;
transition:
background 180ms ease,
box-shadow 180ms ease;
z-index: 0;
}
.server-list-row.is-dragging {
transition: none;
opacity: 0.5;
}
.server-list-row.is-dragging::before {
background: rgba(103, 209, 255, 0.22);
box-shadow: 0 18rpx 34rpx rgba(0, 0, 0, 0.26);
}
.server-list-row.active {
border-bottom-color: rgba(103, 209, 255, 0.75);
}
.server-row-check-wrap {
flex: 0 0 24rpx;
width: 24rpx;
height: 44rpx;
margin-right: 16rpx;
position: relative;
z-index: 2;
overflow: visible;
}
.server-row-content-shell {
flex: 1;
min-width: 0;
position: relative;
overflow: hidden;
border-radius: 18rpx;
z-index: 1;
}
.server-row-swipe-actions {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 240rpx;
display: flex;
align-items: stretch;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
z-index: 0;
}
.server-row-swipe-actions.opened {
opacity: 1;
pointer-events: auto;
}
.server-swipe-copy-btn,
.server-swipe-delete-btn {
width: 50% !important;
min-width: 0 !important;
height: 100% !important;
margin: 0 !important;
border: 0 !important;
color: #f7fbff !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
line-height: 1 !important;
font-size: 0 !important;
}
.server-swipe-copy-btn {
border-radius: 18rpx 0 0 18rpx !important;
background: rgba(101, 130, 149, 0.84) !important;
}
.server-swipe-delete-btn {
border-radius: 0 18rpx 18rpx 0 !important;
background: rgba(164, 118, 118, 0.86) !important;
}
.server-swipe-btn-text {
font-size: 24rpx;
line-height: 1;
font-weight: 600;
letter-spacing: 2rpx;
}
.server-row-track {
position: relative;
z-index: 1;
transition: transform 160ms ease;
will-change: transform;
}
.server-row-check-hitbox {
/* 透明热区浮在勾选框上方,并向右覆盖一部分卡片左边缘。 */
position: absolute;
left: -20rpx;
top: 50%;
width: 64rpx;
height: 64rpx;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.server-row-check {
width: 24rpx;
height: 44rpx;
display: inline-flex;
align-items: center;
justify-content: center;
}
.server-check-input {
width: 24rpx;
height: 24rpx;
border: 1rpx solid var(--btn-border);
border-radius: 8rpx;
background: var(--icon-btn-bg);
position: relative;
}
.server-check-input.checked {
border-color: var(--accent-border);
background: var(--accent);
}
.server-check-input.checked::after {
content: "";
position: absolute;
left: 9rpx;
top: 3rpx;
width: 5rpx;
height: 12rpx;
border: solid #ffffff;
border-width: 0 2rpx 2rpx 0;
transform: rotate(45deg);
}
.server-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
min-width: 0;
position: relative;
z-index: 1;
}
.server-info-top {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16rpx;
padding-right: 0;
}
.server-row-actions {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 12rpx;
margin-left: auto;
}
.server-copy-btn,
.server-ai-btn,
.connect-icon-btn {
width: 44rpx !important;
height: 44rpx !important;
min-width: 0 !important;
margin: 0 !important;
border: 0 !important;
background: transparent !important;
background-color: transparent !important;
color: inherit !important;
padding: 0 !important;
line-height: 1 !important;
font-size: 0 !important;
display: inline-flex !important;
overflow: visible !important;
align-items: center;
justify-content: center;
}
.server-copy-btn {
border-radius: 999rpx !important;
background: var(--btn-bg) !important;
background-color: var(--btn-bg) !important;
box-shadow: inset 0 0 0 2rpx var(--btn-border-strong);
}
.server-copy-icon {
width: 24rpx;
height: 24rpx;
}
.server-ai-btn,
.server-ai-icon {
width: 44rpx;
height: 44rpx;
}
.connect-icon-btn {
border-radius: 999rpx !important;
background: var(--icon-btn-bg) !important;
background-color: var(--icon-btn-bg) !important;
box-shadow: inset 0 0 0 2rpx var(--btn-border);
}
.server-ai-btn {
border-radius: 999rpx !important;
background: var(--icon-btn-bg) !important;
background-color: var(--icon-btn-bg) !important;
box-shadow: inset 0 0 0 2rpx var(--btn-border);
}
.server-copy-btn.svg-press-btn,
.server-ai-btn.svg-press-btn,
.connect-icon-btn.svg-press-btn {
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: var(--icon-btn-bg-strong);
--svg-press-active-shadow: 0 0 0 8rpx var(--accent-ring);
--svg-press-active-scale: 0.92;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.68;
--svg-press-icon-active-scale: 0.88;
}
.server-copy-btn.svg-press-btn {
--svg-press-active-bg: var(--btn-bg-active);
}
.server-ai-btn.is-connected,
.connect-icon-btn.is-connected {
background: var(--accent) !important;
background-color: var(--accent) !important;
box-shadow: 0 10rpx 24rpx var(--accent-shadow) !important;
}
.server-ai-btn.is-connected.svg-press-btn,
.connect-icon-btn.is-connected.svg-press-btn {
--svg-press-active-bg: var(--accent);
--svg-press-active-shadow: 0 0 0 8rpx var(--accent-ring), 0 10rpx 24rpx var(--accent-shadow);
--svg-press-icon-active-opacity: 0.92;
--svg-press-icon-active-scale: 0.94;
}
.server-ai-btn.is-connected .debug-icon-text,
.connect-icon-btn.is-connected .debug-icon-text {
color: var(--text);
}
.connect-icon-btn.is-connecting {
opacity: 0.45;
}
.server-copy-btn.wx-button-disabled,
.server-ai-btn.wx-button-disabled,
.connect-icon-btn.wx-button-disabled {
opacity: 0.45 !important;
}
.server-info-meta {
display: flex;
align-items: center;
gap: 15rpx;
color: var(--text);
}
.server-name {
flex: 1;
min-width: 0;
margin-right: 8rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-main,
.server-auth,
.server-recent {
font-size: 28rpx;
color: var(--text);
line-height: 1;
}
.server-main {
max-width: 360rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-auth {
font-size: 24rpx;
opacity: 0.95;
}
.server-tags {
display: flex;
align-items: center;
gap: 16rpx;
overflow: hidden;
}
.server-tag {
height: 32rpx;
padding: 0 12rpx;
border-radius: 16rpx;
background: rgba(91, 210, 255, 0.6);
color: var(--text);
font-size: 20rpx;
line-height: 32rpx;
white-space: nowrap;
}
.server-tag-project {
background: rgba(103, 209, 255, 1);
}
.server-empty-tip {
margin: 0;
font-size: 28rpx;
color: var(--muted);
text-align: center;
padding: 24rpx 0;
}

View File

@@ -0,0 +1,88 @@
/* global Page, wx, require, getCurrentPages */
const { listLogs, getSettings } = require("../../utils/storage");
const { pageOf } = require("../../utils/pagination");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const { buildPageCopy, formatTemplate, normalizeUiLanguage, t } = require("../../utils/i18n");
const PAGE_SIZE = 15;
/**
* 日志页(对齐 Web LogsView
* 1. 顶部工具栏保留返回语义;
* 2. 主体为导出 + 列表 + 分页。
*/
Page({
data: {
themeStyle: "",
canGoBack: false,
copy: buildPageCopy("zh-Hans", "logs"),
page: 1,
total: 0,
totalPages: 1,
rows: [],
totalCountText: t("zh-Hans", "logs.totalCount", { total: 0 }),
pageIndicatorText: t("zh-Hans", "common.pageIndicator", { page: 1, total: 1 })
},
onShow() {
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const copy = buildPageCopy(language, "logs");
applyNavigationBarTheme(settings);
wx.setNavigationBarTitle({ title: copy.navTitle || "日志" });
this.setData({ themeStyle: buildThemeStyle(settings), copy });
this.syncCanGoBack();
this.reload();
},
syncCanGoBack() {
const pages = getCurrentPages();
this.setData({ canGoBack: pages.length > 1 });
},
goBack() {
if (!this.data.canGoBack) return;
wx.navigateBack({ delta: 1 });
},
reload() {
const logs = listLogs();
const paged = pageOf(logs, this.data.page, PAGE_SIZE);
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
this.setData({
page: paged.page,
total: paged.total,
totalPages: paged.totalPages,
rows: paged.rows,
totalCountText: formatTemplate(this.data.copy.totalCount, { total: paged.total }),
pageIndicatorText: t(language, "common.pageIndicator", {
page: paged.page,
total: paged.totalPages
})
});
},
onPrev() {
this.setData({ page: Math.max(1, this.data.page - 1) }, () => this.reload());
},
onNext() {
this.setData({ page: Math.min(this.data.totalPages, this.data.page + 1) }, () => this.reload());
},
onExport() {
const rows = listLogs();
const content = rows
.map(
(item) =>
`[${item.startAt || "--"}] ${item.serverId || "-"} ${item.status || "-"}\n${item.summary || ""}`
)
.join("\n\n");
wx.setClipboardData({
data: content || "",
success: () => wx.showToast({ title: this.data.copy?.toast?.copied || "日志已复制", icon: "success" })
});
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "日志",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,29 @@
<view class="page-root logs-page" style="{{themeStyle}}">
<view class="page-content logs-content">
<view class="surface-panel logs-panel">
<view class="actions logs-actions">
<button class="btn" bindtap="onExport">{{copy.exportButton}}</button>
<text class="settings-save-status">{{totalCountText}}</text>
</view>
<scroll-view class="surface-scroll logs-list-scroll" scroll-y="true">
<view class="list-stack logs-list">
<view wx:for="{{rows}}" wx:key="id" class="card log-item">
<view class="item-title">{{item.serverId || '-'}} · {{item.status || '-'}}</view>
<view class="item-sub">{{item.startAt || '--'}} -> {{item.endAt || '--'}}</view>
<view class="item-sub">{{item.summary || '--'}}</view>
</view>
<text wx:if="{{rows.length === 0}}" class="empty">{{copy.empty}}</text>
</view>
</scroll-view>
<view class="records-pagination">
<button class="btn" disabled="{{page <= 1}}" bindtap="onPrev">{{copy.prev}}</button>
<text class="records-pagination-text">{{pageIndicatorText}}</text>
<button class="btn" disabled="{{page >= totalPages}}" bindtap="onNext">{{copy.next}}</button>
</view>
</view>
</view>
<bottom-nav page="logs" />
</view>

View File

@@ -0,0 +1,34 @@
.logs-content {
padding-top: 16rpx;
}
.logs-panel {
padding: 0 0 16rpx;
}
.logs-actions {
justify-content: space-between;
}
.logs-list-scroll {
flex: 1;
min-height: 0;
}
.logs-list {
min-height: 0;
}
.log-item {
border-radius: 20rpx;
}
.item-title {
font-weight: 600;
margin-bottom: 6rpx;
}
.item-sub {
font-size: 22rpx;
color: var(--muted);
}

View File

@@ -0,0 +1,196 @@
/* global Page, wx, require, getCurrentPages, console */
const pluginRuntime = require("../../utils/pluginRuntime");
const { onSessionEvent, getSessionState } = require("../../utils/sessionBus");
const { getSettings } = require("../../utils/storage");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const { buildPageCopy, formatTemplate, getRuntimeStateLabel, normalizeUiLanguage } = require("../../utils/i18n");
/**
* 插件页:
* 1. 对齐 Web 的“插件运行时管理”能力;
* 2. 支持启用/禁用/重载/移除、JSON 导入导出、命令执行与运行日志。
*/
Page({
data: {
themeStyle: "",
canGoBack: false,
pluginJson: "",
records: [],
commands: [],
runtimeLogs: [],
sessionState: "disconnected",
sessionStateLabel: "Disconnected",
copy: buildPageCopy("zh-Hans", "plugins")
},
async onShow() {
const pages = getCurrentPages();
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const copy = buildPageCopy(language, "plugins");
applyNavigationBarTheme(settings);
wx.setNavigationBarTitle({ title: copy.navTitle || "插件" });
this.setData({
canGoBack: pages.length > 1,
sessionState: getSessionState(),
sessionStateLabel: getRuntimeStateLabel(language, getSessionState()),
copy,
themeStyle: buildThemeStyle(settings)
});
if (!Array.isArray(this.sessionUnsubs) || this.sessionUnsubs.length === 0) {
this.sessionUnsubs = [
onSessionEvent("connected", () => {
const nextLanguage = normalizeUiLanguage(getSettings().uiLanguage);
this.setData(
{
sessionState: "connected",
sessionStateLabel: getRuntimeStateLabel(nextLanguage, "connected")
},
() => this.reloadRuntime()
);
}),
onSessionEvent("disconnected", () => {
const nextLanguage = normalizeUiLanguage(getSettings().uiLanguage);
this.setData(
{
sessionState: "disconnected",
sessionStateLabel: getRuntimeStateLabel(nextLanguage, "disconnected")
},
() => this.reloadRuntime()
);
})
];
}
await this.reloadRuntime();
},
onUnload() {
if (Array.isArray(this.sessionUnsubs)) {
this.sessionUnsubs.forEach((off) => {
try {
off();
} catch (error) {
console.warn("[plugins.sessionUnsubs]", error);
}
});
}
this.sessionUnsubs = null;
},
async reloadRuntime() {
try {
await pluginRuntime.ensureBootstrapped();
const records = pluginRuntime.listRecords();
const commands = pluginRuntime.listCommands(this.data.sessionState);
const runtimeLogs = pluginRuntime.listRuntimeLogs();
this.setData({ records, commands, runtimeLogs });
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.bootstrapFailed || "插件初始化失败", icon: "none" });
}
},
goBack() {
if (!this.data.canGoBack) return;
wx.navigateBack({ delta: 1 });
},
onPluginJsonInput(event) {
this.setData({ pluginJson: event.detail.value || "" });
},
async onImportJson() {
if (!String(this.data.pluginJson || "").trim()) {
wx.showToast({ title: this.data.copy?.toast?.pastePluginJsonFirst || "请先粘贴插件 JSON", icon: "none" });
return;
}
try {
await pluginRuntime.importJson(this.data.pluginJson);
this.setData({ pluginJson: "" });
await this.reloadRuntime();
wx.showToast({ title: this.data.copy?.toast?.importSuccess || "导入成功", icon: "success" });
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.importFailed || "导入失败", icon: "none" });
}
},
async onExportJson() {
try {
const raw = await pluginRuntime.exportJson();
wx.setClipboardData({
data: raw,
success: () => {
wx.showToast({ title: this.data.copy?.toast?.exportSuccess || "插件 JSON 已复制", icon: "success" });
}
});
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.exportFailed || "导出失败", icon: "none" });
}
},
async onEnable(event) {
const id = String(event.currentTarget.dataset.id || "");
if (!id) return;
try {
await pluginRuntime.enable(id);
await this.reloadRuntime();
wx.showToast({ title: this.data.copy?.toast?.enabled || "已启用", icon: "success" });
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.enableFailed || "启用失败", icon: "none" });
}
},
async onDisable(event) {
const id = String(event.currentTarget.dataset.id || "");
if (!id) return;
try {
await pluginRuntime.disable(id);
await this.reloadRuntime();
wx.showToast({ title: this.data.copy?.toast?.disabled || "已禁用", icon: "success" });
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.disableFailed || "禁用失败", icon: "none" });
}
},
async onReload(event) {
const id = String(event.currentTarget.dataset.id || "");
if (!id) return;
try {
await pluginRuntime.reload(id);
await this.reloadRuntime();
wx.showToast({ title: this.data.copy?.toast?.reloaded || "已重载", icon: "success" });
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.reloadFailed || "重载失败", icon: "none" });
}
},
async onRemove(event) {
const id = String(event.currentTarget.dataset.id || "");
if (!id) return;
wx.showModal({
title: this.data.copy?.modal?.removeTitle || "移除插件",
content: formatTemplate(this.data.copy?.modal?.removeContent, { id }),
success: async (res) => {
if (!res.confirm) return;
try {
await pluginRuntime.remove(id);
await this.reloadRuntime();
wx.showToast({ title: this.data.copy?.toast?.removed || "已移除", icon: "success" });
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.removeFailed || "移除失败", icon: "none" });
}
}
});
},
async onRunCommand(event) {
const commandId = String(event.currentTarget.dataset.commandId || "");
if (!commandId) return;
try {
await pluginRuntime.runCommand(commandId);
wx.showToast({ title: this.data.copy?.toast?.commandExecuted || "命令已执行", icon: "success" });
} catch (error) {
wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.commandExecuteFailed || "命令执行失败", icon: "none" });
}
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "插件",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,72 @@
<view class="page-root plugins-page" style="{{themeStyle}}">
<view class="page-content plugins-content">
<view class="surface-panel plugins-panel">
<view class="card plugin-summary">
<text>{{copy.runtimeStatePrefix}}{{sessionStateLabel}}</text>
<text class="muted">{{copy.summary}}</text>
</view>
<scroll-view class="surface-scroll plugins-scroll" scroll-y="true">
<view class="list-stack plugins-sections">
<view class="card plugin-block">
<view class="item-title">{{copy.sections.pluginList}}</view>
<view wx:for="{{records}}" wx:key="id" class="plugin-record">
<view class="plugin-record-head">
<text class="plugin-record-title">{{item.id}} · {{item.status}}</text>
<text class="plugin-record-sub">errorCount: {{item.errorCount}}</text>
</view>
<text class="plugin-record-sub">{{item.lastError || '-'}}</text>
<view class="actions plugin-actions">
<button class="btn" data-id="{{item.id}}" bindtap="onEnable">{{copy.buttons.enable}}</button>
<button class="btn" data-id="{{item.id}}" bindtap="onDisable">{{copy.buttons.disable}}</button>
<button class="btn" data-id="{{item.id}}" bindtap="onReload">{{copy.buttons.reload}}</button>
<button class="btn danger" data-id="{{item.id}}" bindtap="onRemove">{{copy.buttons.remove}}</button>
</view>
</view>
<text wx:if="{{records.length === 0}}" class="empty">{{copy.empty.noPlugins}}</text>
</view>
<view class="card plugin-block">
<view class="item-title">{{copy.sections.importJson}}</view>
<textarea
class="textarea plugin-json-input"
value="{{pluginJson}}"
placeholder="{{copy.placeholder.pluginJson}}"
bindinput="onPluginJsonInput"
/>
<view class="actions">
<button class="btn" bindtap="onImportJson">{{copy.buttons.importJson}}</button>
<button class="btn" bindtap="onExportJson">{{copy.buttons.exportJson}}</button>
</view>
</view>
<view class="card plugin-block">
<view class="item-title">{{copy.sections.runCommand}}</view>
<view class="plugin-command-list">
<button
wx:for="{{commands}}"
wx:key="id"
class="btn plugin-command-btn"
data-command-id="{{item.id}}"
bindtap="onRunCommand"
>
{{item.title}}
</button>
</view>
<text wx:if="{{commands.length === 0}}" class="muted">{{copy.empty.noCommands}}</text>
</view>
<view class="card plugin-block">
<view class="item-title">{{copy.sections.runtimeLogs}}</view>
<view class="plugin-log-box">
<text wx:for="{{runtimeLogs}}" wx:key="index" class="plugin-log-line">{{item}}</text>
<text wx:if="{{runtimeLogs.length === 0}}" class="muted">{{copy.empty.noLogs}}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<bottom-nav page="plugins" />
</view>

View File

@@ -0,0 +1,95 @@
.plugins-content {
padding-top: 16rpx;
}
.plugins-panel {
padding: 0 0 16rpx;
}
.plugin-summary {
margin-bottom: 16rpx;
gap: 8rpx;
display: flex;
flex-direction: column;
}
.plugins-scroll {
flex: 1;
min-height: 0;
}
.plugins-sections {
min-height: 0;
}
.plugin-block {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.plugin-record {
border: 1rpx solid var(--surface-border);
border-radius: 16rpx;
background: var(--surface);
padding: 12rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.plugin-record-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.plugin-record-title {
font-size: 26rpx;
font-weight: 600;
}
.plugin-record-sub {
font-size: 22rpx;
color: var(--muted);
word-break: break-all;
}
.plugin-actions {
gap: 8rpx;
}
.plugin-json-input {
min-height: 220rpx;
width: 100%;
}
.plugin-command-list {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.plugin-command-btn {
flex: 0 0 auto;
}
.plugin-log-box {
border: 1rpx solid var(--surface-border);
border-radius: 16rpx;
background: var(--surface);
padding: 12rpx;
max-height: 320rpx;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.plugin-log-line {
color: var(--text);
font-size: 22rpx;
line-height: 1.4;
word-break: break-all;
}

View File

@@ -0,0 +1,868 @@
/* global Page, wx, require, clearTimeout, setTimeout */
const {
addRecord,
searchRecords,
removeRecord,
updateRecord,
getSettings,
DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK,
normalizeVoiceRecordCategories
} = require("../../utils/storage");
const { pageOf } = require("../../utils/pagination");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const { buildButtonIconThemeMaps } = require("../../utils/themedIcons");
const { getWindowMetrics } = require("../../utils/systemInfoCompat");
const { buildPageCopy, formatTemplate, normalizeUiLanguage, t } = require("../../utils/i18n");
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
const PAGE_SIZE = 15;
/**
* 左滑动作区固定为 2x2 四键:左列“废弃 / 已处理”,右列“复制 / 删除”。
* 这里提供一个总宽度兜底,实际交互仍优先读取真实节点宽度。
*/
const SWIPE_ACTION_WIDTH_RPX = 240;
const DEFAULT_WINDOW_WIDTH_PX = 375;
const SWIPE_AXIS_LOCK_THRESHOLD_PX = 6;
const RECORD_EDIT_AUTOSAVE_DELAY_MS = 400;
const QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX = 120;
const QUICK_CATEGORY_DIALOG_MAX_WIDTH_PX = 240;
const QUICK_CATEGORY_DIALOG_MIN_HEIGHT_PX = Math.round((QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX * 3) / 4);
const QUICK_CATEGORY_BUBBLE_GAP_PX = 8;
const QUICK_CATEGORY_BUBBLE_RADIUS_BASE_PX = 12;
const QUICK_CATEGORY_BUBBLE_RADIUS_STEP_PX = 7.6;
const CATEGORY_COLOR_PALETTE = [
{ background: "rgba(91, 210, 255, 0.18)", border: "rgba(91, 210, 255, 0.36)" },
{ background: "rgba(255, 143, 107, 0.18)", border: "rgba(255, 143, 107, 0.36)" },
{ background: "rgba(141, 216, 123, 0.18)", border: "rgba(141, 216, 123, 0.36)" },
{ background: "rgba(255, 191, 105, 0.18)", border: "rgba(255, 191, 105, 0.36)" },
{ background: "rgba(199, 146, 234, 0.18)", border: "rgba(199, 146, 234, 0.36)" },
{ background: "rgba(255, 111, 145, 0.18)", border: "rgba(255, 111, 145, 0.36)" },
{ background: "rgba(99, 210, 255, 0.18)", border: "rgba(99, 210, 255, 0.36)" },
{ background: "rgba(78, 205, 196, 0.18)", border: "rgba(78, 205, 196, 0.36)" },
{ background: "rgba(255, 209, 102, 0.18)", border: "rgba(255, 209, 102, 0.36)" },
{ background: "rgba(144, 190, 109, 0.18)", border: "rgba(144, 190, 109, 0.36)" }
];
function resolveRecordCategories(settings) {
const source = settings && typeof settings === "object" ? settings : {};
const categories = normalizeVoiceRecordCategories(source.voiceRecordCategories);
return categories.length > 0 ? categories : [DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK];
}
function resolveVisibleCategory(rawCategory, categories) {
const normalized = String(rawCategory || "").trim();
if (normalized && categories.includes(normalized)) return normalized;
return DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK;
}
function resolveDefaultCategory(settings, categories) {
const source = settings && typeof settings === "object" ? settings : {};
const options = Array.isArray(categories) && categories.length > 0 ? categories : resolveRecordCategories(source);
const normalized = String(source.voiceRecordDefaultCategory || "").trim();
if (normalized && options.includes(normalized)) return normalized;
return options[0] || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK;
}
/**
* 分类色按当前分类顺序稳定分配,优先保证当前页面里的分类彼此不同。
* 历史脏值已在外层先回退到可见分类,这里直接按展示分类映射即可。
*/
function buildCategoryStyle(category, orderedCategories) {
const label =
String(category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() ||
DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK;
const categories = Array.isArray(orderedCategories) ? orderedCategories : [];
const paletteIndex = Math.max(0, categories.indexOf(label));
const palette =
CATEGORY_COLOR_PALETTE[paletteIndex % CATEGORY_COLOR_PALETTE.length] || CATEGORY_COLOR_PALETTE[0];
return `background:${palette.background};border-color:${palette.border};`;
}
function clampNumber(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function resolveQuickCategoryBubbleSize(category) {
const length = Array.from(String(category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK)).length;
return Math.min(52, Math.max(34, 24 + length * 7));
}
function intersectsPlacedBubble(left, top, size, placedBubbles) {
const centerX = left + size / 2;
const centerY = top + size / 2;
return placedBubbles.some((bubble) => {
const otherCenterX = bubble.left + bubble.size / 2;
const otherCenterY = bubble.top + bubble.size / 2;
const minDistance = size / 2 + bubble.size / 2 + QUICK_CATEGORY_BUBBLE_GAP_PX;
return Math.hypot(centerX - otherCenterX, centerY - otherCenterY) < minDistance;
});
}
function buildBubbleCloudForBox(orderedCategories, currentCategory, width, height, colorCategories) {
const centerX = width / 2;
const centerY = height / 2;
const placedBubbles = [];
let collisionCount = 0;
const items = orderedCategories.map((category, index) => {
const size = resolveQuickCategoryBubbleSize(category);
if (index === 0) {
const left = Math.max(0, Math.min(width - size, centerX - size / 2));
const top = Math.max(0, Math.min(height - size, centerY - size / 2));
placedBubbles.push({ left, top, size });
return {
active: category === currentCategory,
category,
style: `width:${size}px;height:${size}px;left:${left}px;top:${top}px;`,
categoryStyle: buildCategoryStyle(category, colorCategories)
};
}
let fallbackLeft = Math.max(0, Math.min(width - size, centerX - size / 2));
let fallbackTop = Math.max(0, Math.min(height - size, centerY - size / 2));
let placed = false;
for (let step = 1; step <= 320; step += 1) {
const angle = step * 0.72;
const radius =
QUICK_CATEGORY_BUBBLE_RADIUS_BASE_PX + Math.sqrt(step) * QUICK_CATEGORY_BUBBLE_RADIUS_STEP_PX;
const candidateLeft = centerX + Math.cos(angle) * radius - size / 2;
const candidateTop = centerY + Math.sin(angle) * radius * 1.08 - size / 2;
const isWithinWidth = candidateLeft >= 0 && candidateLeft + size <= width;
const isWithinHeight = candidateTop >= 0 && candidateTop + size <= height;
if (!isWithinWidth || !isWithinHeight) continue;
if (intersectsPlacedBubble(candidateLeft, candidateTop, size, placedBubbles)) continue;
fallbackLeft = candidateLeft;
fallbackTop = candidateTop;
placed = true;
break;
}
if (!placed) collisionCount += 1;
placedBubbles.push({ left: fallbackLeft, top: fallbackTop, size });
return {
active: category === currentCategory,
category,
style: `width:${size}px;height:${size}px;left:${fallbackLeft}px;top:${fallbackTop}px;`,
categoryStyle: buildCategoryStyle(category, colorCategories)
};
});
return { collisionCount, items };
}
function buildQuickCategoryLayout(categories, currentCategory, maxWidthPx) {
const colorCategories = categories.slice();
const orderedCategories = categories.slice().sort((left, right) => {
if (left === currentCategory) return -1;
if (right === currentCategory) return 1;
return categories.indexOf(left) - categories.indexOf(right);
});
const longestLength = orderedCategories.reduce(
(max, category) => Math.max(max, Array.from(String(category || "")).length),
0
);
const estimatedWidth =
QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX +
Math.max(0, orderedCategories.length - 3) * 22 +
Math.max(0, longestLength - 2) * 8;
const maxWidth = clampNumber(
maxWidthPx || QUICK_CATEGORY_DIALOG_MAX_WIDTH_PX,
QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX,
QUICK_CATEGORY_DIALOG_MAX_WIDTH_PX
);
let bestWidth = clampNumber(estimatedWidth, QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX, maxWidth);
let bestHeight = Math.max(QUICK_CATEGORY_DIALOG_MIN_HEIGHT_PX, Math.round((bestWidth * 3) / 4));
let bestLayout = buildBubbleCloudForBox(
orderedCategories,
currentCategory,
bestWidth,
bestHeight,
colorCategories
);
for (let width = QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX; width <= maxWidth; width += 20) {
const height = Math.max(QUICK_CATEGORY_DIALOG_MIN_HEIGHT_PX, Math.round((width * 3) / 4));
const candidateLayout = buildBubbleCloudForBox(
orderedCategories,
currentCategory,
width,
height,
colorCategories
);
if (candidateLayout.collisionCount < bestLayout.collisionCount) {
bestWidth = width;
bestHeight = height;
bestLayout = candidateLayout;
}
if (candidateLayout.collisionCount === 0 && width >= bestWidth) {
bestWidth = width;
bestHeight = height;
bestLayout = candidateLayout;
break;
}
}
return {
width: bestWidth,
height: bestHeight,
items: bestLayout.items
};
}
/**
* 闪念页(小程序对齐 Web v2.2.0
* 1. 顶部提供搜索和分类过滤;
* 2. 支持左滑废弃 / 已处理 / 复制 / 删除、点击正文编辑、点击分类快速改分类;
* 3. 过滤后分页,底部分页与新增 / 导出分区展示。
*/
Page({
data: {
...buildSvgButtonPressData(),
themeStyle: "",
icons: {},
accentIcons: {},
copy: buildPageCopy("zh-Hans", "records"),
page: 1,
total: 0,
totalPages: 1,
rows: [],
query: "",
selectedCategory: "",
categoryOptions: [],
categoryMenuVisible: false,
quickCategoryPopupVisible: false,
quickCategoryRecordId: "",
quickCategoryItems: [],
quickCategoryWidthPx: QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX,
quickCategoryHeightPx: QUICK_CATEGORY_DIALOG_MIN_HEIGHT_PX,
quickCategoryPanelStyle: "",
editPopupVisible: false,
editRecordId: "",
editContent: "",
editCategory: "",
editUpdatedAtText: "",
editUpdatedAtLabel: "",
pageIndicatorText: t("zh-Hans", "common.pageIndicator", { page: 1, total: 1 })
},
swipeRuntime: null,
editAutoSaveTimer: null,
onShow() {
this.syncWindowMetrics();
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const copy = buildPageCopy(language, "records");
const { icons, accentIcons } = buildButtonIconThemeMaps(settings);
applyNavigationBarTheme(settings);
wx.setNavigationBarTitle({ title: copy.navTitle || "闪念" });
this.setData({
themeStyle: buildThemeStyle(settings),
icons,
accentIcons,
copy
});
this.reload();
},
onHide() {
this.flushEditAutoSave();
},
onUnload() {
this.flushEditAutoSave();
},
syncWindowMetrics() {
try {
const info = getWindowMetrics(wx);
const width = Number(info.windowWidth);
const height = Number(info.windowHeight);
this.windowWidthPx = Number.isFinite(width) && width > 0 ? width : DEFAULT_WINDOW_WIDTH_PX;
this.windowHeightPx = Number.isFinite(height) && height > 0 ? height : 667;
} catch {
this.windowWidthPx = DEFAULT_WINDOW_WIDTH_PX;
this.windowHeightPx = 667;
}
},
buildQuickCategoryPanelStyle(anchorRect, layout) {
const rect = anchorRect && typeof anchorRect === "object" ? anchorRect : {};
const width = Number(layout && layout.width) || QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX;
const height = Number(layout && layout.height) || QUICK_CATEGORY_DIALOG_MIN_HEIGHT_PX;
const windowWidth = Number(this.windowWidthPx) || DEFAULT_WINDOW_WIDTH_PX;
const windowHeight = Number(this.windowHeightPx) || 667;
const panelPadding = 12;
const gap = 8;
const buttonLeft = Number(rect.left) || 0;
const buttonTop = Number(rect.top) || 0;
const buttonWidth = Number(rect.width) || 0;
const buttonHeight = Number(rect.height) || 0;
let left = buttonLeft + buttonWidth + gap;
if (left + width + panelPadding > windowWidth) {
left = buttonLeft - width - gap;
}
left = clampNumber(left, panelPadding, Math.max(panelPadding, windowWidth - width - panelPadding));
const buttonCenterY = buttonTop + buttonHeight / 2;
let top = buttonCenterY - height / 2 - panelPadding;
top = clampNumber(top, panelPadding, Math.max(panelPadding, windowHeight - height - panelPadding));
return `left:${left}px;top:${top}px;`;
},
getSwipeRevealPx() {
if (Number.isFinite(this.swipeRevealPx) && this.swipeRevealPx > 0) {
return this.swipeRevealPx;
}
const windowWidth = Number(this.windowWidthPx) || DEFAULT_WINDOW_WIDTH_PX;
const actionWidthPx = Math.round((windowWidth * SWIPE_ACTION_WIDTH_RPX) / 750);
return Math.max(24, actionWidthPx);
},
syncSwipeRevealWidth() {
const query = this.createSelectorQuery();
query
.select(".record-item-actions-wrap")
.boundingClientRect((rect) => {
const width = Number(rect && rect.width);
if (!Number.isFinite(width) || width <= 0) return;
this.swipeRevealPx = Math.max(24, Math.round(width));
})
.exec();
},
/**
* 先按关键字过滤,再用当前有效分类做展示过滤,保持与 web 的语义一致。
*/
reload() {
const settings = getSettings();
const { icons, accentIcons } = buildButtonIconThemeMaps(settings);
const categoryOptions = resolveRecordCategories(settings);
const selectedCategory = categoryOptions.includes(this.data.selectedCategory)
? this.data.selectedCategory
: "";
const keyword = String(this.data.query || "").trim();
const filtered = searchRecords({ keyword }).filter(
(item) =>
!selectedCategory || resolveVisibleCategory(item.category, categoryOptions) === selectedCategory
);
const paged = pageOf(filtered, this.data.page, PAGE_SIZE);
const language = normalizeUiLanguage(settings.uiLanguage);
const nextOffsets = {};
const rows = paged.rows.map((item) => {
const displayCategory = resolveVisibleCategory(item.category, categoryOptions);
const swipeOffsetX =
this.swipeOffsets && Number(this.swipeOffsets[item.id]) ? this.swipeOffsets[item.id] : 0;
nextOffsets[item.id] = swipeOffsetX;
return {
...item,
processed: item.processed === true,
discarded: item.discarded === true,
isMuted: false,
displayCategory,
categoryStyle: buildCategoryStyle(displayCategory, categoryOptions),
timeText: this.formatTime(item.createdAt),
updatedAtText: this.formatTime(item.updatedAt),
contextLabelText: item.contextLabel || this.data.copy.contextFallback || "未设置上下文",
swipeOffsetX
};
});
this.swipeOffsets = nextOffsets;
this.swipeRuntime = null;
this.setData(
{
themeStyle: buildThemeStyle(settings),
icons,
accentIcons,
categoryOptions,
selectedCategory,
categoryMenuVisible: false,
page: paged.page,
total: paged.total,
totalPages: paged.totalPages,
rows,
pageIndicatorText: t(language, "common.pageIndicator", {
page: paged.page,
total: paged.totalPages
})
},
() => this.syncSwipeRevealWidth()
);
},
formatTime(input) {
if (!input) return "--";
const date = new Date(input);
if (Number.isNaN(+date)) return "--";
const pad = (value) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
},
onQueryInput(event) {
this.setData({ query: String(event.detail.value || ""), page: 1 }, () => this.reload());
},
onToggleCategoryMenu() {
this.closeAllRows();
this.closeQuickCategoryPopup();
this.setData({ categoryMenuVisible: !this.data.categoryMenuVisible });
},
onSelectFilterCategory(event) {
const category = String(event.currentTarget.dataset.category || "");
this.setData({ selectedCategory: category, categoryMenuVisible: false, page: 1 }, () => this.reload());
},
onPrev() {
this.setData({ page: Math.max(1, this.data.page - 1) }, () => this.reload());
},
onNext() {
this.setData({ page: Math.min(this.data.totalPages, this.data.page + 1) }, () => this.reload());
},
/**
* 新增入口复用现有编辑弹层:
* 1. 先以空草稿和默认分类打开;
* 2. 首次输入非空内容后自动创建记录;
* 3. 后续继续沿用原有编辑自动保存链路。
*/
onOpenCreate() {
const settings = getSettings();
const categoryOptions = this.data.categoryOptions.length
? this.data.categoryOptions.slice()
: resolveRecordCategories(settings);
this.clearEditAutoSaveTimer();
this.closeAllRows();
this.closeQuickCategoryPopup();
this.setData({
categoryMenuVisible: false,
editPopupVisible: true,
editRecordId: "",
editContent: "",
editCategory: resolveDefaultCategory(settings, categoryOptions),
editUpdatedAtText: "",
editUpdatedAtLabel: this.data.copy.newRecordHint || "输入后自动保存为新闪念"
});
},
resolveTouchPoint(event) {
const point =
(event && event.touches && event.touches[0]) ||
(event && event.changedTouches && event.changedTouches[0]) ||
null;
if (!point) return null;
const x = Number(point.clientX);
const y = Number(point.clientY);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
},
clampSwipeOffset(value) {
const revealPx = this.getSwipeRevealPx();
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
if (numeric < -revealPx) return -revealPx;
if (numeric > 0) return 0;
return numeric;
},
findRowIndexById(id) {
return this.data.rows.findIndex((item) => item.id === id);
},
updateRowSwipeOffset(id, offset) {
const index = this.findRowIndexById(id);
if (index < 0) return;
const normalized = this.clampSwipeOffset(offset);
this.swipeOffsets[id] = normalized;
this.setData({ [`rows[${index}].swipeOffsetX`]: normalized });
},
closeOtherRows(exceptId) {
const updates = {};
this.data.rows.forEach((item, index) => {
if (item.id === exceptId) return;
if (item.swipeOffsetX === 0) return;
this.swipeOffsets[item.id] = 0;
updates[`rows[${index}].swipeOffsetX`] = 0;
});
if (Object.keys(updates).length > 0) this.setData(updates);
},
closeAllRows() {
const updates = {};
this.data.rows.forEach((item, index) => {
if (item.swipeOffsetX === 0) return;
this.swipeOffsets[item.id] = 0;
updates[`rows[${index}].swipeOffsetX`] = 0;
});
if (Object.keys(updates).length > 0) this.setData(updates);
},
onListTap() {
this.closeAllRows();
if (this.data.categoryMenuVisible) {
this.setData({ categoryMenuVisible: false });
}
this.closeQuickCategoryPopup();
},
onRecordTouchStart(event) {
const id = event.currentTarget.dataset.id;
if (!id) return;
const point = this.resolveTouchPoint(event);
if (!point) return;
this.closeOtherRows(id);
this.swipeRuntime = {
id,
startX: point.x,
startY: point.y,
startOffsetX: this.clampSwipeOffset((this.swipeOffsets && this.swipeOffsets[id]) || 0),
dragging: false,
blocked: false
};
},
onRecordTouchMove(event) {
const runtime = this.swipeRuntime;
if (!runtime || runtime.blocked) return;
const id = event.currentTarget.dataset.id;
if (!id || id !== runtime.id) return;
const point = this.resolveTouchPoint(event);
if (!point) return;
const deltaX = point.x - runtime.startX;
const deltaY = point.y - runtime.startY;
if (!runtime.dragging) {
if (
Math.abs(deltaX) < SWIPE_AXIS_LOCK_THRESHOLD_PX &&
Math.abs(deltaY) < SWIPE_AXIS_LOCK_THRESHOLD_PX
) {
return;
}
if (Math.abs(deltaY) > Math.abs(deltaX)) {
runtime.blocked = true;
return;
}
runtime.dragging = true;
}
this.updateRowSwipeOffset(id, runtime.startOffsetX + deltaX);
},
onRecordTouchEnd(event) {
const runtime = this.swipeRuntime;
this.swipeRuntime = null;
if (!runtime || runtime.blocked) return;
const id = event.currentTarget.dataset.id;
if (!id || id !== runtime.id) return;
const revealPx = this.getSwipeRevealPx();
const current = this.clampSwipeOffset((this.swipeOffsets && this.swipeOffsets[id]) || 0);
const shouldOpen = current <= -revealPx * 0.45;
this.updateRowSwipeOffset(id, shouldOpen ? -revealPx : 0);
},
onDelete(event) {
const id = String(event.currentTarget.dataset.id || "");
if (!id) return;
removeRecord(id);
if (this.swipeOffsets) delete this.swipeOffsets[id];
wx.showToast({ title: this.data.copy?.toast?.deleted || "已删除", icon: "success" });
this.reload();
},
onCopy(event) {
const id = String(event.currentTarget.dataset.id || "");
const record = this.data.rows.find((item) => item.id === id);
if (!record) return;
wx.setClipboardData({
data: String(record.content || ""),
success: () => {
this.closeAllRows();
wx.showToast({ title: this.data.copy?.toast?.copied || "已复制", icon: "success" });
}
});
},
/**
* “已处理”是一次性整理动作:
* 1. 仅把当前闪念标记为已处理;
* 2. 已处理状态继续允许编辑,但列表会整体降到 60% opacity
* 3. 重复点击保持幂等,只回显同一条成功提示。
*/
onMarkProcessed(event) {
const id = String(event.currentTarget.dataset.id || "");
const record = this.data.rows.find((item) => item.id === id);
if (!record) return;
if (record.processed && !record.discarded) {
this.closeAllRows();
wx.showToast({ title: this.data.copy?.toast?.processed || "已处理", icon: "success" });
return;
}
const updated = updateRecord({
id: record.id,
content: record.content,
category: record.category || record.displayCategory,
processed: true,
discarded: false
});
if (!updated) {
wx.showToast({ title: this.data.copy?.toast?.updateFailed || "更新失败", icon: "none" });
return;
}
this.closeAllRows();
wx.showToast({ title: this.data.copy?.toast?.processed || "已处理", icon: "success" });
this.reload();
},
/**
* “废弃”与“已处理”同属终态:
* 1. 写入废弃后会自动取消已处理,保证两种状态互斥;
* 2. 当前版本仅按需求收口按钮与状态,不额外改变正文内容;
* 3. 列表视觉同样按 60% opacity 弱化,便于后续统一筛看。
*/
onMarkDiscarded(event) {
const id = String(event.currentTarget.dataset.id || "");
const record = this.data.rows.find((item) => item.id === id);
if (!record) return;
if (record.discarded && !record.processed) {
this.closeAllRows();
wx.showToast({ title: this.data.copy?.toast?.discarded || "已废弃", icon: "success" });
return;
}
const updated = updateRecord({
id: record.id,
content: record.content,
category: record.category || record.displayCategory,
processed: false,
discarded: true
});
if (!updated) {
wx.showToast({ title: this.data.copy?.toast?.updateFailed || "更新失败", icon: "none" });
return;
}
this.closeAllRows();
wx.showToast({ title: this.data.copy?.toast?.discarded || "已废弃", icon: "success" });
this.reload();
},
/**
* 点击左侧分类胶囊,改为页内弹层气泡云,避免原生 action sheet 破坏当前页面语义。
*/
onQuickCategoryTap(event) {
const recordId = String(event.currentTarget.dataset.id || "");
const record = this.data.rows.find((item) => item.id === recordId);
if (!record) return;
const categories = this.data.categoryOptions.slice();
if (categories.length === 0) return;
const maxWidth = Math.max(
QUICK_CATEGORY_DIALOG_MIN_WIDTH_PX,
Math.min(
QUICK_CATEGORY_DIALOG_MAX_WIDTH_PX,
(Number(this.windowWidthPx) || DEFAULT_WINDOW_WIDTH_PX) - 48
)
);
const layout = buildQuickCategoryLayout(categories, record.displayCategory, maxWidth);
const anchorId = `#quick-category-${record.id}`;
const query = this.createSelectorQuery();
query.select(anchorId).boundingClientRect((rect) => {
const panelStyle = this.buildQuickCategoryPanelStyle(rect, layout);
this.closeAllRows();
this.setData({
quickCategoryPopupVisible: true,
quickCategoryRecordId: record.id,
quickCategoryItems: layout.items,
quickCategoryWidthPx: layout.width,
quickCategoryHeightPx: layout.height,
quickCategoryPanelStyle: panelStyle
});
});
query.exec();
},
closeQuickCategoryPopup() {
if (!this.data.quickCategoryPopupVisible) return;
this.setData({
quickCategoryPopupVisible: false,
quickCategoryRecordId: "",
quickCategoryItems: [],
quickCategoryPanelStyle: ""
});
},
onApplyQuickCategory(event) {
const recordId = String(this.data.quickCategoryRecordId || "");
const record = this.data.rows.find((item) => item.id === recordId);
const nextCategory = String(event.currentTarget.dataset.category || "");
if (!record || !nextCategory || nextCategory === record.displayCategory) {
this.closeQuickCategoryPopup();
return;
}
const updated = updateRecord({
id: record.id,
content: record.content,
category: nextCategory
});
if (!updated) {
wx.showToast({ title: this.data.copy?.toast?.updateFailed || "更新失败", icon: "none" });
return;
}
wx.showToast({ title: this.data.copy?.toast?.categoryUpdated || "分类已更新", icon: "success" });
this.closeQuickCategoryPopup();
this.reload();
},
onOpenEdit(event) {
const recordId = String(event.currentTarget.dataset.id || "");
const record = this.data.rows.find((item) => item.id === recordId);
if (!record) return;
if (Number(record.swipeOffsetX) < 0) {
this.updateRowSwipeOffset(recordId, 0);
return;
}
this.clearEditAutoSaveTimer();
this.setData({
editPopupVisible: true,
editRecordId: record.id,
editContent: record.content || "",
editCategory: record.displayCategory,
editUpdatedAtText: record.updatedAtText || this.formatTime(record.updatedAt),
editUpdatedAtLabel: formatTemplate(this.data.copy.updatedAtPrefix, {
time: record.updatedAtText || this.formatTime(record.updatedAt)
})
});
},
resetEditState() {
this.clearEditAutoSaveTimer();
this.setData({
editPopupVisible: false,
editRecordId: "",
editContent: "",
editCategory: "",
editUpdatedAtText: "",
editUpdatedAtLabel: ""
});
},
onCloseEdit() {
this.flushEditAutoSave();
this.resetEditState();
},
onEditContentInput(event) {
this.setData({ editContent: String(event.detail.value || "") }, () => this.scheduleEditAutoSave());
},
onEditCategoryTap(event) {
const category = String(event.currentTarget.dataset.category || "");
if (!category) return;
this.setData({ editCategory: category }, () => this.scheduleEditAutoSave());
},
/**
* 编辑弹窗改为自动保存:
* 1. 内容或分类变化后走 400ms 防抖;
* 2. 关闭弹窗、页面隐藏、页面卸载时立即 flush
* 3. 空内容继续不写回存储,避免误清空历史闪念。
*/
persistEditDraft() {
const recordId = String(this.data.editRecordId || "").trim();
const content = String(this.data.editContent || "").trim();
const category = String(this.data.editCategory || "").trim();
if (!content) return null;
if (!recordId) {
const created = addRecord(content, "", {
category: category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK
});
if (!created) return null;
const updatedAtText = this.formatTime(created.updatedAt);
this.reload();
this.setData({
editRecordId: created.id,
editContent: created.content,
editCategory: created.category,
editUpdatedAtText: updatedAtText,
editUpdatedAtLabel: formatTemplate(this.data.copy.updatedAtPrefix, { time: updatedAtText })
});
return created;
}
const currentRecord = searchRecords({ keyword: "" }).find((item) => item.id === recordId);
const normalizedCategory = category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK;
if (
currentRecord &&
String(currentRecord.content || "").trim() === content &&
String(currentRecord.category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() === normalizedCategory
) {
return currentRecord;
}
const updated = updateRecord({
id: recordId,
content,
category: normalizedCategory
});
if (!updated) return null;
this.reload();
const updatedAtText = this.formatTime(updated.updatedAt);
this.setData({
editUpdatedAtText: updatedAtText,
editUpdatedAtLabel: formatTemplate(this.data.copy.updatedAtPrefix, { time: updatedAtText })
});
return updated;
},
clearEditAutoSaveTimer() {
if (!this.editAutoSaveTimer) return;
clearTimeout(this.editAutoSaveTimer);
this.editAutoSaveTimer = null;
},
scheduleEditAutoSave() {
if (!this.data.editPopupVisible) return;
this.clearEditAutoSaveTimer();
this.editAutoSaveTimer = setTimeout(() => {
this.editAutoSaveTimer = null;
this.persistEditDraft();
}, RECORD_EDIT_AUTOSAVE_DELAY_MS);
},
flushEditAutoSave() {
this.clearEditAutoSaveTimer();
this.persistEditDraft();
},
onExport() {
const settings = getSettings();
const categoryOptions = resolveRecordCategories(settings);
const selectedCategory = categoryOptions.includes(this.data.selectedCategory)
? this.data.selectedCategory
: "";
const keyword = String(this.data.query || "").trim();
const rows = searchRecords({ keyword }).filter(
(item) =>
!selectedCategory || resolveVisibleCategory(item.category, categoryOptions) === selectedCategory
);
const content = rows
.map((item) => {
const displayCategory = resolveVisibleCategory(item.category, categoryOptions);
const exportFields = this.data.copy.exportFields || {};
return [
`${exportFields.createdAt || "创建时间"}${this.formatTime(item.createdAt)}`,
`${exportFields.updatedAt || "更新时间"}${this.formatTime(item.updatedAt)}`,
`${exportFields.server || "服务器"}${item.serverId || "-"}`,
`${exportFields.category || "分类"}${displayCategory}`,
`${exportFields.context || "上下文"}${item.contextLabel || "-"}`,
String(item.content || "")
].join("\n");
})
.join("\n\n");
wx.setClipboardData({
data: content || "",
success: () => wx.showToast({ title: this.data.copy?.toast?.exported || "闪念已复制", icon: "success" })
});
},
noop() {},
...createSvgButtonPressMethods()
});

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "闪念",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,194 @@
<view class="page-root records-page" style="{{themeStyle}}">
<view class="page-content">
<view class="surface-panel records-panel">
<view class="records-search-wrap">
<view class="records-search-shell">
<input
class="records-search-input"
type="text"
placeholder="{{copy.searchPlaceholder}}"
placeholder-class="records-search-input-placeholder"
value="{{query}}"
bindinput="onQueryInput"
/>
<button class="records-filter-btn" bindtap="onToggleCategoryMenu">
<text class="records-filter-arrow">{{categoryMenuVisible ? '▲' : '▼'}}</text>
</button>
</view>
<view wx:if="{{categoryMenuVisible}}" class="records-filter-menu">
<view
class="records-filter-option {{selectedCategory === '' ? 'active' : ''}}"
data-category=""
bindtap="onSelectFilterCategory"
>{{copy.allCategories}}</view
>
<view
wx:for="{{categoryOptions}}"
wx:key="*this"
class="records-filter-option {{selectedCategory === item ? 'active' : ''}}"
data-category="{{item}}"
bindtap="onSelectFilterCategory"
>{{item}}</view
>
</view>
</view>
<scroll-view class="surface-scroll records-list-scroll" scroll-y="true">
<view class="list-stack records-list" bindtap="onListTap">
<view
wx:for="{{rows}}"
wx:key="id"
class="record-item-shell {{item.isMuted ? 'record-item-shell-muted' : ''}}"
data-id="{{item.id}}"
bindtouchstart="onRecordTouchStart"
bindtouchmove="onRecordTouchMove"
bindtouchend="onRecordTouchEnd"
bindtouchcancel="onRecordTouchEnd"
>
<view class="record-item-actions-wrap {{(item.swipeOffsetX || 0) < 0 ? 'opened' : ''}}">
<button class="record-swipe-copy-btn" data-id="{{item.id}}" catchtap="onCopy">
<text class="record-swipe-btn-text">{{copy.swipeCopy}}</text>
</button>
<button class="record-swipe-processed-btn" data-id="{{item.id}}" catchtap="onMarkProcessed">
<text class="record-swipe-btn-text">{{copy.swipeProcessed}}</text>
</button>
<button class="record-swipe-discarded-btn" data-id="{{item.id}}" catchtap="onMarkDiscarded">
<text class="record-swipe-btn-text">{{copy.swipeDiscarded}}</text>
</button>
<button class="record-swipe-delete-btn" data-id="{{item.id}}" catchtap="onDelete">
<text class="record-swipe-btn-text">{{copy.swipeDelete}}</text>
</button>
</view>
<view class="record-item-track" style="transform: translateX({{item.swipeOffsetX || 0}}px);">
<view class="record-item-main">
<view class="record-item-category-hitbox" data-id="{{item.id}}" catchtap="onQuickCategoryTap">
<view
id="quick-category-{{item.id}}"
class="record-item-category"
style="{{item.categoryStyle}}"
>
<text class="record-item-category-text">{{item.displayCategory}}</text>
</view>
</view>
<view
class="card record-item {{item.processed ? 'record-item-processed' : ''}}"
data-id="{{item.id}}"
bindtap="onOpenEdit"
>
<view class="record-item-header">
<text class="record-item-time">{{item.timeText}}</text>
<text class="record-item-context">{{item.contextLabelText}}</text>
</view>
<text class="record-item-content {{item.discarded ? 'record-item-content-discarded' : ''}}">{{item.content || '--'}}</text>
</view>
</view>
</view>
</view>
<text wx:if="{{rows.length === 0}}" class="empty">{{copy.empty}}</text>
</view>
</scroll-view>
<view class="records-footer">
<view class="records-pagination">
<button class="btn" disabled="{{page <= 1}}" bindtap="onPrev">{{copy.prev}}</button>
<text class="records-pagination-text">{{pageIndicatorText}}</text>
<button class="btn" disabled="{{page >= totalPages}}" bindtap="onNext">{{copy.next}}</button>
</view>
<view class="records-footer-actions">
<button
class="btn records-footer-action-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="records:add"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onOpenCreate"
>
<image
class="records-footer-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'records:add' ? (accentIcons.add || icons.add || '/assets/icons/add.svg') : (icons.add || '/assets/icons/add.svg')}}"
mode="aspectFit"
/>
<text>{{copy.addButton}}</text>
</button>
<button class="btn" bindtap="onExport">{{copy.exportButton}}</button>
</view>
</view>
</view>
</view>
<view wx:if="{{quickCategoryPopupVisible}}" class="records-quick-mask" bindtap="closeQuickCategoryPopup">
<view class="records-quick-panel" style="{{quickCategoryPanelStyle}}" catchtap="noop">
<view
class="records-quick-cloud"
style="width: {{quickCategoryWidthPx}}px; height: {{quickCategoryHeightPx}}px;"
>
<button
wx:for="{{quickCategoryItems}}"
wx:key="category"
class="records-quick-bubble {{item.active ? 'active' : ''}}"
style="{{item.style}} {{item.categoryStyle}}"
data-category="{{item.category}}"
bindtap="onApplyQuickCategory"
>
{{item.category}}
</button>
</view>
</view>
</view>
<view wx:if="{{editPopupVisible}}" class="records-edit-mask" bindtap="onCloseEdit">
<view class="records-edit-panel" catchtap="noop">
<scroll-view class="records-edit-category-scroll" scroll-x="true" show-scrollbar="false">
<view class="records-edit-category-row">
<view
wx:for="{{categoryOptions}}"
wx:key="*this"
class="records-edit-category-pill {{editCategory === item ? 'active' : ''}}"
data-category="{{item}}"
bindtap="onEditCategoryTap"
>{{item}}</view
>
</view>
</scroll-view>
<textarea
class="records-edit-textarea"
value="{{editContent}}"
maxlength="-1"
auto-height
placeholder="{{copy.editPlaceholder}}"
bindinput="onEditContentInput"
/>
<text class="records-edit-time">{{editUpdatedAtLabel}}</text>
<button
class="records-edit-close-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="records:close-edit"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onCloseEdit"
aria-label="{{copy.closeEditAriaLabel}}"
>
<image
class="records-edit-close-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'records:close-edit' ? (accentIcons.cancel || icons.cancel || '/assets/icons/cancel.svg') : (icons.cancel || '/assets/icons/cancel.svg')}}"
mode="aspectFit"
/>
</button>
</view>
</view>
<bottom-nav page="records" />
</view>

View File

@@ -0,0 +1,498 @@
.records-page .page-content {
padding-top: 16rpx;
}
.records-panel {
padding: 0 0 16rpx;
gap: 16rpx;
}
.records-search-wrap {
position: relative;
z-index: 3;
}
.records-search-shell {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
height: 64rpx;
border: 1rpx solid var(--surface-border);
border-radius: 54rpx;
overflow: hidden;
background: var(--surface);
}
.records-search-input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text);
font-size: 22rpx;
line-height: normal;
padding: 0 16rpx;
}
.records-search-input-placeholder {
color: var(--muted);
}
.records-filter-btn {
flex: 0 0 88rpx;
width: 88rpx !important;
min-width: 88rpx !important;
height: 100% !important;
margin: 0 !important;
border: 0 !important;
border-left: 1rpx solid var(--surface-border);
border-radius: 0 !important;
background: var(--bg) !important;
color: var(--text) !important;
padding: 0 16rpx !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.records-filter-arrow {
flex: 0 0 auto;
font-size: 22rpx;
line-height: 1;
}
.records-filter-menu {
margin-top: 12rpx;
padding: 10rpx;
border: 1rpx solid var(--surface-border);
border-radius: 18rpx;
background: var(--surface);
display: flex;
flex-wrap: wrap;
gap: 10rpx;
box-shadow: 0 14rpx 30rpx var(--surface-shadow);
}
.records-filter-option {
padding: 10rpx 18rpx;
border-radius: 999rpx;
border: 1rpx solid var(--surface-border);
background: var(--bg);
color: var(--muted);
font-size: 22rpx;
line-height: 1.1;
}
.records-filter-option.active {
border-color: rgba(91, 210, 255, 0.78);
background: rgba(91, 210, 255, 0.18);
color: var(--text);
font-weight: 700;
}
.records-list-scroll {
flex: 1;
min-height: 0;
}
.records-list {
min-height: 0;
padding-bottom: 8rpx;
}
.record-item-shell {
position: relative;
overflow: hidden;
border-radius: 20rpx;
transition: opacity 160ms ease;
}
.record-item-shell-muted {
opacity: 0.6;
}
.record-item-actions-wrap {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 240rpx;
display: flex;
flex-wrap: nowrap;
gap: 0;
z-index: 0;
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
}
.record-item-actions-wrap.opened {
opacity: 1;
pointer-events: auto;
}
.record-swipe-copy-btn,
.record-swipe-delete-btn,
.record-swipe-processed-btn,
.record-swipe-discarded-btn {
width: 25% !important;
flex: 1 1 0;
height: 100% !important;
min-width: 0 !important;
margin: 0 !important;
border: 0 !important;
border-radius: 0 !important;
color: #f7fbff !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
font-size: 26rpx !important;
line-height: 1 !important;
padding: 0 !important;
}
.record-swipe-copy-btn {
border-radius: 20rpx 0 0 20rpx !important;
background: rgba(101, 130, 149, 0.8) !important;
}
.record-swipe-delete-btn {
border-radius: 0 20rpx 20rpx 0 !important;
background: rgba(164, 118, 118, 0.8) !important;
}
.record-swipe-processed-btn {
border-radius: 0 !important;
background: rgba(124, 145, 114, 0.8) !important;
}
.record-swipe-discarded-btn {
border-radius: 0 !important;
background: rgba(118, 124, 136, 0.8) !important;
}
.record-swipe-btn-text {
/* 左滑动作区维持竖排文案,适配四个窄按钮且不引入额外 gap。 */
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 2rpx;
font-size: 22rpx;
line-height: 1.05;
font-weight: 600;
}
.record-item-track {
position: relative;
z-index: 1;
transition: transform 160ms ease;
will-change: transform;
}
.record-item-main {
display: flex;
align-items: stretch;
gap: 2rpx;
}
.record-item-category-hitbox {
/* 分类条视觉宽度不变,只把横向热区扩到约 2 倍。 */
flex: 0 0 auto;
display: inline-flex;
align-items: stretch;
padding: 0 24rpx;
margin: 0 -24rpx;
position: relative;
z-index: 1;
}
.record-item-category {
flex: 0 0 auto;
width: 48rpx;
min-width: 48rpx;
border: 1rpx solid transparent;
border-radius: 18rpx 0 0 18rpx;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 10rpx 6rpx;
}
.record-item-category-text {
color: var(--text);
font-size: 20rpx;
line-height: 1.1;
text-align: center;
writing-mode: vertical-rl;
text-orientation: mixed;
}
.record-item {
flex: 1;
min-width: 0;
border-radius: 0 18rpx 18rpx 0;
}
.record-item-processed {
/* 已处理态改成更亮的浅绿底,拉开与深绿正文的反差,避免在移动端上糊成一片。 */
background: linear-gradient(180deg, rgba(226, 238, 213, 0.96), rgba(206, 224, 188, 0.94));
border-color: rgba(109, 136, 95, 0.62);
}
.record-item-processed .record-item-time,
.record-item-processed .record-item-context {
color: #58714f;
}
.record-item-processed .record-item-content {
color: #34513a;
}
.record-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.record-item-time {
flex: 0 0 auto;
font-size: 22rpx;
color: var(--muted);
}
.record-item-context {
flex: 1;
min-width: 0;
font-size: 22rpx;
color: var(--muted);
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.record-item-content {
display: block;
margin-top: 10rpx;
color: var(--text);
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
font-size: 24rpx;
}
.record-item-content-discarded {
text-decoration: line-through;
text-decoration-thickness: 2rpx;
}
.records-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.records-footer-actions {
display: inline-flex;
align-items: center;
gap: 12rpx;
}
.records-footer-action-btn {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 8rpx;
--svg-press-active-radius: 16rpx;
--svg-press-active-bg: var(--btn-bg-active);
--svg-press-active-shadow: none;
--svg-press-active-scale: 1;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.72;
--svg-press-icon-active-scale: 0.92;
}
.records-footer-action-icon {
width: 26rpx;
height: 26rpx;
display: block;
}
.records-pagination {
display: flex;
align-items: center;
gap: 12rpx;
}
.records-pagination-text {
font-size: 22rpx;
color: var(--muted);
}
.records-edit-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 40;
background: rgba(5, 11, 24, 0.54);
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
box-sizing: border-box;
}
.records-quick-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 38;
background: rgba(5, 11, 24, 0.2);
}
.records-quick-panel {
position: absolute;
padding: 12rpx;
border-radius: 24rpx;
background: rgba(13, 24, 42, 0.2);
backdrop-filter: blur(10px);
}
.records-quick-cloud {
position: relative;
}
.records-quick-bubble {
position: absolute;
display: inline-flex !important;
align-items: center;
justify-content: center;
margin: 0 !important;
border: 1rpx solid transparent !important;
border-radius: 999rpx !important;
padding: 0 !important;
color: var(--text) !important;
font-size: 20rpx !important;
line-height: 1.15 !important;
font-weight: 700 !important;
text-align: center;
white-space: normal;
overflow-wrap: anywhere;
box-shadow: 0 10rpx 24rpx rgba(15, 35, 68, 0.18);
}
.records-quick-bubble.active {
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.18),
0 10rpx 24rpx rgba(42, 92, 182, 0.24);
transform: scale(1.05);
}
.records-edit-panel {
position: relative;
width: 100%;
max-width: 680rpx;
border-radius: 28rpx;
background: var(--surface);
border: 1rpx solid var(--surface-border);
padding: 24rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16rpx;
box-shadow: 0 18rpx 42rpx var(--surface-shadow);
}
.records-edit-category-scroll {
width: calc(100% - 56rpx);
white-space: nowrap;
}
.records-edit-category-row {
display: inline-flex;
align-items: center;
gap: 10rpx;
min-width: 100%;
}
.records-edit-category-pill {
flex: 0 0 auto;
min-height: 52rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
border: 1rpx solid var(--surface-border);
background: var(--bg);
color: var(--muted);
font-size: 22rpx;
line-height: 1.1;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.records-edit-category-pill.active {
border-color: rgba(91, 210, 255, 0.78);
background: rgba(91, 210, 255, 0.18);
color: var(--text);
font-weight: 700;
}
.records-edit-textarea {
width: 100%;
min-height: 240rpx;
border: 1rpx solid var(--surface-border);
border-radius: 20rpx;
background: var(--bg);
color: var(--text);
font-size: 26rpx;
line-height: 1.6;
box-sizing: border-box;
padding: 18rpx 20rpx;
}
.records-edit-time {
font-size: 22rpx;
color: var(--muted);
padding-right: 48rpx;
}
.records-edit-close-btn {
position: absolute;
right: 24rpx;
top: 24rpx;
width: 40rpx !important;
min-width: 40rpx !important;
height: 40rpx !important;
margin: 0 !important;
border: 0 !important;
border-radius: 999rpx !important;
background: transparent !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: rgba(156, 169, 191, 0.24);
--svg-press-active-shadow:
inset 0 0 0 1rpx rgba(210, 220, 236, 0.34),
0 0 0 8rpx rgba(156, 169, 191, 0.12);
--svg-press-active-scale: 0.9;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.68;
--svg-press-icon-active-scale: 0.88;
}
.records-edit-close-icon {
width: 32rpx;
height: 32rpx;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "服务器配置",
"disableScroll": true
}

View File

@@ -0,0 +1,303 @@
<view class="page-root server-settings-page" style="{{themeStyle}}">
<view class="page-content server-settings-content">
<scroll-view class="surface-scroll" scroll-y="true">
<view class="surface-panel server-settings-panel">
<view class="settings-sections">
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.basicTitle}}</text>
<text class="settings-section-desc">{{copy.sections.basicDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.name}}</text>
<input class="input" value="{{form.name}}" data-key="name" bindinput="onFieldInput" />
</view>
<view class="field wide">
<text>{{copy.fields.tags}}</text>
<input
class="input"
value="{{tagText}}"
placeholder="{{copy.placeholders.tags}}"
data-key="tagsText"
bindinput="onFieldInput"
/>
</view>
<view class="field">
<text>{{copy.fields.host}}</text>
<input class="input" value="{{form.host}}" data-key="host" bindinput="onFieldInput" />
</view>
<view class="field">
<text>{{copy.fields.port}}</text>
<input class="input" type="number" value="{{form.port}}" data-key="port" bindinput="onFieldInput" />
</view>
<view class="field">
<text>{{copy.fields.username}}</text>
<input class="input" value="{{form.username}}" data-key="username" bindinput="onFieldInput" />
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.authTitle}}</text>
<text class="settings-section-desc">{{copy.sections.authDesc}}</text>
</view>
<view class="field-grid">
<view class="field auth-type-field">
<text>{{copy.fields.authType}}</text>
<view class="auth-type-pills">
<block wx:for="{{authTypeOptions}}" wx:key="value">
<view
class="pill-chip {{authTypeIndex === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onAuthTypeTap"
>{{item.label}}</view>
</block>
</view>
</view>
<view wx:if="{{form.authType === 'password'}}" class="field">
<text>{{copy.fields.password}}</text>
<input class="input" password="true" value="{{form.password}}" data-key="password" bindinput="onFieldInput" />
</view>
<view wx:if="{{form.authType === 'privateKey' || form.authType === 'certificate'}}" class="field wide">
<text>{{copy.fields.privateKey}}</text>
<textarea class="textarea" value="{{form.privateKey}}" data-key="privateKey" bindinput="onFieldInput" />
</view>
<view wx:if="{{form.authType === 'privateKey' || form.authType === 'certificate'}}" class="field">
<text>{{copy.fields.passphrase}}</text>
<input class="input" password="true" value="{{form.passphrase}}" data-key="passphrase" bindinput="onFieldInput" />
</view>
<view wx:if="{{form.authType === 'certificate'}}" class="field wide">
<text>{{copy.fields.certificate}}</text>
<textarea class="textarea" value="{{form.certificate}}" data-key="certificate" bindinput="onFieldInput" />
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.connectTitle}}</text>
<text class="settings-section-desc">{{copy.sections.connectDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.transportMode}}</text>
<view class="input picker-input readonly-input">gateway</view>
</view>
<view class="field wide ai-workdir-field">
<text>{{copy.fields.aiProjectPath}}</text>
<view class="ai-workdir-wrap">
<view class="ai-workdir-inline">
<input class="input" value="{{form.projectPath}}" placeholder="{{copy.placeholders.aiProjectPath}}" data-key="projectPath" bindinput="onFieldInput" />
<view class="pill-chip ai-workdir-select-btn" bindtap="onOpenDirectoryPicker">{{copy.directoryPicker.openButton}}</view>
</view>
<view wx:if="{{dirPickerVisible}}" class="dir-picker-panel">
<scroll-view class="dir-tree-scroll" scroll-y="true">
<view class="dir-tree-stack">
<view wx:for="{{directoryRows}}" wx:key="path" class="dir-tree-row {{item.isSelected ? 'selected' : ''}}">
<view
class="dir-expand-toggle {{item.canExpand ? '' : 'disabled'}}"
style="margin-left: {{item.paddingLeft}}rpx;"
data-path="{{item.path}}"
catchtap="onDirectoryExpandTap"
>
<text wx:if="{{item.canExpand}}">{{item.expanded ? '▾' : '▸'}}</text>
<text wx:else>·</text>
</view>
<view class="dir-row-main" data-path="{{item.path}}" bindtap="onDirectorySelectTap">
<text class="dir-row-name">{{item.name}}</text>
<text wx:if="{{item.loading}}" class="dir-row-loading">{{copy.directoryPicker.loading}}</text>
</view>
</view>
</view>
</scroll-view>
<text wx:if="{{dirPickerError}}" class="dir-picker-error">{{dirPickerError}}</text>
<view class="actions dir-picker-actions">
<button class="btn" bindtap="onDirectoryPickerCancel">{{copy.directoryPicker.cancel}}</button>
<button class="btn primary" bindtap="onDirectoryPickerConfirm" disabled="{{dirPickerLoading}}">{{copy.directoryPicker.apply}}</button>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.jumpHostTitle}}</text>
<text class="settings-section-desc">{{copy.sections.jumpHostDesc}}</text>
<switch class="jump-host-switch" checked="{{form.jumpHost.enabled}}" bindchange="onJumpSwitchChange" />
</view>
<view wx:if="{{form.jumpHost.enabled}}" class="field-grid">
<view class="field">
<text>{{copy.fields.jumpHost}}</text>
<input class="input" value="{{form.jumpHost.host}}" data-key="jumpHost.host" bindinput="onFieldInput" />
</view>
<view class="field">
<text>{{copy.fields.jumpPort}}</text>
<input
class="input"
type="number"
value="{{form.jumpHost.port}}"
data-key="jumpHost.port"
bindinput="onFieldInput"
/>
</view>
<view class="field">
<text>{{copy.fields.jumpUsername}}</text>
<input
class="input"
value="{{form.jumpHost.username}}"
data-key="jumpHost.username"
bindinput="onFieldInput"
/>
</view>
<view class="field auth-type-field">
<text>{{copy.fields.authType}}</text>
<view class="auth-type-pills">
<block wx:for="{{authTypeOptions}}" wx:key="value">
<view
class="pill-chip {{form.jumpHost.authType === item.value ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onJumpAuthTypeTap"
>{{item.label}}</view>
</block>
</view>
</view>
<view wx:if="{{form.jumpHost.authType === 'password'}}" class="field">
<text>{{copy.fields.password}}</text>
<input
class="input"
password="true"
value="{{form.jumpPassword}}"
data-key="jumpPassword"
bindinput="onFieldInput"
/>
</view>
<view wx:if="{{form.jumpHost.authType === 'privateKey' || form.jumpHost.authType === 'certificate'}}" class="field wide">
<text>{{copy.fields.privateKey}}</text>
<textarea
class="textarea"
value="{{form.jumpPrivateKey}}"
data-key="jumpPrivateKey"
bindinput="onFieldInput"
/>
</view>
<view wx:if="{{form.jumpHost.authType === 'privateKey' || form.jumpHost.authType === 'certificate'}}" class="field">
<text>{{copy.fields.passphrase}}</text>
<input
class="input"
password="true"
value="{{form.jumpPassphrase}}"
data-key="jumpPassphrase"
bindinput="onFieldInput"
/>
</view>
<view wx:if="{{form.jumpHost.authType === 'certificate'}}" class="field wide">
<text>{{copy.fields.certificate}}</text>
<textarea
class="textarea"
value="{{form.jumpCertificate}}"
data-key="jumpCertificate"
bindinput="onFieldInput"
/>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="bottom-bar server-settings-bottom">
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="server-settings:back"
disabled="{{!canGoBack}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="goBack"
>
<image
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'server-settings:back' ? (accentIcons.back || icons.back || '/assets/icons/back.svg') : (icons.back || '/assets/icons/back.svg')}}"
mode="aspectFit"
/>
</button>
<view class="bottom-right-actions">
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="server-settings:connect"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onConnect"
>
<image
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'server-settings:connect' ? (accentIcons.connect || icons.connect || '/assets/icons/connect.svg') : (icons.connect || '/assets/icons/connect.svg')}}"
mode="aspectFit"
/>
</button>
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="server-settings:save"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onSave"
>
<image
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'server-settings:save' ? (accentIcons.save || icons.save || '/assets/icons/save.svg') : (icons.save || '/assets/icons/save.svg')}}"
mode="aspectFit"
/>
</button>
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="server-settings:records"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onOpenRecords"
>
<image
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'server-settings:records' ? (accentIcons.recordmanager || icons.recordmanager || '/assets/icons/recordmanager.svg') : (icons.recordmanager || '/assets/icons/recordmanager.svg')}}"
mode="aspectFit"
/>
</button>
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="server-settings:about"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onOpenAbout"
>
<image
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'server-settings:about' ? (accentIcons.about || icons.about || '/assets/icons/about.svg') : (icons.about || '/assets/icons/about.svg')}}"
mode="aspectFit"
/>
</button>
</view>
</view>
</view>

View File

@@ -0,0 +1,282 @@
.server-settings-page {
gap: 0;
}
.server-settings-content {
padding-top: 16rpx;
padding-bottom: 0;
}
.server-settings-panel {
padding-bottom: 16rpx;
gap: 20rpx;
}
.settings-sections {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.settings-section {
border: 1rpx solid var(--surface-border);
background: var(--surface);
border-radius: 16rpx;
padding: 14rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.settings-section-head {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.jump-host-switch {
margin-left: auto;
}
.settings-section-title {
font-size: 26rpx;
font-weight: 600;
color: var(--text);
}
.settings-section-desc {
font-size: 22rpx;
color: var(--muted);
}
.server-settings-page .field-grid {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.server-settings-page .field {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.server-settings-page .field.wide {
align-items: flex-start;
}
.server-settings-page .field.auth-type-field {
align-items: flex-start;
}
.server-settings-page .field.ai-workdir-field {
align-items: flex-start;
}
.server-settings-page .field > text {
width: 220rpx;
min-width: 220rpx;
max-width: 220rpx;
margin: 0;
color: var(--muted);
font-size: 22rpx;
line-height: 1.3;
}
.server-settings-page .field.wide > text {
padding-top: 14rpx;
}
.server-settings-page .field .input,
.server-settings-page .field .textarea {
flex: 1;
min-width: 0;
}
.auth-type-pills {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
padding: 2rpx 2rpx 4rpx;
}
.pill-chip {
min-height: 52rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
border: 1rpx solid var(--btn-border);
background: var(--icon-btn-bg);
color: var(--btn-text);
font-size: 22rpx;
line-height: 1.1;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.2rpx;
transition:
background 140ms ease,
border-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.pill-chip.active {
border-color: var(--accent-border);
background: var(--accent-bg-strong);
color: var(--text);
font-weight: 700;
box-shadow:
0 0 0 2rpx var(--accent-ring),
0 8rpx 18rpx var(--accent-shadow);
}
.ai-workdir-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.ai-workdir-inline {
display: flex;
align-items: center;
gap: 12rpx;
}
.ai-workdir-inline .input {
flex: 1;
min-width: 0;
}
.ai-workdir-select-btn {
flex: 0 0 auto;
min-height: 64rpx;
padding: 10rpx 18rpx;
}
.dir-picker-panel {
border: 1rpx solid rgba(141, 187, 255, 0.26);
border-radius: 14rpx;
background: rgba(10, 20, 36, 0.58);
padding: 12rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.dir-tree-scroll {
max-height: 360rpx;
border-radius: 12rpx;
border: 1rpx solid rgba(141, 187, 255, 0.2);
background: rgba(7, 14, 24, 0.35);
}
.dir-tree-stack {
display: flex;
flex-direction: column;
gap: 4rpx;
padding: 8rpx;
}
.dir-tree-row {
display: flex;
align-items: center;
border-radius: 10rpx;
min-height: 56rpx;
}
.dir-tree-row.selected {
background: rgba(91, 210, 255, 0.16);
box-shadow: inset 0 0 0 1rpx rgba(113, 168, 235, 0.48);
}
.dir-expand-toggle {
width: 44rpx;
height: 44rpx;
margin-right: 6rpx;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 22rpx;
flex: 0 0 auto;
}
.dir-expand-toggle.disabled {
opacity: 0.35;
}
.dir-row-main {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 10rpx;
padding-right: 8rpx;
}
.dir-row-name {
flex: 1;
min-width: 0;
color: var(--text);
font-size: 22rpx;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dir-row-loading {
flex: 0 0 auto;
color: var(--muted);
font-size: 20rpx;
}
.dir-picker-error {
color: var(--danger);
font-size: 21rpx;
line-height: 1.4;
}
.dir-picker-actions {
justify-content: flex-end;
}
.picker-input {
display: flex;
align-items: center;
}
.readonly-input {
opacity: 0.75;
}
.server-settings-page .textarea {
min-height: 180rpx;
line-height: 1.45;
}
.server-settings-bottom {
padding: 0 64rpx 0 32rpx;
}
.server-settings-bottom .svg-press-btn {
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: rgba(156, 169, 191, 0.24);
--svg-press-active-shadow:
inset 0 0 0 1rpx rgba(210, 220, 236, 0.34),
0 0 0 8rpx rgba(156, 169, 191, 0.12);
--svg-press-active-scale: 0.9;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.68;
--svg-press-icon-active-scale: 0.88;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "设置",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,601 @@
<view class="page-root settings-page" style="{{themeStyle}}">
<view class="settings-tabs">
<block wx:for="{{tabs}}" wx:key="id">
<button class="settings-tab-btn {{activeTab === item.id ? 'active' : ''}}" data-tab="{{item.id}}" bindtap="onTabTap">{{item.label}}</button>
</block>
</view>
<view class="page-content">
<scroll-view class="surface-scroll" scroll-y="true">
<view class="surface-panel settings-panel">
<view class="actions settings-header-actions">
<text class="settings-save-status">{{saveStatusText}}</text>
</view>
<view wx:if="{{activeTab === 'ui'}}" class="settings-sections">
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.languageTitle}}</text>
<text class="settings-section-desc">{{copy.sections.languageDesc}}</text>
</view>
<scroll-view class="pill-scroll" scroll-x="true" show-scrollbar="false">
<view class="pill-row">
<block wx:for="{{uiLanguageOptions}}" wx:key="value">
<view
class="pill-chip {{uiLanguageIndex === index ? 'active' : ''}}"
data-key="uiLanguage"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</scroll-view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.uiTitle}}</text>
<text class="settings-section-desc">{{copy.sections.uiDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.themeMode}}</text>
<view class="segment-control">
<block wx:for="{{uiThemeModeOptions}}" wx:key="value">
<view
class="segment-item {{uiThemeModeIndex === index ? 'active' : ''}}"
data-key="uiThemeMode"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</view>
<view class="field">
<text>{{copy.fields.themePreset}}</text>
<scroll-view class="pill-scroll" scroll-x="true" show-scrollbar="false">
<view class="pill-row">
<block wx:for="{{uiThemePresetOptions}}" wx:key="value">
<view
class="pill-chip {{uiThemePresetIndex === index ? 'active' : ''}}"
data-key="uiThemePreset"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</scroll-view>
</view>
<view class="field color-field">
<text>{{copy.fields.uiAccentColor}}</text>
<view class="input color-trigger" data-key="uiAccentColor" bindtap="onToggleColorPanel">
<view class="color-bar">
<view class="color-bar-fill" style="background: {{form.uiAccentColor}};"></view>
</view>
</view>
<view wx:if="{{activeColorPanelKey === 'uiAccentColor'}}" class="color-palette-grid">
<block wx:for="{{colorPaletteOptions}}" wx:key="value">
<view
class="palette-swatch-wrap {{form.uiAccentColor === item.value ? 'active' : ''}}"
data-key="uiAccentColor"
data-color="{{item.value}}"
bindtap="onPickPaletteColor"
>
<view class="palette-swatch" style="background: {{item.value}};"></view>
</view>
</block>
</view>
</view>
<view class="field color-field">
<text>{{copy.fields.uiBgColor}}</text>
<view class="input color-trigger" data-key="uiBgColor" bindtap="onToggleColorPanel">
<view class="color-bar">
<view class="color-bar-fill" style="background: {{form.uiBgColor}};"></view>
</view>
</view>
<view wx:if="{{activeColorPanelKey === 'uiBgColor'}}" class="color-palette-grid">
<block wx:for="{{colorPaletteOptions}}" wx:key="value">
<view
class="palette-swatch-wrap {{form.uiBgColor === item.value ? 'active' : ''}}"
data-key="uiBgColor"
data-color="{{item.value}}"
bindtap="onPickPaletteColor"
>
<view class="palette-swatch" style="background: {{item.value}};"></view>
</view>
</block>
</view>
</view>
<view class="field color-field">
<text>{{copy.fields.uiTextColor}}</text>
<view class="input color-trigger" data-key="uiTextColor" bindtap="onToggleColorPanel">
<view class="color-bar">
<view class="color-bar-fill" style="background: {{form.uiTextColor}};"></view>
</view>
</view>
<view wx:if="{{activeColorPanelKey === 'uiTextColor'}}" class="color-palette-grid">
<block wx:for="{{colorPaletteOptions}}" wx:key="value">
<view
class="palette-swatch-wrap {{form.uiTextColor === item.value ? 'active' : ''}}"
data-key="uiTextColor"
data-color="{{item.value}}"
bindtap="onPickPaletteColor"
>
<view class="palette-swatch" style="background: {{item.value}};"></view>
</view>
</block>
</view>
</view>
<view class="field color-field">
<text>{{copy.fields.uiBtnColor}}</text>
<view class="input color-trigger" data-key="uiBtnColor" bindtap="onToggleColorPanel">
<view class="color-bar">
<view class="color-bar-fill" style="background: {{form.uiBtnColor}};"></view>
</view>
</view>
<view wx:if="{{activeColorPanelKey === 'uiBtnColor'}}" class="color-palette-grid">
<block wx:for="{{colorPaletteOptions}}" wx:key="value">
<view
class="palette-swatch-wrap {{form.uiBtnColor === item.value ? 'active' : ''}}"
data-key="uiBtnColor"
data-color="{{item.value}}"
bindtap="onPickPaletteColor"
>
<view class="palette-swatch" style="background: {{item.value}};"></view>
</view>
</block>
</view>
</view>
</view>
</view>
</view>
<view wx:if="{{activeTab === 'shell'}}" class="settings-sections">
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.shellDisplayTitle}}</text>
<text class="settings-section-desc">{{copy.sections.shellDisplayDesc}}</text>
</view>
<view
class="terminal-style-preview"
style="background: {{form.shellBgColor}}; color: {{form.shellTextColor}}; font-family: {{form.shellFontFamily}}; font-size: {{form.shellFontSize}}px; line-height: {{form.shellLineHeight}};"
>
<text class="terminal-style-preview-line">Last login: Sat Feb 28 20:49:12 2026 from 115.193.12.66</text>
<text class="terminal-style-preview-line">
<text class="terminal-style-preview-prompt" style="color: {{form.shellAccentColor}};">gavin mini ~ %</text> ls -la
</text>
<text class="terminal-style-preview-line">drwxr-xr-x 4 gavin staff 128 Feb 28 20:49 workspace</text>
<text class="terminal-style-preview-line">{{terminalPreviewLine}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.themeMode}}</text>
<view class="segment-control">
<block wx:for="{{shellThemeModeOptions}}" wx:key="value">
<view
class="segment-item {{shellThemeModeIndex === index ? 'active' : ''}}"
data-key="shellThemeMode"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</view>
<view class="field">
<text>{{copy.fields.themePreset}}</text>
<scroll-view class="pill-scroll" scroll-x="true" show-scrollbar="false">
<view class="pill-row">
<block wx:for="{{shellThemePresetOptions}}" wx:key="value">
<view
class="pill-chip {{shellThemePresetIndex === index ? 'active' : ''}}"
data-key="shellThemePreset"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</scroll-view>
</view>
<view class="field color-field">
<text>{{copy.fields.shellBgColor}}</text>
<view class="input color-trigger" data-key="shellBgColor" bindtap="onToggleColorPanel">
<view class="color-bar">
<view class="color-bar-fill" style="background: {{form.shellBgColor}};"></view>
</view>
</view>
<view wx:if="{{activeColorPanelKey === 'shellBgColor'}}" class="color-palette-grid">
<block wx:for="{{colorPaletteOptions}}" wx:key="value">
<view
class="palette-swatch-wrap {{form.shellBgColor === item.value ? 'active' : ''}}"
data-key="shellBgColor"
data-color="{{item.value}}"
bindtap="onPickPaletteColor"
>
<view class="palette-swatch" style="background: {{item.value}};"></view>
</view>
</block>
</view>
</view>
<view class="field color-field">
<text>{{copy.fields.shellTextColor}}</text>
<view class="input color-trigger" data-key="shellTextColor" bindtap="onToggleColorPanel">
<view class="color-bar">
<view class="color-bar-fill" style="background: {{form.shellTextColor}};"></view>
</view>
</view>
<view wx:if="{{activeColorPanelKey === 'shellTextColor'}}" class="color-palette-grid">
<block wx:for="{{colorPaletteOptions}}" wx:key="value">
<view
class="palette-swatch-wrap {{form.shellTextColor === item.value ? 'active' : ''}}"
data-key="shellTextColor"
data-color="{{item.value}}"
bindtap="onPickPaletteColor"
>
<view class="palette-swatch" style="background: {{item.value}};"></view>
</view>
</block>
</view>
</view>
<view class="field color-field">
<text>{{copy.fields.shellAccentColor}}</text>
<view class="input color-trigger" data-key="shellAccentColor" bindtap="onToggleColorPanel">
<view class="color-bar">
<view class="color-bar-fill" style="background: {{form.shellAccentColor}};"></view>
</view>
</view>
<view wx:if="{{activeColorPanelKey === 'shellAccentColor'}}" class="color-palette-grid">
<block wx:for="{{colorPaletteOptions}}" wx:key="value">
<view
class="palette-swatch-wrap {{form.shellAccentColor === item.value ? 'active' : ''}}"
data-key="shellAccentColor"
data-color="{{item.value}}"
bindtap="onPickPaletteColor"
>
<view class="palette-swatch" style="background: {{item.value}};"></view>
</view>
</block>
</view>
</view>
<view class="field pill-field">
<text>{{copy.fields.shellFontFamily}}</text>
<view class="field-stack">
<scroll-view class="pill-scroll" scroll-x="true" show-scrollbar="false">
<view class="pill-row">
<block wx:for="{{shellFontFamilyOptions}}" wx:key="value">
<view
class="pill-chip font-pill {{shellFontFamilyIndex === index ? 'active' : ''}}"
data-key="shellFontFamily"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</scroll-view>
</view>
</view>
<view class="field">
<text>{{copy.fields.shellFontSize}}</text>
<view class="field-inline-row">
<text class="field-hint field-hint-inline">{{copy.hints.shellFontSizeReconnect}}</text>
<input
class="input field-inline-input"
type="number"
value="{{form.shellFontSize}}"
data-key="shellFontSize"
bindinput="onInput"
/>
</view>
</view>
<view class="field">
<text>{{copy.fields.shellLineHeight}}</text>
<view class="field-stack">
<input class="input" type="number" value="{{form.shellLineHeight}}" data-key="shellLineHeight" bindinput="onInput" />
</view>
</view>
<view class="field">
<text>{{copy.fields.unicode11}}</text>
<switch checked="{{form.unicode11}}" color="#67d1ff" data-key="unicode11" bindchange="onSwitch" />
</view>
<view class="field">
<text>{{copy.fields.shellActivationDebugOutline}}</text>
<view class="field-stack">
<text class="field-hint">{{copy.hints.shellActivationDebugOutline}}</text>
</view>
<switch
checked="{{form.shellActivationDebugOutline}}"
color="#67d1ff"
data-key="shellActivationDebugOutline"
bindchange="onSwitch"
/>
</view>
<view class="field">
<text>{{copy.fields.showVoiceInputButton}}</text>
<view class="field-stack">
<text class="field-hint">{{copy.hints.showVoiceInputButton}}</text>
</view>
<switch
checked="{{form.showVoiceInputButton}}"
color="#67d1ff"
data-key="showVoiceInputButton"
bindchange="onSwitch"
/>
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.ttsTitle}}</text>
<text class="settings-section-desc">{{copy.sections.ttsDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.ttsSpeakableMaxChars}}</text>
<input
class="input"
type="number"
value="{{form.ttsSpeakableMaxChars}}"
data-key="ttsSpeakableMaxChars"
bindinput="onInput"
/>
</view>
<view class="field">
<text>{{copy.fields.ttsSegmentMaxChars}}</text>
<input
class="input"
type="number"
value="{{form.ttsSegmentMaxChars}}"
data-key="ttsSegmentMaxChars"
bindinput="onInput"
/>
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.shellBufferTitle}}</text>
<text class="settings-section-desc">{{copy.sections.shellBufferDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.shellBufferMaxEntries}}</text>
<input
class="input"
type="number"
value="{{form.shellBufferMaxEntries}}"
data-key="shellBufferMaxEntries"
bindinput="onInput"
/>
</view>
<view class="field">
<text>{{copy.fields.shellBufferMaxBytes}}</text>
<input
class="input"
type="number"
value="{{form.shellBufferMaxBytes}}"
data-key="shellBufferMaxBytes"
bindinput="onInput"
/>
</view>
<view class="field">
<text>{{copy.fields.shellBufferSnapshotMaxLines}}</text>
<input
class="input"
type="number"
value="{{form.shellBufferSnapshotMaxLines}}"
data-key="shellBufferSnapshotMaxLines"
bindinput="onInput"
/>
</view>
</view>
</view>
</view>
<view wx:if="{{activeTab === 'connection'}}" class="settings-sections">
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.connectionTitle}}</text>
<text class="settings-section-desc">{{copy.sections.connectionDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.autoReconnect}}</text>
<switch checked="{{form.autoReconnect}}" color="#67d1ff" data-key="autoReconnect" bindchange="onSwitch" />
</view>
<view class="field">
<text>{{copy.fields.reconnectLimit}}</text>
<input class="input" type="number" value="{{form.reconnectLimit}}" data-key="reconnectLimit" bindinput="onInput" />
</view>
<view class="field">
<text>{{copy.fields.backgroundSessionKeepAliveMinutes}}</text>
<input
class="input"
type="number"
value="{{form.backgroundSessionKeepAliveMinutes}}"
data-key="backgroundSessionKeepAliveMinutes"
bindinput="onInput"
/>
</view>
<view class="field">
<text>{{copy.fields.defaultAuthType}}</text>
<view class="segment-control">
<block wx:for="{{defaultAuthTypeOptions}}" wx:key="value">
<view
class="segment-item {{defaultAuthTypeIndex === index ? 'active' : ''}}"
data-key="defaultAuthType"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</view>
<view class="field">
<text>{{copy.fields.defaultPort}}</text>
<input class="input" type="number" value="{{form.defaultPort}}" data-key="defaultPort" bindinput="onInput" />
</view>
<view class="field wide">
<text>{{copy.fields.defaultProjectPath}}</text>
<input class="input" value="{{form.defaultProjectPath}}" placeholder="{{copy.placeholders.defaultProjectPath}}" data-key="defaultProjectPath" bindinput="onInput" />
</view>
<view class="field">
<text>{{copy.fields.defaultTimeoutSeconds}}</text>
<input class="input" type="number" value="{{form.defaultTimeoutSeconds}}" data-key="defaultTimeoutSeconds" bindinput="onInput" />
</view>
<view class="field">
<text>{{copy.fields.defaultHeartbeatSeconds}}</text>
<input class="input" type="number" value="{{form.defaultHeartbeatSeconds}}" data-key="defaultHeartbeatSeconds" bindinput="onInput" />
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.aiConnectionTitle}}</text>
<text class="settings-section-desc">{{copy.sections.aiConnectionDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.aiDefaultProvider}}</text>
<view class="segment-control">
<block wx:for="{{aiProviderOptions}}" wx:key="value">
<view
class="segment-item {{aiDefaultProviderIndex === index ? 'active' : ''}}"
data-key="aiDefaultProvider"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</view>
<view class="field pill-field">
<text>{{copy.fields.aiCodexSandboxMode}}</text>
<view class="field-stack">
<scroll-view class="pill-scroll" scroll-x="true" show-scrollbar="false">
<view class="pill-row">
<block wx:for="{{aiCodexSandboxOptions}}" wx:key="value">
<view
class="pill-chip {{aiCodexSandboxModeIndex === index ? 'active' : ''}}"
data-key="aiCodexSandboxMode"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</scroll-view>
</view>
</view>
<view class="field pill-field">
<text>{{copy.fields.aiCopilotPermissionMode}}</text>
<view class="field-stack">
<scroll-view class="pill-scroll" scroll-x="true" show-scrollbar="false">
<view class="pill-row">
<block wx:for="{{aiCopilotPermissionOptions}}" wx:key="value">
<view
class="pill-chip {{aiCopilotPermissionModeIndex === index ? 'active' : ''}}"
data-key="aiCopilotPermissionMode"
data-index="{{index}}"
bindtap="onPillSelect"
>{{item.label}}</view>
</block>
</view>
</scroll-view>
</view>
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.syncTitle}}</text>
<text class="settings-section-desc">{{copy.sections.syncDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.syncConfigEnabled}}</text>
<view class="field-stack">
<text class="field-hint">{{copy.hints.syncConfigLine1}}</text>
<text class="field-hint">{{copy.hints.syncConfigLine2}}</text>
</view>
<switch
checked="{{form.syncConfigEnabled}}"
color="#67d1ff"
data-key="syncConfigEnabled"
bindchange="onSwitch"
/>
</view>
</view>
</view>
</view>
<view wx:if="{{activeTab === 'log'}}" class="settings-sections">
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.recordTitle}}</text>
<text class="settings-section-desc">{{copy.sections.recordDesc}}</text>
</view>
<view class="field-grid">
<view class="field">
<text>{{copy.fields.logRetentionDays}}</text>
<input class="input" type="number" value="{{form.logRetentionDays}}" data-key="logRetentionDays" bindinput="onInput" />
</view>
</view>
</view>
<view class="settings-section">
<view class="settings-section-head">
<text class="settings-section-title">{{copy.sections.voiceCategoryTitle}}</text>
<text class="settings-section-desc">{{copy.sections.voiceCategoryDesc}}</text>
</view>
<view class="category-create-row">
<input
class="input category-create-input"
value="{{newVoiceRecordCategory}}"
placeholder="{{copy.placeholders.newVoiceRecordCategory}}"
maxlength="12"
bindinput="onVoiceRecordCategoryInput"
/>
<button class="btn category-create-btn" bindtap="onAddVoiceRecordCategory">{{copy.buttons.addVoiceRecordCategory}}</button>
</view>
<text class="settings-inline-label">{{copy.fields.voiceCategoryList}}</text>
<view
class="voice-category-grid {{voiceCategoryDragActive ? 'dragging' : ''}}"
catchtouchmove="onVoiceRecordCategoryDragMove"
catchtouchend="onVoiceRecordCategoryDragEnd"
catchtouchcancel="onVoiceRecordCategoryDragEnd"
>
<view
wx:for="{{voiceRecordCategoryCards}}"
wx:key="category"
class="voice-category-card {{item.isSelected ? 'is-selected' : ''}} {{item.dragging ? 'is-dragging' : ''}}"
style="{{item.dragStyle}}"
data-category="{{item.category}}"
bindtap="onSelectVoiceRecordCategory"
catchlongpress="onStartVoiceRecordCategoryDrag"
>
<view class="voice-category-card-head">
<text class="voice-category-card-name">{{item.category}}</text>
<text wx:if="{{item.isDefault}}" class="voice-category-card-badge">{{copy.labels.defaultBadge}}</text>
</view>
</view>
</view>
<view class="voice-category-actions">
<button
class="btn secondary compact-btn"
disabled="{{!selectedVoiceRecordCategory || form.voiceRecordDefaultCategory === selectedVoiceRecordCategory}}"
bindtap="applySelectedVoiceRecordCategoryAsDefault"
>{{copy.buttons.setDefaultCategory}}</button>
<button
class="btn danger compact-btn"
disabled="{{!selectedVoiceRecordCategory}}"
bindtap="removeSelectedVoiceRecordCategory"
>{{copy.buttons.removeSelectedCategory}}</button>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<bottom-nav page="settings" />
</view>

View File

@@ -0,0 +1,459 @@
.settings-page {
/* 统一设置项标签列和控件列的基线,避免不同控件各用一套尺寸。 */
--settings-label-width: 220rpx;
--settings-field-gap: 16rpx;
--settings-control-height: 64rpx;
--settings-color-trigger-padding-y: 10rpx;
--settings-color-trigger-padding-x: 14rpx;
--settings-color-bar-height: 24rpx;
}
.settings-tabs {
display: flex;
gap: 8rpx;
padding: 16px 32rpx 0;
flex-shrink: 0;
}
.settings-page .page-content {
padding-top: 16rpx;
}
.settings-tab-btn {
border: 1rpx solid var(--btn-border);
background: var(--btn-bg);
color: var(--btn-text);
border-radius: 12rpx;
padding: 8rpx 16rpx;
font-size: 24rpx;
opacity: 0.75;
}
.settings-tab-btn.active {
background: var(--btn-bg-strong);
border-color: var(--btn-border-strong);
color: var(--btn-text);
opacity: 1;
}
.settings-panel {
padding-bottom: 16rpx;
gap: 20rpx;
}
.settings-header-actions {
justify-content: space-between;
margin-bottom: 4rpx;
}
.settings-sections {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.settings-page .field-grid {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.settings-page .field {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--settings-field-gap);
min-height: var(--settings-control-height);
}
.settings-page .field.wide {
width: 100%;
}
.settings-page .field > text {
width: var(--settings-label-width);
min-width: var(--settings-label-width);
max-width: var(--settings-label-width);
margin: 0;
color: var(--muted);
font-size: 22rpx;
line-height: 1.3;
}
.settings-page .field .input,
.settings-page .field .picker-input {
flex: 1;
min-width: 0;
}
.settings-page .field .btn {
flex: 1;
min-width: 0;
}
.settings-page .field switch {
margin-left: auto;
}
.settings-section {
border: 1rpx solid var(--surface-border);
background: var(--surface);
border-radius: 16rpx;
padding: 14rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.settings-section-head {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.settings-section-title {
font-size: 26rpx;
font-weight: 600;
color: var(--text);
}
.settings-section-desc {
font-size: 22rpx;
color: var(--muted);
}
.terminal-style-preview {
width: 100%;
border: 1rpx solid var(--surface-border);
border-radius: 14rpx;
background: var(--surface);
padding: 12rpx 14rpx;
box-sizing: border-box;
overflow: hidden;
margin-bottom: 4rpx;
}
.terminal-style-preview-line {
display: block;
white-space: pre-wrap;
word-break: break-word;
}
.terminal-style-preview-prompt {
font-weight: 600;
}
.picker-input {
display: flex;
align-items: center;
}
.settings-page .field.pill-field {
align-items: center;
}
.settings-page .field .field-stack {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
width: 100%;
gap: 6rpx;
}
/* 设置页提示文案跟随主题色,避免浅色主题下提示几乎不可见。 */
.settings-page .field .field-hint {
width: auto;
min-width: 0;
max-width: none;
margin-top: 0;
color: var(--muted);
font-size: 18rpx;
line-height: 1.35;
}
/* 字号提示放在输入框前方,同一行展示,避免单独占一行拉大纵向间距。 */
.settings-page .field .field-inline-row {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12rpx;
}
.settings-page .field .field-hint.field-hint-inline {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-page .field .field-inline-input {
flex: 0 0 128rpx;
width: 128rpx;
min-width: 128rpx;
}
.segment-control {
flex: 1;
min-width: 0;
min-height: var(--settings-control-height);
display: flex;
align-items: center;
/* gap 与外侧留白必须同值;否则激活态外扩 ring 会让端侧可见间距更窄。 */
padding: 6rpx;
border-radius: 999rpx;
border: 1rpx solid var(--btn-border);
background: var(--icon-btn-bg);
gap: 6rpx;
}
.segment-item {
flex: 1;
min-width: 0;
height: 50rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
line-height: 1;
color: var(--muted);
font-weight: 500;
transition:
background 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segment-item.active {
background: var(--accent-bg-strong);
color: var(--text);
font-weight: 700;
box-shadow: 0 0 0 2rpx var(--accent-ring);
}
.pill-chip {
min-height: 52rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
border: 1rpx solid var(--btn-border);
background: var(--icon-btn-bg);
color: var(--btn-text);
font-size: 22rpx;
line-height: 1.1;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.2rpx;
transition:
background 140ms ease,
border-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.pill-chip.active {
border-color: var(--accent-border);
background: var(--accent-bg-strong);
color: var(--text);
font-weight: 700;
box-shadow:
0 0 0 2rpx var(--accent-ring),
0 8rpx 18rpx var(--accent-shadow);
}
.pill-scroll {
flex: 1;
min-width: 0;
min-height: 56rpx;
display: block;
white-space: nowrap;
overflow: hidden;
}
.pill-row {
display: inline-flex;
align-items: center;
gap: 12rpx;
min-width: 100%;
min-height: 56rpx;
padding: 2rpx 2rpx 4rpx;
}
.pill-chip.font-pill {
max-width: 260rpx;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-page .field.color-field {
flex-wrap: wrap;
row-gap: 8rpx;
}
.settings-page .field.color-field .color-trigger {
flex: 1;
min-width: 0;
height: auto;
min-height: 0;
display: flex;
align-items: center;
padding: var(--settings-color-trigger-padding-y) var(--settings-color-trigger-padding-x);
border-radius: 999rpx;
}
.color-trigger {
display: flex;
align-items: center;
width: 100%;
}
.color-bar {
width: 100%;
height: var(--settings-color-bar-height);
border-radius: 999rpx;
overflow: hidden;
border: 1rpx solid var(--btn-border);
background: rgba(255, 255, 255, 0.12);
}
.color-bar-fill {
width: 100%;
height: 100%;
}
.color-palette-grid {
width: calc(100% - var(--settings-label-width) - var(--settings-field-gap));
margin-left: calc(var(--settings-label-width) + var(--settings-field-gap));
margin-top: 8rpx;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.palette-swatch-wrap {
width: 44rpx;
height: 44rpx;
padding: 3rpx;
border-radius: 10rpx;
box-sizing: border-box;
border: 1rpx solid transparent;
}
.palette-swatch-wrap.active {
border-color: var(--btn-border-strong);
background: var(--btn-bg-strong);
}
.palette-swatch {
width: 100%;
height: 100%;
border-radius: 8rpx;
border: 1rpx solid rgba(0, 0, 0, 0.16);
box-sizing: border-box;
}
.category-create-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.category-create-input {
flex: 1;
min-width: 0;
}
.category-create-btn {
flex: 0 0 128rpx;
}
.settings-inline-label {
display: block;
font-size: 22rpx;
color: var(--muted);
}
.voice-category-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
position: relative;
}
.voice-category-grid.dragging {
overflow: visible;
}
.voice-category-card {
width: calc((100% - 24rpx) / 3);
min-width: 0;
border: 1rpx solid var(--surface-border);
border-radius: 16rpx;
background: var(--surface);
padding: 16rpx 14rpx;
transition:
transform 180ms ease,
border-color 140ms ease,
background 140ms ease,
box-shadow 140ms ease,
opacity 140ms ease;
will-change: transform;
}
.voice-category-card.is-selected {
border-color: rgba(91, 210, 255, 0.72);
background: rgba(91, 210, 255, 0.14);
box-shadow: 0 6rpx 18rpx rgba(42, 92, 182, 0.22);
}
.voice-category-card.is-dragging {
transition: none;
opacity: 0.72;
box-shadow: 0 12rpx 28rpx rgba(0, 0, 0, 0.28);
}
.voice-category-card-head {
display: flex;
align-items: center;
gap: 8rpx;
min-width: 0;
}
.voice-category-card-name {
flex: 1;
min-width: 0;
color: var(--text);
font-size: 24rpx;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.voice-category-card-badge {
flex: 0 0 auto;
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: rgba(91, 210, 255, 0.2);
color: var(--text);
font-size: 18rpx;
line-height: 1;
}
.voice-category-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12rpx;
}
.compact-btn {
min-width: 132rpx;
}

View File

@@ -0,0 +1,71 @@
/**
* 2026-03-11 使用本机 codex-cli v0.113.0 真实 PTY 抓取,
* 并裁掉 `script` 头尾后的最小回放样本。
* 这里保留了 `CSI ? 2026 h/l`、`Working (...)` 和底部 footer 等关键控制流。
*/
const CODEX_TTY_CAPTURE_20260311_BASE64 =
"G1s/MjAwNGgbWz43dRtbPzEwMDRoG1s2bhtbP3UbW2MbXTEwOz8bXBtbPzIwMjZoG1sxOzJIG1swbRtbbRtbSxtbMjs0Mkgb" +
"WzBtG1ttG1tLG1szOzQySBtbMG0bW20bW0sbWzQ7NDJIG1swbRtbbRtbSxtbNTs0MkgbWzBtG1ttG1tLG1s2OzQySBtbMG0b" +
"W20bW0sbWzc7NDJIG1swbRtbbRtbSxtbODsySBtbMG0bW20bW0sbWzk7MkgbWzBtG1ttG1tLG1sxMDsyN0gbWzBtG1ttG1tL" +
"G1sxMTsySBtbMG0bW20bW0sbWzEyOzc5SBtbMG0bW20bW0sbWzI7MUgbWzJt4pWt4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pWuG1szOzFI4pSCID5fIBtbMjJtG1sxbU9wZW5BSSBDb2RleBtbMjJtG1sybRtbMm0g" +
"KHYwLjExMy4wKSAgICAgICAgICAgIOKUghtbNDsxSOKUgiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg" +
"IOKUghtbNTsxSOKUgiBtb2RlbDogICAgIBtbM21sb2FkaW5nG1syM20gICAbWzIybRtbO20vbW9kZWwbWzJtG1s7bSB0byBj" +
"aGFuZ2Ug4pSCG1s2OzFI4pSCIGRpcmVjdG9yeTogG1syMm1+L3JlbW90ZWNvbm4bWzJtICAgICAgICAgICAgICAg4pSCG1s3" +
"OzFI4pWw4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pWvG1sxMDsxSBtbMjJtG1sxbeKA" +
"uhtbMTA7M0gbWzIybRtbMm0bWzJtU3VtbWFyaXplIHJlY2VudCBjb21taXRzG1sxMjszSD8gZm9yIHNob3J0Y3V0cxtbMTI7" +
"NjJIMTAwJSBjb250ZXh0IGxlZnQbW20bW20bWzBtG1s/MjVoG1sxMDszSBtbPzIwMjZsG1s/MjAyNmgbWzE7MUgbW0obWzE7" +
"MkgbWzBtG1ttG1tLG1syOzUySBtbMG0bW20bW0sbWzM7MkgbWzBtG1ttG1tLG1s0OzJIG1swbRtbbRtbSxtbNTsyN0gbWzBt" +
"G1ttG1tLG1s2OzJIG1swbRtbbRtbSxtbNzs0M0gbWzBtG1ttG1tLG1syOzFI4oCiG1syOzNIG1sybUJvb3RpbmcgTUNQIHNl" +
"cnZlcjogZmlnbWEbWzI7MjlIKDBzIOKAoiBlc2MgdG8gaW50ZXJydXB0KRtbNTsxSBtbMjJtG1sxbeKAuhtbNTszSBtbMjJt" +
"G1sybRtbMm1TdW1tYXJpemUgcmVjZW50IGNvbW1pdHMbWzc7MUggIGdwdC01LjQgeGhpZ2ggwrcgMTAwJSBsZWZ0IMK3IH4v" +
"cmVtb3RlY29ubhtbbRtbbRtbMG0bWz8yNWgbWzU7M0gbWz8yMDI2bBtbPzIwMjZoG1sxOzJIG1swbRtbbRtbSxtbMjs1Mkgb" +
"WzBtG1ttG1tLG1szOzJIG1swbRtbbRtbSxtbNDsySBtbMG0bW20bW0sbWzU7MjdIG1swbRtbbRtbSxtbNjsySBtbMG0bW20b" +
"W0sbWzc7NDNIG1swbRtbbRtbSxtbMjsxMEggG1sxbU1DUCBzG1syMm1lG1syOzMwSBtbMm0yG1ttG1ttG1swbRtbPzI1aBtb" +
"NTszSBtbPzIwMjZsG1s/MjAyNmgbWzE7MjRyG1sxOzFIG00bTRtNG00bTRtNG00bTRtNG00bTRtNG1tyG1sxOzEychtbMTsx" +
"SA0NChtbO20bW0sbWzJt4pWt4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
"4pSA4pSA4pSA4pWuG1ttG1ttG1swbQ0NChtbO20bW0sbWzJt4pSCID5fIBtbMjJtG1sxbU9wZW5BSSBDb2RleBtbMjJtG1sy" +
"bRtbMm0gKHYwLjExMy4wKSAgICAgICAgICAgICAgICAgIOKUghtbbRtbbRtbMG0NDQobWzttG1tLG1sybeKUgiAgICAgICAg" +
"ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIOKUghtbbRtbbRtbMG0NDQobWzttG1tLG1sybeKUgiBtb2Rl" +
"bDogICAgIBtbMjJtZ3B0LTUuNCB4aGlnaBtbMm0gICAbWzIybRtbO20vbW9kZWwbWzJtG1s7bSB0byBjaGFuZ2Ug4pSCG1tt" +
"G1ttG1swbQ0NChtbO20bW0sbWzJt4pSCIGRpcmVjdG9yeTogG1syMm1+L3JlbW90ZWNvbm4bWzJtICAgICAgICAgICAgICAg" +
"ICAgICAg4pSCG1ttG1ttG1swbQ0NChtbO20bW0sbWzJt4pWw4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pWvG1ttG1ttG1swbQ0NChtbO20bW0sbW20bW20bWzBtDQ0KG1s7bRtbSyAg" +
"G1sxbVRpcDobWzIybSAbWzNtTmV3G1syM20gVXNlIBtbMW0vZmFzdBtbMjJtIHRvIGVuYWJsZSBvdXIgZmFzdGVzdCBpbmZl" +
"cmVuY2UgYXQgMlggcGxhbiB1c2FnZS4bW20bW20bWzBtDQ0KG1s7bRtbSxtbbRtbbRtbMG0NDQobWzttG1tLG1ttG1ttG1sw" +
"bQ0NChtbO20bW0sbWzFtG1sybeKAuiAbWzIybRtbMjJtUmVwbHkgd2l0aCB0aGUgc2luZ2xlIHdvcmQgT0sgYW5kIHRoZW4g" +
"c3RvcC4bW20bW20bWzBtDQ0KG1s7bRtbSxtbbRtbbRtbMG0bW3IbWzU7M0gbWzEzOzJIG1swbRtbbRtbSxtbMTQ7NTJIG1sw" +
"bRtbbRtbSxtbMTU7MkgbWzBtG1ttG1tLG1sxNjsySBtbMG0bW20bW0sbWzE3OzI3SBtbMG0bW20bW0sbWzE4OzJIG1swbRtb" +
"bRtbSxtbMTk7NDNIG1swbRtbbRtbSxtbbRtbbRtbMG0bWz8yNWgbWzE3OzNIG1s/MjAyNmwbWz8yMDI2aBtbMTM7MUgbW0ob" +
"WzEzOzI0chtbMTM7MUgbTRtNG00bW3IbWzE7MTVyG1sxMjsxSA0NChtbO20bW0sbW20bW20bWzBtDQ0KG1s7bRtbSxtbO23i" +
"mqAgSGVhZHMgdXAsIHlvdSBoYXZlIGxlc3MgdGhhbiAyNSUgb2YgeW91ciB3ZWVrbHkgbGltaXQgbGVmdC4gUnVuIC9zdGF0" +
"dXMgZm9yIGEbW20bW20bWzBtDQ0KG1s7bRtbSyAgG1s7bWJyZWFrZG93bi4bW20bW20bWzBtG1tyG1sxNzszSBtbMTY7Mkgb" +
"WzBtG1ttG1tLG1sxNzsySBtbMG0bW20bW0sbWzE4OzI3SBtbMG0bW20bW0sbWzE5OzJIG1swbRtbbRtbSxtbMjA7NDNIG1sw" +
"bRtbbRtbSxtbMTg7MUgbWzFt4oC6G1sxODszSBtbMjJtG1sybRtbMm1TdW1tYXJpemUgcmVjZW50IGNvbW1pdHMbWzIwOzFI" +
"ICBncHQtNS40IHhoaWdoIMK3IDEwMCUgbGVmdCDCtyB+L3JlbW90ZWNvbm4bW20bW20bWzBtG1s/MjVoG1sxODszSBtbPzIw" +
"MjZsG1s/MjAyNmgbWzE2OzFIG1tKG1sxNjsySBtbMG0bW20bW0sbWzE3OzM0SBtbMG0bW20bW0sbWzE4OzJIG1swbRtbbRtb" +
"SxtbMTk7MkgbWzBtG1ttG1tLG1syMDsyN0gbWzBtG1ttG1tLG1syMTsySBtbMG0bW20bW0sbWzIyOzQzSBtbMG0bW20bW0sb" +
"WzE3OzFI4oCiG1sxNzszSBtbMm1Xb3JraW5nG1sxNzsxMUgoMHMg4oCiIGVzYyB0byBpbnRlcnJ1cHQpG1syMDsxSBtbMjJt" +
"G1sxbeKAuhtbMjA7M0gbWzIybRtbMm0bWzJtU3VtbWFyaXplIHJlY2VudCBjb21taXRzG1syMjsxSCAgZ3B0LTUuNCB4aGln" +
"aCDCtyAxMDAlIGxlZnQgwrcgfi9yZW1vdGVjb25uG1ttG1ttG1swbRtbPzI1aBtbMjA7M0gbWz8yMDI2bBtbPzIwMjZoG1sx" +
"NjsySBtbMG0bW20bW0sbWzE3OzM0SBtbMG0bW20bW0sbWzE4OzJIG1swbRtbbRtbSxtbMTk7MkgbWzBtG1ttG1tLG1syMDsy" +
"N0gbWzBtG1ttG1tLG1syMTsySBtbMG0bW20bW0sbWzIyOzQzSBtbMG0bW20bW0sbW20bW20bWzBtG1s/MjVoG1syMDszSBtb" +
"PzIwMjZsG1s/MjAyNmgbWzE2OzFIG1tKG1sxNjsyNHIbWzE2OzFIG00bTRtNG1tyG1sxOzE4chtbMTU7MUgNDQobWzttG1tL" +
"G1ttG1ttG1swbQ0NChtbO20bW0sbWztt4pagIENvbnZlcnNhdGlvbiBpbnRlcnJ1cHRlZCAtIHRlbGwgdGhlIG1vZGVsIHdo" +
"YXQgdG8gZG8gZGlmZmVyZW50bHkuIFNvbWV0aGluZxtbbRtbbRtbMG0NDQobWzttG1tLG1s7bXdlbnQgd3Jvbmc/IEhpdCBg" +
"L2ZlZWRiYWNrYCB0byByZXBvcnQgdGhlIGlzc3VlLhtbbRtbbRtbMG0bW3IbWzIwOzNIG1sxOTsySBtbMG0bW20bW0sbWzIw" +
"OzJIG1swbRtbbRtbSxtbMjE7MjdIG1swbRtbbRtbSxtbMjI7MkgbWzBtG1ttG1tLG1syMzs0M0gbWzBtG1ttG1tLG1syMTsx" +
"SBtbMW3igLobWzIxOzNIG1syMm0bWzJtG1sybVN1bW1hcml6ZSByZWNlbnQgY29tbWl0cxtbMjM7MUggIGdwdC01LjQgeGhp" +
"Z2ggwrcgMTAwJSBsZWZ0IMK3IH4vcmVtb3RlY29ubhtbbRtbbRtbMG0bWz8yNWgbWzIxOzNIG1s/MjAyNmw=";
function decodeCodexTtyCapture20260311() {
return Buffer.from(CODEX_TTY_CAPTURE_20260311_BASE64, "base64").toString("utf8");
}
module.exports = {
decodeCodexTtyCapture20260311
};

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
const { decodeCodexTtyCapture20260311 } = require("./codexCaptureFixture.js");
const {
consumeTerminalSyncUpdateFrames,
createTerminalSyncUpdateState
} = require("./vtParser.js");
const {
getActiveTerminalBuffer,
rebuildTerminalBufferStateFromReplayText
} = require("./terminalBufferState.js");
const { buildTerminalViewportState } = require("./terminalViewportModel.js");
function splitTextIntoChunks(text: string, chunkSize: number) {
const chunks = [];
for (let index = 0; index < text.length; index += chunkSize) {
chunks.push(text.slice(index, index + chunkSize));
}
return chunks;
}
function serializeViewportLines(replayText: string) {
const state = rebuildTerminalBufferStateFromReplayText(replayText, {
bufferCols: 80,
bufferRows: 24
});
const active = getActiveTerminalBuffer(state);
const viewport = buildTerminalViewportState({
bufferRows: active.cells,
cursorRow: active.cursorRow,
activeBufferName: state.activeBufferName,
visibleRows: 24,
lineHeight: 20
});
return viewport.renderRows
.map((line) =>
(Array.isArray(line) ? line : [])
.filter((cell) => cell && !cell.continuation)
.map((cell) => cell.text || " ")
.join("")
.replace(/\s+$/, "")
)
.filter(Boolean);
}
describe("codexCaptureReplay", () => {
it("真实 Codex 抓包回放时,会在交互进行中同时保留 Working 行和底部 footer", () => {
const sample = decodeCodexTtyCapture20260311();
expect(sample).toContain("\u001b[?2026h");
expect(sample).toContain("Working");
expect(sample).toContain("gpt-5.4 xhigh");
let syncState = createTerminalSyncUpdateState();
let replayText = "";
let matchedLines: string[] | null = null;
splitTextIntoChunks(sample, 97).forEach((chunk) => {
if (matchedLines) {
return;
}
const result = consumeTerminalSyncUpdateFrames(chunk, syncState);
syncState = result.state;
if (!result.text) {
return;
}
replayText += result.text;
const lines = serializeViewportLines(replayText);
const hasWorking = lines.some((line) => line.includes("Working"));
const hasFooter = lines.some(
(line) => line.includes("gpt-5.4 xhigh") && line.includes("~/remoteconn")
);
const hasConversation = lines.some(
(line) =>
line.includes("Reply with the single word OK and then stop.") ||
line.includes("Summarize recent commits")
);
if (hasWorking && hasFooter && hasConversation) {
matchedLines = lines;
}
});
expect(matchedLines).not.toBeNull();
expect(matchedLines).toEqual(
expect.arrayContaining([
expect.stringContaining("Working"),
expect.stringContaining("gpt-5.4 xhigh"),
expect.stringContaining("Summarize recent commits")
])
);
});
it("真实 Codex 抓包完整回放后normal buffer viewport 仍会保留底部 footer", () => {
let syncState = createTerminalSyncUpdateState();
let replayText = "";
splitTextIntoChunks(decodeCodexTtyCapture20260311(), 97).forEach((chunk) => {
const result = consumeTerminalSyncUpdateFrames(chunk, syncState);
syncState = result.state;
replayText += result.text;
});
expect(syncState).toEqual({
depth: 0,
carryText: "",
bufferedText: ""
});
const lines = serializeViewportLines(replayText);
expect(
lines.some((line) => line.includes("gpt-5.4 xhigh") && line.includes("~/remoteconn"))
).toBe(true);
expect(lines).toEqual(
expect.arrayContaining([
expect.stringContaining("Conversation interrupted"),
expect.stringContaining("Summarize recent commits")
])
);
});
});

View File

@@ -0,0 +1,352 @@
/* global module, require */
const { toSvgDataUri } = require("../../utils/svgDataUri");
const DEFAULT_WIDTH = 640;
const DEFAULT_HEIGHT = 212;
const DEFAULT_PADDING = 0;
const AXIS_GUTTER = 32;
const TOP_INSET = 6;
const BOTTOM_INSET = 6;
function normalizeSampleValue(value) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue < 0) {
return null;
}
return Math.round(numberValue);
}
/**
* 所有诊断曲线都只保留固定窗口内的最近采样点,
* 这样卡片高度稳定,也避免历史峰值把当前波动压扁。
*/
function appendDiagnosticSample(samplesInput, value, maxPoints) {
const normalizedValue = normalizeSampleValue(value);
const next = Array.isArray(samplesInput) ? samplesInput.slice() : [];
const sampleLimit = Math.max(1, Math.round(Number(maxPoints) || 30));
if (normalizedValue == null) {
return next.slice(-sampleLimit);
}
next.push(normalizedValue);
return next.slice(-sampleLimit);
}
function normalizeSeries(samplesInput) {
return Array.isArray(samplesInput)
? samplesInput.map((sample) => normalizeSampleValue(sample)).filter((sample) => sample != null)
: [];
}
function escapeXmlText(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* 双轴图需要给左右刻度预留固定留白,
* 否则数字标签会压到曲线和端点高亮。
*/
function buildPlotFrame(width, height, padding) {
const left = padding + AXIS_GUTTER;
const right = width - padding - AXIS_GUTTER;
const top = padding + TOP_INSET;
const bottom = height - padding - BOTTOM_INSET;
return {
left,
right,
top,
bottom,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top)
};
}
/**
* 两条曲线共用横轴,但保留各自真实量纲:
* 左轴给“网关响应”,右轴给“网络时延”。
*/
function buildSeriesScale(samples) {
if (samples.length === 0) {
return null;
}
const rawMin = Math.min(...samples);
const rawMax = Math.max(...samples);
const rawSpan = rawMax - rawMin;
const padding = Math.max(4, rawSpan * 0.16, rawMax * 0.08, 1);
const min = Math.max(0, rawMin - padding);
const max = Math.max(min + 1, rawMax + padding);
return {
min,
max
};
}
function buildAxisTicks(scale, plotFrame) {
if (!scale) {
return [];
}
const values = [scale.max, (scale.max + scale.min) / 2, scale.min];
return values.map((value, index) => {
const ratio = values.length <= 1 ? 0 : index / (values.length - 1);
return {
value,
y: plotFrame.top + plotFrame.height * ratio
};
});
}
function formatAxisTickLabel(value) {
return `${Math.round(value)}ms`;
}
function buildChartPoints(samples, plotFrame, totalSlots, scale) {
if (samples.length === 0 || !scale) {
return [];
}
const safeRange = Math.max(1, scale.max - scale.min);
const safeSlots = Math.max(samples.length, Math.round(Number(totalSlots) || samples.length), 2);
const slotOffset = Math.max(0, safeSlots - samples.length);
return samples.map((sample, index) => {
const ratio = safeSlots <= 1 ? 0 : (slotOffset + index) / (safeSlots - 1);
const x = plotFrame.left + plotFrame.width * ratio;
const y = plotFrame.bottom - plotFrame.height * ((sample - scale.min) / safeRange);
return { x, y };
});
}
/**
* 使用 Catmull-Rom 转三次贝塞尔,让时延曲线保持圆润,
* 避免折线在采样点较少时显得生硬。
*/
function buildSmoothLinePath(points) {
if (points.length === 0) return "";
if (points.length === 1) {
const point = points[0];
return `M ${point.x.toFixed(2)} ${point.y.toFixed(2)} L ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
}
let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
for (let index = 0; index < points.length - 1; index += 1) {
const p0 = points[index - 1] || points[index];
const p1 = points[index];
const p2 = points[index + 1];
const p3 = points[index + 2] || p2;
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)} ${cp2x.toFixed(2)} ${cp2y.toFixed(
2
)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
return path;
}
function buildSmoothAreaPath(points, baselineY) {
if (points.length === 0) return "";
const first = points[0];
const last = points[points.length - 1];
return `${buildSmoothLinePath(points)} L ${last.x.toFixed(2)} ${baselineY.toFixed(2)} L ${first.x.toFixed(
2
)} ${baselineY.toFixed(2)} Z`;
}
function buildGridLines(plotFrame, strokeColor) {
const lines = [];
for (let index = 0; index < 4; index += 1) {
const y = plotFrame.top + (plotFrame.height / 3) * index;
lines.push(
`<line x1="${plotFrame.left.toFixed(2)}" y1="${y.toFixed(2)}" x2="${plotFrame.right.toFixed(
2
)}" y2="${y.toFixed(2)}" stroke="${strokeColor}" stroke-width="1" stroke-dasharray="6 10"/>`
);
}
return lines.join("");
}
function buildAxisLayer(side, ticks, plotFrame, lineColor, labelColor) {
if (!Array.isArray(ticks) || ticks.length === 0) {
return "";
}
const isLeft = side === "left";
const axisX = isLeft ? plotFrame.left : plotFrame.right;
const tickOuterX = isLeft ? axisX - 4 : axisX + 4;
const labelX = isLeft ? axisX - 6 : axisX + 6;
const anchor = isLeft ? "end" : "start";
const tickMarks = ticks
.map(
(tick) => `
<line x1="${axisX.toFixed(2)}" y1="${tick.y.toFixed(2)}" x2="${tickOuterX.toFixed(
2
)}" y2="${tick.y.toFixed(2)}" stroke="${lineColor}" stroke-opacity="0.38" stroke-width="1"/>
<text x="${labelX.toFixed(2)}" y="${(tick.y + 4).toFixed(
2
)}" text-anchor="${anchor}" font-size="10" fill="${labelColor}" fill-opacity="0.88">${escapeXmlText(
formatAxisTickLabel(tick.value)
)}</text>
`
)
.join("");
return `
<line x1="${axisX.toFixed(2)}" y1="${plotFrame.top.toFixed(2)}" x2="${axisX.toFixed(
2
)}" y2="${plotFrame.bottom.toFixed(2)}" stroke="${lineColor}" stroke-opacity="0.22" stroke-width="1"/>
${tickMarks}
`;
}
function buildEmptySparklineSvg(width, height, plotFrame, colors) {
const midY = (plotFrame.top + plotFrame.bottom) * 0.5;
const leftY = midY - 18;
const rightY = midY + 18;
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" fill="none">
<defs>
<linearGradient id="emptyBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${colors.cardGlow}" stop-opacity="0.22"/>
<stop offset="100%" stop-color="${colors.cardGlow}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#emptyBg)"/>
${buildGridLines(plotFrame, colors.grid)}
<line x1="${plotFrame.left.toFixed(2)}" y1="${plotFrame.top.toFixed(2)}" x2="${plotFrame.left.toFixed(
2
)}" y2="${plotFrame.bottom.toFixed(2)}" stroke="${colors.responseLine}" stroke-opacity="0.18" stroke-width="1"/>
<line x1="${plotFrame.right.toFixed(2)}" y1="${plotFrame.top.toFixed(2)}" x2="${plotFrame.right.toFixed(
2
)}" y2="${plotFrame.bottom.toFixed(2)}" stroke="${colors.networkLine}" stroke-opacity="0.18" stroke-width="1"/>
<line x1="${plotFrame.left.toFixed(2)}" y1="${midY.toFixed(2)}" x2="${plotFrame.right.toFixed(
2
)}" y2="${midY.toFixed(2)}" stroke="${colors.grid}" stroke-width="1" stroke-dasharray="8 12"/>
<line x1="${plotFrame.left.toFixed(2)}" y1="${leftY.toFixed(2)}" x2="${plotFrame.right.toFixed(
2
)}" y2="${leftY.toFixed(2)}" stroke="${colors.responseLine}" stroke-width="1.25" stroke-linecap="round" opacity="0.38"/>
<line x1="${plotFrame.left.toFixed(2)}" y1="${rightY.toFixed(2)}" x2="${plotFrame.right.toFixed(
2
)}" y2="${rightY.toFixed(2)}" stroke="${colors.networkLine}" stroke-width="1.25" stroke-linecap="round" opacity="0.38"/>
</svg>
`;
}
function buildSeriesDefs(prefix, colors) {
return `
<linearGradient id="${prefix}AreaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${colors.fill}" stop-opacity="0.18"/>
<stop offset="100%" stop-color="${colors.fill}" stop-opacity="0"/>
</linearGradient>
<filter id="${prefix}LineGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.2" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
`;
}
function buildSeriesLayers(points, colors, prefix, baselineY) {
if (points.length === 0) {
return "";
}
const linePath = buildSmoothLinePath(points);
const areaPath = buildSmoothAreaPath(points, baselineY);
const lastPoint = points[points.length - 1];
return `
<path d="${areaPath}" fill="url(#${prefix}AreaFill)"/>
<path d="${linePath}" stroke="${colors.line}" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" filter="url(#${prefix}LineGlow)"/>
<circle cx="${lastPoint.x.toFixed(2)}" cy="${lastPoint.y.toFixed(2)}" r="2.6" fill="${colors.line}" fill-opacity="0.12"/>
<circle cx="${lastPoint.x.toFixed(2)}" cy="${lastPoint.y.toFixed(2)}" r="1.4" fill="${colors.glow}"/>
`;
}
/**
* 双序列图把“网关响应”和“网络时延”画在同一张坐标图里:
* 1. 横轴按采样槽位右对齐,保证两个序列的最新点处在同一时间位置;
* 2. 左轴保留“网关响应”自身量纲,右轴保留“网络时延”自身量纲;
* 3. 两条曲线继续使用平滑贝塞尔,避免采样点少时退化成生硬折线。
*/
function buildCombinedDiagnosticSparkline(seriesInput, options) {
const responseSamples = normalizeSeries(seriesInput && seriesInput.responseSamples);
const networkSamples = normalizeSeries(seriesInput && seriesInput.networkSamples);
const width = Math.max(120, Math.round(Number(options && options.width) || DEFAULT_WIDTH));
const height = Math.max(80, Math.round(Number(options && options.height) || DEFAULT_HEIGHT));
const padding = Math.max(0, Math.round(Number(options && options.padding) || DEFAULT_PADDING));
const plotFrame = buildPlotFrame(width, height, padding);
const colors = {
responseLine: (options && options.responseLineColor) || "#67D1FF",
responseFill: (options && options.responseFillColor) || "#67D1FF",
responseGlow: (options && options.responseGlowColor) || "#B7F1FF",
networkLine: (options && options.networkLineColor) || "#FFB35C",
networkFill: (options && options.networkFillColor) || "#FFB35C",
networkGlow: (options && options.networkGlowColor) || "#FFE0A3",
cardGlow: (options && options.cardGlowColor) || "rgba(103, 209, 255, 0.28)",
grid: (options && options.gridColor) || "rgba(255, 255, 255, 0.12)"
};
if (responseSamples.length === 0 && networkSamples.length === 0) {
return toSvgDataUri(buildEmptySparklineSvg(width, height, plotFrame, colors));
}
const responseScale = buildSeriesScale(responseSamples);
const networkScale = buildSeriesScale(networkSamples);
const responseTicks = buildAxisTicks(responseScale, plotFrame);
const networkTicks = buildAxisTicks(networkScale, plotFrame);
const totalSlots = Math.max(responseSamples.length, networkSamples.length, 2);
const responsePoints = buildChartPoints(responseSamples, plotFrame, totalSlots, responseScale);
const networkPoints = buildChartPoints(networkSamples, plotFrame, totalSlots, networkScale);
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" fill="none">
<defs>
<radialGradient id="cardGlow" cx="0.5" cy="0.08" r="0.82">
<stop offset="0%" stop-color="${colors.cardGlow}" stop-opacity="0.55"/>
<stop offset="100%" stop-color="${colors.cardGlow}" stop-opacity="0"/>
</radialGradient>
${buildSeriesDefs("response", {
line: colors.responseLine,
fill: colors.responseFill,
glow: colors.responseGlow
})}
${buildSeriesDefs("network", {
line: colors.networkLine,
fill: colors.networkFill,
glow: colors.networkGlow
})}
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#cardGlow)"/>
${buildGridLines(plotFrame, colors.grid)}
${buildAxisLayer("left", responseTicks, plotFrame, colors.responseLine, colors.responseGlow)}
${buildAxisLayer("right", networkTicks, plotFrame, colors.networkLine, colors.networkGlow)}
${buildSeriesLayers(
responsePoints,
{
line: colors.responseLine,
fill: colors.responseFill,
glow: colors.responseGlow
},
"response",
plotFrame.bottom
)}
${buildSeriesLayers(
networkPoints,
{
line: colors.networkLine,
fill: colors.networkFill,
glow: colors.networkGlow
},
"network",
plotFrame.bottom
)}
</svg>
`;
return toSvgDataUri(svg);
}
module.exports = {
appendDiagnosticSample,
buildCombinedDiagnosticSparkline
};

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
const { appendDiagnosticSample, buildCombinedDiagnosticSparkline } = require("./connectionDiagnosticsSparkline.js");
function decodeSvgDataUri(uri: string) {
const base64 = uri.replace(/^data:image\/svg\+xml;base64,/, "");
return Buffer.from(base64, "base64").toString("utf8");
}
describe("connectionDiagnosticsSparkline", () => {
it("限制最近 30 个采样点", () => {
let samples: number[] = [];
for (let index = 1; index <= 35; index += 1) {
samples = appendDiagnosticSample(samples, index, 30);
}
expect(samples).toHaveLength(30);
expect(samples[0]).toBe(6);
expect(samples[29]).toBe(35);
});
it("为双序列生成带平滑曲线与左右双轴的 SVG data URI", () => {
const uri = buildCombinedDiagnosticSparkline(
{
responseSamples: [12, 22, 18, 30],
networkSamples: [88, 76, 90, 94]
},
{
responseLineColor: "#67D1FF",
responseFillColor: "#67D1FF",
networkLineColor: "#FFB35C",
networkFillColor: "#FFB35C"
}
);
const svg = decodeSvgDataUri(uri);
expect(uri.startsWith("data:image/svg+xml;base64,")).toBe(true);
expect(svg).not.toContain("网关响应");
expect(svg).not.toContain("网络时延");
expect(svg).toContain('text-anchor="end"');
expect(svg).toContain(" C ");
expect(svg).not.toContain("<polyline");
expect(svg).toContain('stroke-width="1.25"');
expect(svg).toContain('stroke="#67D1FF"');
expect(svg).toContain('stroke="#FFB35C"');
expect(svg).not.toContain("clipPath");
});
it("空数据时仍返回不带标题文字的占位图", () => {
const uri = buildCombinedDiagnosticSparkline({
responseSamples: [],
networkSamples: []
});
const svg = decodeSvgDataUri(uri);
expect(uri.startsWith("data:image/svg+xml;base64,")).toBe(true);
expect(svg).not.toContain("网关响应");
expect(svg).not.toContain("网络时延");
expect(svg).toContain("stroke-dasharray");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "终端",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,543 @@
<view class="page-root terminal-page" style="{{themeStyle}}">
<view class="page-toolbar terminal-toolbar">
<view class="toolbar-left">
<button
class="icon-btn toolbar-plain-btn terminal-toolbar-touch-btn svg-press-btn {{activeAiProvider ? 'is-connected' : ''}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:ai"
disabled="{{aiLaunchBusy}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onOpenCodex"
>
<image
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:ai' ? (activeAiProvider ? (uiButtonActiveIcons.codex || uiButtonIcons.codex || '/assets/icons/codex.svg') : (uiButtonAccentIcons.codex || uiButtonIcons.codex || '/assets/icons/codex.svg')) : (activeAiProvider ? (uiButtonActiveIcons.codex || uiButtonIcons.codex || '/assets/icons/codex.svg') : (uiButtonIcons.codex || '/assets/icons/codex.svg'))}}"
mode="aspectFit"
/>
</button>
<button
class="icon-btn toolbar-plain-btn terminal-toolbar-touch-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:clear"
disabled="{{statusText === 'connected' && activeAiProvider === 'codex'}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onClearScreen"
>
<image
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:clear' ? (uiButtonAccentIcons.clear || uiButtonIcons.clear || '/assets/icons/clear.svg') : (uiButtonIcons.clear || '/assets/icons/clear.svg')}}"
mode="aspectFit"
/>
</button>
</view>
<view class="toolbar-spacer"></view>
<view class="terminal-toolbar-actions">
<button
class="icon-btn terminal-toolbar-tts-btn svg-press-btn {{ttsEnabled ? 'is-enabled' : 'is-disabled'}} {{ttsState === 'playing' ? 'is-playing' : ''}} {{ttsState === 'preparing' ? 'is-preparing' : ''}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:tts"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onToggleTts"
>
<image
class="icon-img terminal-toolbar-tts-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:tts' ? (ttsEnabled ? (terminalToolActiveIcons.reading || terminalToolIcons.reading || '/assets/icons/reading.svg') : (terminalToolActiveIcons.stopreading || terminalToolIcons.stopreading || '/assets/icons/stopreading.svg')) : (ttsEnabled ? (terminalToolActiveIcons.reading || terminalToolIcons.reading || '/assets/icons/reading.svg') : (terminalToolIcons.stopreading || '/assets/icons/stopreading.svg'))}}"
mode="aspectFit"
/>
</button>
<view class="state-chip state-{{statusClass}}" bindtap="onOpenSessionInfo">
<text>{{statusLabel}}</text>
</view>
<text class="state-chip state-chip-action" bindtap="onOpenConnectionDiagnostics">{{latencyMs}}ms</text>
<view class="terminal-toolbar-divider"></view>
<button
class="terminal-connection-switch {{connectionActionReconnect ? 'is-reconnect' : 'is-disconnect'}} {{statusClass === 'connected' ? 'is-connected' : ''}}"
disabled="{{connectionActionDisabled}}"
bindtap="onConnectionAction"
>
<text class="terminal-connection-switch-label">{{connectionActionText}}</text>
<view class="terminal-connection-switch-knob"></view>
</button>
</view>
</view>
<view class="page-content terminal-content">
<view class="terminal-surface">
<view class="surface-panel terminal-panel" bindtap="onPanelTap">
<scroll-view
class="terminal-output"
scroll-y="true"
scroll-top="{{outputScrollTop}}"
bindscroll="onOutputScroll"
catchlongpress="onOutputLongPress"
catchtap="onOutputTap"
>
<view
wx:if="{{outputTopSpacerPx > 0}}"
class="output-viewport-spacer"
style="height: {{outputTopSpacerPx}}px;"
></view>
<view
wx:for="{{outputRenderLines}}"
wx:key="index"
class="output-line"
style="min-height: {{outputLineHeightPx}}px; line-height: {{outputLineHeightPx}}px; {{item.lineStyle}}"
data-line-index="{{item.bufferRow !== undefined ? item.bufferRow : index}}"
catchlongpress="onOutputLongPress"
catchtap="onOutputLineTap"
>
<text
wx:for="{{item.segments}}"
wx:key="index"
class="output-segment {{item.fixed ? 'output-segment-fixed' : ''}}"
style="{{item.style}}"
user-select="true"
>{{item.text}}</text
>
</view>
<view
wx:if="{{outputBottomSpacerPx > 0}}"
class="output-viewport-spacer"
style="height: {{outputBottomSpacerPx}}px;"
></view>
<view
wx:if="{{outputKeyboardInsetPx > 0}}"
class="output-keyboard-spacer"
style="height: {{outputKeyboardInsetPx}}px;"
></view>
</scroll-view>
<text wx:if="{{disconnectedHintVisible}}" class="terminal-disconnected-hint"
>{{disconnectedHintText}}</text
>
<view class="shell-metrics-probe">
<view class="shell-metrics-probe-line shell-metrics-probe-line-ascii"
>{{shellMetricsAsciiProbeText}}</view
>
<view class="shell-metrics-probe-line shell-metrics-probe-line-wide"
>{{shellMetricsWideProbeText}}</view
>
</view>
<view
wx:if="{{activationDebugVisible}}"
class="terminal-activation-debug"
style="top: {{activationDebugTopPx}}px; height: {{activationDebugHeightPx}}px;"
></view>
<view
wx:if="{{terminalCaretVisible}}"
class="terminal-caret"
style="left: {{terminalCaretLeftPx}}px; top: {{terminalCaretTopPx}}px; height: {{terminalCaretHeightPx}}px;"
></view>
<input
class="terminal-shell-input-proxy"
value="{{shellInputValue}}"
focus="{{shellInputFocus}}"
cursor="{{shellInputCursor}}"
type="text"
disabled="{{statusClass !== 'connected'}}"
maxlength="4096"
adjust-position="false"
confirm-type="send"
bindinput="onShellInputChange"
bindfocus="onShellInputFocus"
bindconfirm="onShellInputConfirm"
bindkeyboardheightchange="onShellInputKeyboardHeightChange"
bindblur="onShellInputBlur"
/>
<view
wx:if="{{touchToolsExpanded}}"
class="terminal-touch-filter"
catchtap="noop"
catchtouchstart="noop"
catchtouchmove="noop"
catchtouchend="noop"
catchtouchcancel="noop"
></view>
<view class="terminal-touch-tools {{touchToolsExpanded ? 'is-expanded' : ''}}" catchtap="noop">
<view wx:if="{{touchToolsExpanded}}" class="terminal-touch-tools-body">
<view class="terminal-touch-direction-pad">
<button
wx:for="{{terminalTouchDirectionKeys}}"
wx:key="key"
class="terminal-touch-direction-btn svg-press-btn {{item.slotClass}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-key="{{item.key}}"
data-press-key="{{item.pressKey}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onTouchKeyTap"
>
<image
class="terminal-touch-direction-icon svg-press-icon"
src="{{pressedSvgButtonKey === item.pressKey ? item.pressedIcon : item.icon}}"
mode="aspectFit"
/>
</button>
</view>
<view class="terminal-touch-action-stack">
<block wx:for="{{terminalTouchActionButtons}}" wx:key="key">
<button
wx:if="{{item.action === 'paste'}}"
class="terminal-touch-action-btn svg-press-btn {{item.slotClass}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="{{item.pressKey}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onPasteFromClipboard"
>
<image
class="terminal-touch-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === item.pressKey ? item.pressedIcon : item.icon}}"
mode="aspectFit"
/>
</button>
<button
wx:else
class="terminal-touch-action-btn svg-press-btn {{item.slotClass}} {{touchShiftMode !== 'off' && item.key === 'shift' ? 'is-active' : ''}} {{touchShiftMode === 'lock' && item.key === 'shift' ? 'is-locked' : ''}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-key="{{item.key}}"
data-press-key="{{item.pressKey}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onTouchKeyTap"
>
<image
class="terminal-touch-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === item.pressKey ? item.pressedIcon : item.icon}}"
mode="aspectFit"
/>
</button>
</block>
</view>
</view>
<button
class="terminal-touch-toggle-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:toggle-touch-tools"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onToggleTouchTools"
>
<image
class="terminal-touch-toggle-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:toggle-touch-tools' ? (terminalTouchTogglePressedIcon || terminalTouchToggleIcon || '/assets/icons/keyboard.svg') : (terminalTouchToggleIcon || '/assets/icons/keyboard.svg')}}"
mode="aspectFit"
/>
</button>
</view>
<view
wx:if="{{showVoiceInputButton}}"
class="voice-float-layer"
style="left: {{voiceFloatLeft}}px; bottom: {{voiceFloatBottom}}px; width: {{voicePanelVisible ? voicePanelWidthPx : voiceButtonSizePx}}px;"
catchtap="noop"
bindtouchstart="onVoiceLayerTouchStart"
bindtouchmove="onVoiceLayerTouchMove"
bindtouchend="onVoiceLayerTouchEnd"
bindtouchcancel="onVoiceLayerTouchEnd"
>
<button
wx:if="{{!voicePanelVisible}}"
class="voice-floating-btn voice-plain-btn svg-press-btn {{voiceHolding ? 'is-holding' : ''}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-drag-handle="1"
data-press-key="terminal:voice-floating"
bindtouchstart="onVoicePressStart"
catchtouchmove="onVoiceLayerTouchMove"
bindtouchend="onVoicePressEnd"
bindtouchcancel="onVoicePressEnd"
>
<image
class="voice-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:voice-floating' ? (terminalToolActiveIcons.voice || terminalToolIcons.voice || '/assets/icons/voice.svg') : (terminalToolIcons.voice || '/assets/icons/voice.svg')}}"
mode="aspectFit"
/>
</button>
<view wx:if="{{voicePanelVisible}}" class="voice-wrap" catchtap="noop">
<view class="frame2256 {{voiceHolding ? 'is-recording' : ''}}" style="opacity: {{frameOpacity}};">
<view class="terminal-input-wrap" data-drag-handle="1" catchtouchmove="onVoiceLayerTouchMove">
<view wx:if="{{voiceHolding}}" class="voice-recording-hint">
<view class="voice-recording-pulse">
<view class="voice-recording-pulse-core" />
<view class="voice-recording-pulse-ring" />
<view class="voice-recording-pulse-ring voice-recording-pulse-ring-delay" />
</view>
<text class="voice-recording-hint-text">{{copy.voice.recordingHint}}</text>
</view>
<textarea
class="terminal-voice-input"
auto-height
value="{{inputText}}"
placeholder="{{copy.voice.inputPlaceholder}}"
confirm-type="send"
bindconfirm="onInputConfirm"
bindinput="onInputText"
/>
</view>
<view class="voice-actions-row">
<view
class="voice-actions-left-track"
bindtouchstart="onVoiceActionsTouchStart"
catchtouchmove="onVoiceActionsTouchMove"
bindtouchend="onVoiceActionsTouchEnd"
bindtouchcancel="onVoiceActionsTouchEnd"
>
<view class="voice-actions-left" style="transform: translateX({{voiceActionsOffsetX}}px);">
<button
class="voice-action-btn voice-main-action-btn voice-plain-btn svg-press-btn {{voiceHolding ? 'is-holding' : ''}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-drag-handle="1"
data-press-key="terminal:voice-main"
bindtouchstart="onVoicePressStart"
catchtouchmove="onVoiceLayerTouchMove"
bindtouchend="onVoicePressEnd"
bindtouchcancel="onVoicePressEnd"
>
<image
class="voice-action-icon voice-main-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:voice-main' ? (terminalToolActiveIcons.voice || terminalToolIcons.voice || '/assets/icons/voice.svg') : (terminalToolIcons.voice || '/assets/icons/voice.svg')}}"
mode="aspectFit"
/>
</button>
<button
class="voice-action-btn voice-secondary-action-btn voice-plain-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:voice-record"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onRecordDraft"
>
<image
class="voice-action-icon voice-secondary-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:voice-record' ? (terminalToolActiveIcons.record || terminalToolIcons.record || '/assets/icons/record.svg') : (terminalToolIcons.record || '/assets/icons/record.svg')}}"
mode="aspectFit"
/>
</button>
<button
class="voice-action-btn voice-secondary-action-btn voice-plain-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:voice-send"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onSendDraft"
>
<image
class="voice-action-icon voice-secondary-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:voice-send' ? (terminalToolActiveIcons.sent || terminalToolIcons.sent || '/assets/icons/sent.svg') : (terminalToolIcons.sent || '/assets/icons/sent.svg')}}"
mode="aspectFit"
/>
</button>
</view>
</view>
<view class="voice-actions-right">
<button
class="voice-action-btn voice-plain-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:voice-clear-input"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onClearDraft"
>
<image
class="voice-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:voice-clear-input' ? (terminalToolActiveIcons.clearInput || terminalToolIcons.clearInput || '/assets/icons/clear-input.svg') : (terminalToolIcons.clearInput || '/assets/icons/clear-input.svg')}}"
mode="aspectFit"
/>
</button>
<button
class="voice-action-btn voice-plain-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="terminal:voice-cancel"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onCancelDraft"
>
<image
class="voice-action-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'terminal:voice-cancel' ? (terminalToolActiveIcons.cancel || terminalToolIcons.cancel || '/assets/icons/cancel.svg') : (terminalToolIcons.cancel || '/assets/icons/cancel.svg')}}"
mode="aspectFit"
/>
</button>
</view>
</view>
<scroll-view class="voice-category-scroll" scroll-x="true" show-scrollbar="false">
<view class="voice-category-row">
<view
wx:for="{{voiceRecordCategories}}"
wx:key="*this"
class="voice-category-pill {{selectedRecordCategory === item ? 'active' : ''}}"
data-category="{{item}}"
bindtap="onSelectRecordCategory"
>{{item}}</view
>
</view>
</scroll-view>
</view>
</view>
</view>
</view>
</view>
</view>
<view wx:if="{{sessionInfoVisible}}" class="session-info-mask" bindtap="onCloseSessionInfo">
<view class="session-info-panel" catchtap="noop">
<view class="connection-diagnostics-card session-info-card" style="{{sessionInfoTheme.cardStyle}}">
<text class="session-info-title" style="{{sessionInfoTheme.titleStyle}}">{{sessionInfoTitle}}</text>
<view class="session-info-hero" style="{{sessionInfoTheme.heroStyle}}">
<view class="session-info-hero-orb is-left"></view>
<view class="session-info-hero-orb is-right"></view>
<text class="session-info-hero-eyebrow">{{sessionInfoHero.eyebrow}}</text>
<text class="session-info-hero-name">{{sessionInfoHero.name}}</text>
<text class="session-info-hero-subtitle" user-select="true">{{sessionInfoHero.subtitle}}</text>
<text wx:if="{{sessionInfoHero.route}}" class="session-info-hero-route" user-select="true"
>{{sessionInfoHero.routeLabel}} · {{sessionInfoHero.route}}</text
>
</view>
<view class="session-info-status-grid">
<view
wx:for="{{sessionInfoStatusChips}}"
wx:key="key"
class="session-info-status-pill {{item.connected ? 'is-connected' : 'is-disconnected'}} {{((item.key === 'sshConnection' && !connectionActionDisabled) || (item.key === 'aiConnection' && !aiLaunchBusy)) ? 'is-actionable' : ''}}"
data-key="{{item.key}}"
bindtap="onSessionInfoStatusTap"
>
<view class="session-info-status-top">
<text class="session-info-status-label">{{item.label}}</text>
<text class="session-info-status-badge">{{item.badge}}</text>
</view>
<text class="session-info-status-value">{{item.value}}</text>
<text class="session-info-status-note">{{item.note}}</text>
</view>
</view>
<view class="session-info-detail-grid">
<view
wx:for="{{sessionInfoDetailItems}}"
wx:key="key"
class="session-info-detail-card {{item.wide ? 'is-wide' : ''}}"
>
<text class="session-info-detail-accent">{{item.accent}}</text>
<text class="session-info-detail-label">{{item.label}}</text>
<text class="session-info-detail-value" user-select="true">{{item.value}}</text>
</view>
</view>
</view>
</view>
</view>
<view
wx:if="{{connectionDiagnosticsVisible}}"
class="connection-diagnostics-mask"
bindtap="onCloseConnectionDiagnostics"
>
<view class="connection-diagnostics-panel" catchtap="noop">
<view
class="connection-diagnostics-card connection-diagnostics-chart-card is-combined"
style="{{connectionDiagnosticCombinedChart.cardStyle}}"
>
<view class="connection-diagnostics-chart-head">
<view
class="connection-diagnostics-chart-metric is-response"
style="{{connectionDiagnosticCombinedChart.responseMetricStyle}}"
>
<text class="connection-diagnostics-chart-axis-pill is-response"
>{{connectionDiagnosticCombinedChart.responseCardLabel}}</text
>
<view class="connection-diagnostics-chart-stats-row">
<view
wx:for="{{connectionDiagnosticCombinedChart.responseStatItems}}"
wx:key="key"
class="connection-diagnostics-chart-stat-item"
>
<text class="connection-diagnostics-chart-stat-label">{{item.label}}</text>
<text class="connection-diagnostics-chart-stat-value is-response">{{item.valueLabel}}</text>
<text wx:if="{{item.divider}}" class="connection-diagnostics-chart-stat-divider">·</text>
</view>
</view>
</view>
<view
class="connection-diagnostics-chart-metric is-network"
style="{{connectionDiagnosticCombinedChart.networkMetricStyle}}"
>
<text class="connection-diagnostics-chart-axis-pill is-network"
>{{connectionDiagnosticCombinedChart.networkCardLabel}}</text
>
<view class="connection-diagnostics-chart-stats-row">
<view
wx:for="{{connectionDiagnosticCombinedChart.networkStatItems}}"
wx:key="key"
class="connection-diagnostics-chart-stat-item"
>
<text class="connection-diagnostics-chart-stat-label">{{item.label}}</text>
<text class="connection-diagnostics-chart-stat-value is-network">{{item.valueLabel}}</text>
<text wx:if="{{item.divider}}" class="connection-diagnostics-chart-stat-divider">·</text>
</view>
</view>
</view>
</view>
<view
class="connection-diagnostics-chart-image-shell"
style="{{connectionDiagnosticCombinedChart.chartImageShellStyle}}"
>
<image
class="connection-diagnostics-chart-image"
src="{{connectionDiagnosticCombinedChart.imageUri}}"
mode="widthFix"
fade-show="{{false}}"
/>
</view>
</view>
</view>
</view>
<bottom-nav page="terminal" />
</view>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
function createTerminalPageHarness(initialStorage: Record<string, unknown>) {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const storage = new Map<string, unknown>(Object.entries(initialStorage));
const noop = () => {};
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getStorageSync: vi.fn((key: string) => storage.get(key)),
setStorageSync: vi.fn((key: string, value: unknown) => {
storage.set(key, value);
}),
removeStorageSync: vi.fn((key: string) => {
storage.delete(key);
}),
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
})),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false),
showToast: vi.fn()
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
setData(patch: Record<string, unknown>, callback?: () => void) {
Object.assign(this.data, patch);
callback?.();
}
} as TerminalPageInstance;
return { page, storage };
}
describe("terminal ai foreground lock", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("当前前台是 Codex 时,成功发出 Ctrl+C 会释放本地 AI 锁并同步快照", () => {
const { page, storage } = createTerminalPageHarness({});
const sendStdin = vi.fn();
page.client = { sendStdin };
page.sessionKey = "mini-session-key";
page.activeAiProvider = "codex";
page.activeCodexSandboxMode = "danger-full-access";
page.aiRuntimeExitCarry = "partial-marker";
page.data.serverId = "srv-1";
page.data.serverLabel = "server-1";
page.data.sessionId = "mini-session";
page.data.statusText = "connected";
const sent = page.sendControlSequence("\u0003");
const snapshot = storage.get("remoteconn.terminal.session.v1") as Record<string, unknown>;
expect(sent).toBe(true);
expect(sendStdin).toHaveBeenCalledWith("\u0003");
expect(page.activeAiProvider).toBe("");
expect(page.activeCodexSandboxMode).toBe("");
expect(page.aiRuntimeExitCarry).toBe("");
expect(snapshot.activeAiProvider).toBe("");
expect(snapshot.codexSandboxMode).toBe("");
});
it("Codex 前台态时,清屏操作应直接忽略", () => {
const { page } = createTerminalPageHarness({});
page.activeAiProvider = "codex";
page.data.statusText = "connected";
page.captureTerminalBufferState = vi.fn();
const result = page.onClearScreen();
expect(result).toBe(false);
expect(page.captureTerminalBufferState).not.toHaveBeenCalled();
});
it("home 快捷键会发送切回服务器工作目录的 cd 命令", () => {
const { page } = createTerminalPageHarness({});
const sendStdin = vi.fn();
page.client = { sendStdin };
page.server = { projectPath: "~/workspace/remoteconn" };
page.onTouchKeyTap({
currentTarget: {
dataset: {
key: "home"
}
}
} as unknown as Parameters<typeof page.onTouchKeyTap>[0]);
expect(sendStdin).toHaveBeenCalledWith("cd \"$HOME\"/'workspace/remoteconn'\r");
});
it("AI 前台态时home 快捷键应静默忽略", () => {
const { page } = createTerminalPageHarness({});
const sendStdin = vi.fn();
page.client = { sendStdin };
page.server = { projectPath: "~/workspace/remoteconn" };
page.activeAiProvider = "copilot";
page.onTouchKeyTap({
currentTarget: {
dataset: {
key: "home"
}
}
} as unknown as Parameters<typeof page.onTouchKeyTap>[0]);
expect(sendStdin).not.toHaveBeenCalled();
expect(globalState.wx?.showToast).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
handleDisconnect: (reason: string) => void;
connectGateway: ReturnType<typeof vi.fn>;
logTerminalPerf: ReturnType<typeof vi.fn>;
stopConnectionDiagnosticNetworkProbe: ReturnType<typeof vi.fn>;
persistConnectionDiagnosticSamples: ReturnType<typeof vi.fn>;
clearTerminalStdoutCarry: ReturnType<typeof vi.fn>;
clearCodexBootstrapGuard: ReturnType<typeof vi.fn>;
persistTerminalBufferSnapshot: ReturnType<typeof vi.fn>;
stopVoiceRound: ReturnType<typeof vi.fn>;
teardownAsrClient: ReturnType<typeof vi.fn>;
syncActiveAiProvider: ReturnType<typeof vi.fn>;
setStatus: ReturnType<typeof vi.fn>;
autoReconnectTimer: ReturnType<typeof setTimeout> | null;
autoReconnectAttempts: number;
autoReconnectSuppressed: boolean;
sessionSuspended: boolean;
activeAiProvider: string;
activeCodexSandboxMode: string;
resumeGraceMs: number;
sessionKey: string;
server: Record<string, unknown> | null;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
function createWxStorage(initial: Record<string, unknown>) {
const store = new Map<string, unknown>(Object.entries(initial));
return {
getStorageSync(key: string) {
return store.get(key);
},
setStorageSync(key: string, value: unknown) {
store.set(key, value);
},
removeStorageSync(key: string) {
store.delete(key);
},
getStorageInfoSync() {
return {
keys: Array.from(store.keys())
};
}
};
}
function createTerminalPageHarness(initialStorage: Record<string, unknown> = {}) {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const noop = () => {};
const wxStorage = createWxStorage(initialStorage);
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
...wxStorage,
env: {
USER_DATA_PATH: "/tmp"
},
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
})),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false),
showToast: vi.fn()
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
setData(patch: Record<string, unknown>, callback?: () => void) {
Object.assign(this.data, patch);
callback?.();
}
} as TerminalPageInstance;
page.logTerminalPerf = vi.fn();
page.stopConnectionDiagnosticNetworkProbe = vi.fn();
page.persistConnectionDiagnosticSamples = vi.fn();
page.clearTerminalStdoutCarry = vi.fn();
page.clearCodexBootstrapGuard = vi.fn();
page.persistTerminalBufferSnapshot = vi.fn();
page.stopVoiceRound = vi.fn();
page.teardownAsrClient = vi.fn();
page.syncActiveAiProvider = vi.fn();
page.setStatus = vi.fn(function (this: TerminalPageInstance, status: string) {
this.data.statusText = status;
});
page.connectGateway = vi.fn().mockResolvedValue(undefined);
page.autoReconnectTimer = null;
page.autoReconnectAttempts = 0;
page.autoReconnectSuppressed = false;
page.sessionSuspended = false;
page.activeAiProvider = "";
page.activeCodexSandboxMode = "";
page.resumeGraceMs = 5 * 60 * 1000;
page.sessionKey = "session-key";
page.server = {
id: "srv-1",
name: "srv-1",
host: "127.0.0.1",
port: 22,
username: "root"
};
page.data.serverId = "srv-1";
page.data.serverLabel = "srv-1";
page.data.sessionId = "session-1";
return {
page,
wxRuntime: globalState.wx as Record<string, ReturnType<typeof vi.fn>>
};
}
describe("terminal auto reconnect", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("ws_closed 且开启自动重连时,会按次数上限调度重连", async () => {
vi.useFakeTimers();
const { page } = createTerminalPageHarness({
"remoteconn.settings.v2": {
autoReconnect: true,
reconnectLimit: 2
}
});
page.handleDisconnect("ws_closed");
expect(page.autoReconnectAttempts).toBe(1);
expect(page.setStatus).toHaveBeenCalledWith("disconnected");
expect(page.setStatus).toHaveBeenCalledWith("reconnecting");
await vi.advanceTimersByTimeAsync(1200);
expect(page.connectGateway).toHaveBeenCalledWith(true);
});
it("手动断开不会进入自动重连", async () => {
vi.useFakeTimers();
const { page } = createTerminalPageHarness({
"remoteconn.settings.v2": {
autoReconnect: true,
reconnectLimit: 2
}
});
page.handleDisconnect("manual");
await vi.advanceTimersByTimeAsync(2000);
expect(page.autoReconnectAttempts).toBe(0);
expect(page.connectGateway).not.toHaveBeenCalled();
});
it("本地已抑制自动重连时,后续 ws_closed 不会误触发重连", async () => {
vi.useFakeTimers();
const { page } = createTerminalPageHarness({
"remoteconn.settings.v2": {
autoReconnect: true,
reconnectLimit: 2
}
});
page.autoReconnectSuppressed = true;
page.handleDisconnect("ws_closed");
await vi.advanceTimersByTimeAsync(2000);
expect(page.autoReconnectAttempts).toBe(0);
expect(page.connectGateway).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,240 @@
/* global module, require */
const { cloneTerminalCell } = require("./terminalCursorModel.js");
const TERMINAL_BUFFER_STATE_VERSION = 2;
const DEFAULT_ACTIVE_BUFFER = "normal";
const DEFAULT_TERMINAL_MODES = Object.freeze({
applicationCursorKeys: false,
applicationKeypad: false,
originMode: false,
reverseWraparound: false,
sendFocus: false,
wraparound: true,
cursorHidden: false,
bracketedPasteMode: false,
insertMode: false
});
function cloneAnsiState(state) {
const source = state && typeof state === "object" ? state : null;
return {
fg: source && source.fg ? String(source.fg) : "",
bg: source && source.bg ? String(source.bg) : "",
bold: !!(source && source.bold),
underline: !!(source && source.underline)
};
}
function cloneTerminalBufferRows(rows) {
const source = Array.isArray(rows) && rows.length > 0 ? rows : [[]];
return source.map((lineCells) =>
Array.isArray(lineCells) ? lineCells.map((cell) => cloneTerminalCell(cell)) : []
);
}
function clampNonNegativeInteger(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.round(parsed));
}
function clampBufferRows(value) {
return Math.max(1, clampNonNegativeInteger(value, 24));
}
function createEmptyRows(count) {
const total = Math.max(1, clampNonNegativeInteger(count, 1));
return Array.from({ length: total }, () => []);
}
function normalizeScreenBuffer(input, options) {
const source = input && typeof input === "object" ? input : null;
const isAlt = !!(options && options.isAlt);
const bufferRows = clampBufferRows(options && options.bufferRows);
let cells = cloneTerminalBufferRows(source && source.cells);
if (isAlt) {
if (cells.length > bufferRows) {
cells = cells.slice(0, bufferRows);
}
while (cells.length < bufferRows) {
cells.push([]);
}
} else if (cells.length === 0) {
cells = [[]];
}
const cursorRowFallback = 0;
const cursorRow = clampNonNegativeInteger(source && source.cursorRow, cursorRowFallback);
const cursorCol = clampNonNegativeInteger(source && source.cursorCol, 0);
const maxRow = isAlt ? bufferRows - 1 : Math.max(0, Math.max(cells.length - 1, cursorRow));
if (!isAlt) {
while (cells.length <= cursorRow) {
cells.push([]);
}
}
const normalizedCursorRow = Math.max(0, Math.min(cursorRow, maxRow));
const savedCursorRow = clampNonNegativeInteger(source && source.savedCursorRow, normalizedCursorRow);
const normalizedSavedCursorRow = Math.max(0, Math.min(savedCursorRow, maxRow));
const scrollTop = Math.max(
0,
Math.min(clampNonNegativeInteger(source && source.scrollTop, 0), bufferRows - 1)
);
const scrollBottom = Math.max(
scrollTop,
Math.min(clampNonNegativeInteger(source && source.scrollBottom, bufferRows - 1), bufferRows - 1)
);
return {
isAlt,
cells,
ansiState: cloneAnsiState(source && source.ansiState),
cursorRow: normalizedCursorRow,
cursorCol,
savedCursorRow: normalizedSavedCursorRow,
savedCursorCol: clampNonNegativeInteger(source && source.savedCursorCol, cursorCol),
savedAnsiState: cloneAnsiState(
source && source.savedAnsiState ? source.savedAnsiState : source && source.ansiState
),
scrollTop,
scrollBottom
};
}
function normalizeTerminalModes(input) {
const source = input && typeof input === "object" ? input : null;
return {
applicationCursorKeys:
source && source.applicationCursorKeys !== undefined
? !!source.applicationCursorKeys
: DEFAULT_TERMINAL_MODES.applicationCursorKeys,
applicationKeypad:
source && source.applicationKeypad !== undefined
? !!source.applicationKeypad
: DEFAULT_TERMINAL_MODES.applicationKeypad,
originMode:
source && source.originMode !== undefined ? !!source.originMode : DEFAULT_TERMINAL_MODES.originMode,
reverseWraparound:
source && source.reverseWraparound !== undefined
? !!source.reverseWraparound
: DEFAULT_TERMINAL_MODES.reverseWraparound,
sendFocus:
source && source.sendFocus !== undefined ? !!source.sendFocus : DEFAULT_TERMINAL_MODES.sendFocus,
wraparound:
source && source.wraparound !== undefined ? !!source.wraparound : DEFAULT_TERMINAL_MODES.wraparound,
cursorHidden:
source && source.cursorHidden !== undefined
? !!source.cursorHidden
: DEFAULT_TERMINAL_MODES.cursorHidden,
bracketedPasteMode:
source && source.bracketedPasteMode !== undefined
? !!source.bracketedPasteMode
: DEFAULT_TERMINAL_MODES.bracketedPasteMode,
insertMode:
source && source.insertMode !== undefined ? !!source.insertMode : DEFAULT_TERMINAL_MODES.insertMode
};
}
/**
* 统一把旧版单缓冲状态和新版双缓冲状态收敛成同一种结构:
* 1. `normal/alt` 两套 buffer 永远同形;
* 2. 页面层只消费 active buffer 的镜像字段;
* 3. 未来补更多 VT 模式时,只在这里扩展,不再把状态分散在页面运行时里。
*/
function normalizeTerminalBufferState(input, options, runtimeOptions) {
const source = input && typeof input === "object" ? input : null;
const bufferRows = clampBufferRows(options && options.bufferRows);
const legacyBuffer =
source &&
(!source.version || !source.buffers) &&
(source.cells || source.cursorRow !== undefined || source.cursorCol !== undefined)
? source
: null;
const normalSource =
source && source.version === TERMINAL_BUFFER_STATE_VERSION && source.buffers
? source.buffers.normal
: legacyBuffer;
const altSource =
source && source.version === TERMINAL_BUFFER_STATE_VERSION && source.buffers ? source.buffers.alt : null;
const activeBuffer =
source && source.version === TERMINAL_BUFFER_STATE_VERSION && source.activeBuffer === "alt"
? "alt"
: DEFAULT_ACTIVE_BUFFER;
const normalized = {
version: TERMINAL_BUFFER_STATE_VERSION,
buffers: {
normal: normalizeScreenBuffer(normalSource, { isAlt: false, bufferRows }),
alt: normalizeScreenBuffer(altSource, { isAlt: true, bufferRows })
},
activeBuffer,
modes: normalizeTerminalModes(source && source.modes ? source.modes : source)
};
return syncActiveBufferSnapshot(normalized, options, runtimeOptions);
}
function cloneTerminalBufferState(input, options, runtimeOptions) {
return normalizeTerminalBufferState(input, options, runtimeOptions);
}
function getActiveTerminalBuffer(state) {
const source = state && typeof state === "object" ? state : null;
if (!source || !source.buffers) {
return normalizeScreenBuffer(null, { isAlt: false, bufferRows: 24 });
}
return source.activeBuffer === "alt" ? source.buffers.alt : source.buffers.normal;
}
function getTerminalModeState(state) {
return normalizeTerminalModes(state && state.modes ? state.modes : state);
}
function syncActiveBufferSnapshot(state, options, runtimeOptions) {
const source = state && typeof state === "object" ? state : normalizeTerminalBufferState(null, options);
const active = source.activeBuffer === "alt" ? source.buffers.alt : source.buffers.normal;
const cloneRows = !(runtimeOptions && runtimeOptions.cloneRows === false);
const activeCells =
Array.isArray(active && active.cells) && active.cells.length > 0
? active.cells
: active && active.isAlt
? createEmptyRows(clampBufferRows(options && options.bufferRows))
: [[]];
source.cells = cloneRows ? cloneTerminalBufferRows(activeCells) : activeCells;
source.ansiState = cloneAnsiState(active && active.ansiState);
source.cursorRow = clampNonNegativeInteger(active && active.cursorRow, 0);
source.cursorCol = clampNonNegativeInteger(active && active.cursorCol, 0);
source.cursorHidden = !!(source.modes && source.modes.cursorHidden);
source.applicationCursorKeys = !!(source.modes && source.modes.applicationCursorKeys);
source.applicationKeypad = !!(source.modes && source.modes.applicationKeypad);
source.bracketedPasteMode = !!(source.modes && source.modes.bracketedPasteMode);
source.reverseWraparound = !!(source.modes && source.modes.reverseWraparound);
source.sendFocus = !!(source.modes && source.modes.sendFocus);
source.insertMode = !!(source.modes && source.modes.insertMode);
source.activeBufferName = source.activeBuffer === "alt" ? "alt" : "normal";
return source;
}
function createEmptyTerminalBufferState(options) {
return normalizeTerminalBufferState(null, options);
}
module.exports = {
DEFAULT_TERMINAL_MODES,
TERMINAL_BUFFER_STATE_VERSION,
cloneAnsiState,
cloneTerminalBufferRows,
cloneTerminalBufferState,
createEmptyRows,
createEmptyTerminalBufferState,
getActiveTerminalBuffer,
getTerminalModeState,
normalizeTerminalBufferState,
syncActiveBufferSnapshot
};

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
const {
cloneTerminalBufferState,
createEmptyTerminalBufferState,
getActiveTerminalBuffer,
getTerminalModeState
} = require("./terminalBufferSet.js");
describe("terminalBufferSet", () => {
it("会把旧版单缓冲状态提升为 normal/alt 双缓冲结构", () => {
const state = cloneTerminalBufferState(
{
cells: [[{ text: "A", width: 1, continuation: false, style: null }]],
ansiState: { fg: "#fff", bg: "", bold: false, underline: false },
cursorRow: 0,
cursorCol: 1
},
{ bufferRows: 4 }
);
expect(state.version).toBe(2);
expect(state.activeBuffer).toBe("normal");
expect(getActiveTerminalBuffer(state).isAlt).toBe(false);
expect(state.buffers.alt.cells).toHaveLength(4);
expect(state.cursorCol).toBe(1);
});
it("active buffer 镜像字段会跟随 alt buffer 切换", () => {
const state = createEmptyTerminalBufferState({ bufferRows: 3 });
state.activeBuffer = "alt";
state.buffers.alt.cursorRow = 2;
state.buffers.alt.cursorCol = 5;
const cloned = cloneTerminalBufferState(state, { bufferRows: 3 });
expect(cloned.activeBufferName).toBe("alt");
expect(cloned.cursorRow).toBe(2);
expect(cloned.cursorCol).toBe(5);
expect(getActiveTerminalBuffer(cloned).isAlt).toBe(true);
});
it("模式位会按统一结构收敛,避免页面层分散保存", () => {
const state = cloneTerminalBufferState(
{
modes: {
applicationCursorKeys: true,
applicationKeypad: true,
cursorHidden: true,
bracketedPasteMode: true,
reverseWraparound: true,
sendFocus: true,
insertMode: true
}
},
{ bufferRows: 2 }
);
expect(getTerminalModeState(state)).toMatchObject({
applicationCursorKeys: true,
applicationKeypad: true,
cursorHidden: true,
bracketedPasteMode: true,
reverseWraparound: true,
sendFocus: true,
insertMode: true,
wraparound: true
});
});
it("运行态克隆可复用 active buffer 镜像引用,避免热路径重复深拷贝", () => {
const state = createEmptyTerminalBufferState({ bufferRows: 3 });
state.buffers.normal.cells = [[{ text: "A", width: 1, continuation: false, style: null }]];
state.activeBuffer = "normal";
const cloned = cloneTerminalBufferState(state, { bufferRows: 3 }, { cloneRows: false });
expect(cloned.cells).toBe(cloned.buffers.normal.cells);
expect(cloned.cells).not.toBe(state.buffers.normal.cells);
});
});

View File

@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: any;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
const { createTerminalCell, createContinuationCell } = require("./terminalCursorModel.js");
const { serializeTerminalSnapshotRows } = require("./terminalSnapshotCodec.js");
function createTerminalPageHarness(initialStorage: Record<string, unknown>) {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const storage = new Map<string, unknown>(Object.entries(initialStorage));
const noop = () => {};
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getStorageSync: vi.fn((key: string) => storage.get(key)),
setStorageSync: vi.fn((key: string, value: unknown) => {
storage.set(key, value);
}),
removeStorageSync: vi.fn((key: string) => {
storage.delete(key);
}),
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
})),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false)
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
setData(patch: Record<string, unknown>, callback?: () => void) {
Object.assign(this.data, patch);
callback?.();
}
} as TerminalPageInstance;
return { page };
}
describe("terminal snapshot restore", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("恢复第一页时会优先使用样式快照,保留 ANSI 颜色并继续保留 replayText", () => {
const style = { fg: "#ff5f56", bg: "#1f2937", bold: true, underline: false };
const rows = [
[
createTerminalCell("错", style, 2),
createContinuationCell(style),
createTerminalCell("误", style, 2),
createContinuationCell(style),
createTerminalCell(":", style, 1)
]
];
const snapshotLines = serializeTerminalSnapshotRows(rows);
const { page } = createTerminalPageHarness({
"remoteconn.terminal.buffer.v1": {
sessionKey: "mini-key-color",
lines: ["错误:"],
styleTable: snapshotLines.styleTable,
styledLines: snapshotLines.styledLines,
replayText: "\u001b[31;1m错误:\u001b[0m",
bufferCols: 40,
bufferRows: 12,
cursorRow: 0,
cursorCol: 5
}
});
page.sessionKey = "mini-key-color";
page.restorePersistedTerminalBuffer();
expect(page.outputCells[0][0]?.style).toEqual(style);
expect(page.outputCells[0][2]?.style).toEqual(style);
expect(page.outputReplayText).toBe("\u001b[31;1m错误:\u001b[0m");
expect(page.outputReplayBytes).toBeGreaterThan(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,738 @@
import { describe, expect, it } from "vitest";
const {
ANSI_RESET_STATE,
applyTerminalOutput,
cloneTerminalBufferState,
getActiveTerminalBuffer,
rebuildTerminalBufferStateFromReplayText,
trimTerminalReplayTextToMaxBytes
} = require("./terminalBufferState.js");
const { lineCellsToText } = require("./terminalCursorModel.js");
const { takeTerminalReplaySlice } = require("./vtParser.js");
describe("terminalBufferState", () => {
it("宽字符和组合字符仍按 cell 列推进,不回退到字符串长度语义", () => {
const result = applyTerminalOutput(
{
cells: [[]],
ansiState: { ...ANSI_RESET_STATE },
cursorRow: 0,
cursorCol: 0
},
"中e\u0301A",
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
);
const row = result.state.cells[0];
expect(lineCellsToText(row)).toBe("中e\u0301A");
expect(result.state.cursorRow).toBe(0);
expect(result.state.cursorCol).toBe(4);
expect(row[0]).toMatchObject({ text: "中", width: 2, continuation: false });
expect(row[1]).toMatchObject({ text: "", width: 0, continuation: true });
expect(row[2]).toMatchObject({ text: "e\u0301", width: 1, continuation: false });
expect(row[3]).toMatchObject({ text: "A", width: 1, continuation: false });
});
it("覆盖宽字符 continuation 时会先清理 owner避免留下脏半格", () => {
const result = applyTerminalOutput(
{
cells: [[]],
ansiState: { ...ANSI_RESET_STATE },
cursorRow: 0,
cursorCol: 0
},
"中\bA",
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
);
const row = result.state.cells[0];
expect(lineCellsToText(row)).toBe(" A");
expect(result.state.cursorCol).toBe(2);
expect(row[0]).toMatchObject({ text: "", width: 1, continuation: false, placeholder: true });
expect(row[1]).toMatchObject({ text: "A", width: 1, continuation: false });
expect(row.some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)).toBe(false);
});
it("在列数变化后可按重放文本重新排布旧输出", () => {
const narrowResult = applyTerminalOutput(
{
cells: [[]],
ansiState: { ...ANSI_RESET_STATE },
cursorRow: 0,
cursorCol: 0
},
"abcdef",
{ bufferCols: 4, maxEntries: 20, maxBytes: 1024 }
);
const replayState = rebuildTerminalBufferStateFromReplayText(narrowResult.cleanText, {
bufferCols: 8,
maxEntries: 20,
maxBytes: 1024
});
expect(narrowResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcd", "ef"]);
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcdef"]);
expect(replayState.cursorRow).toBe(0);
expect(replayState.cursorCol).toBe(6);
});
it("重放文本会保留 ANSI 清行后的最终屏幕状态", () => {
const result = applyTerminalOutput(
{
cells: [[]],
ansiState: { ...ANSI_RESET_STATE },
cursorRow: 0,
cursorCol: 0
},
"hello\r\u001b[0Kworld",
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
);
const replayState = rebuildTerminalBufferStateFromReplayText(result.cleanText, {
bufferCols: 10,
maxEntries: 20,
maxBytes: 1024
});
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["world"]);
expect(replayState.cursorCol).toBe(5);
});
it("快照裁剪时保留尾部重放文本,便于恢复后按新列宽重建", () => {
expect(trimTerminalReplayTextToMaxBytes("ab中cd", 5)).toBe("中cd");
expect(trimTerminalReplayTextToMaxBytes("abcd", 8)).toBe("abcd");
});
it("宽字符输出在列数变化后仍可按 replay 文本重建", () => {
const narrowResult = applyTerminalOutput(
{
cells: [[]],
ansiState: { ...ANSI_RESET_STATE },
cursorRow: 0,
cursorCol: 0
},
"中AB",
{ bufferCols: 3, maxEntries: 20, maxBytes: 1024 }
);
const replayState = rebuildTerminalBufferStateFromReplayText(narrowResult.cleanText, {
bufferCols: 4,
maxEntries: 20,
maxBytes: 1024
});
expect(narrowResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["中A", "B"]);
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["中AB"]);
expect(replayState.cursorRow).toBe(0);
expect(replayState.cursorCol).toBe(4);
});
it("按安全切片连续推进后,最终终端状态应与整段推进一致", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const source = "ab\r\n\u001b[31mcd\u001b[0mZ";
const whole = applyTerminalOutput(createEmptyState(), source, options);
let remaining = source;
let state = createEmptyState();
while (remaining) {
const part = takeTerminalReplaySlice(remaining, 4);
expect(part.slice.length).toBeGreaterThan(0);
const partial = applyTerminalOutput(state, part.slice, options);
state = partial.state;
remaining = part.rest;
}
expect(state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(
whole.state.cells.map((row: unknown[]) => lineCellsToText(row))
);
expect(state.cursorRow).toBe(whole.state.cursorRow);
expect(state.cursorCol).toBe(whole.state.cursorCol);
expect(state.ansiState).toEqual(whole.state.ansiState);
});
it("stdout 运行态复用时,最终状态应与常规不可变推进一致", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "ab\r\ncd", options);
const regularBase = cloneTerminalBufferState(base.state, options);
const runtimeBase = cloneTerminalBufferState(base.state, options, { cloneRows: false });
const regular = applyTerminalOutput(regularBase, "\u001b[31mZ", options);
const reused = applyTerminalOutput(runtimeBase, "\u001b[31mZ", options, {
reuseState: true,
reuseRows: true
});
expect(reused.state).toBe(runtimeBase);
expect(reused.state.cells).toBe(reused.state.buffers.normal.cells);
expect(reused.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(
regular.state.cells.map((row: unknown[]) => lineCellsToText(row))
);
expect(reused.state.cursorRow).toBe(regular.state.cursorRow);
expect(reused.state.cursorCol).toBe(regular.state.cursorCol);
expect(reused.state.ansiState).toEqual(regular.state.ansiState);
});
it("1049 alt screen 切换后会保留 normal buffer 历史,并在退出时恢复", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "shell>", options);
const entered = applyTerminalOutput(base.state, "\u001b[?1049hTOP", options);
const restored = applyTerminalOutput(entered.state, "\u001b[?1049l", options);
expect(getActiveTerminalBuffer(entered.state).isAlt).toBe(true);
expect(entered.state.activeBufferName).toBe("alt");
expect(entered.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["TOP", "", "", ""]);
expect(restored.state.activeBufferName).toBe("normal");
expect(restored.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["shell>"]);
});
it("私有模式会驱动 Codex 依赖的关键终端模式位", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const hidden = applyTerminalOutput(
createEmptyState(),
"\u001b[?25l\u001b[?1h\u001b[?45h\u001b[?66h\u001b[?1004h\u001b[?2004h\u001b[4h",
options
);
const shown = applyTerminalOutput(
hidden.state,
"\u001b[?25h\u001b[?1l\u001b[?45l\u001b[?66l\u001b[?1004l\u001b[?2004l\u001b[4l",
options
);
expect(hidden.state.modes).toMatchObject({
cursorHidden: true,
applicationCursorKeys: true,
applicationKeypad: true,
reverseWraparound: true,
sendFocus: true,
bracketedPasteMode: true,
insertMode: true
});
expect(shown.state.modes).toMatchObject({
cursorHidden: false,
applicationCursorKeys: false,
applicationKeypad: false,
reverseWraparound: false,
sendFocus: false,
bracketedPasteMode: false,
insertMode: false
});
});
it("DSR/CPR 查询会生成响应,而不会污染屏幕内容", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "ab\u001b[6n\u001b[5n", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["ab"]);
expect(result.responses).toEqual(["\u001b[1;3R", "\u001b[0n"]);
});
it("DA1/DA2 查询会分别返回 primary 和 secondary device attributes", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "\u001b[c\u001b[>c", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
expect(result.responses).toEqual(["\u001b[?1;2c", "\u001b[>0;276;0c"]);
});
it("OSC 10/11/12 颜色查询会返回最小可用响应,而不会污染屏幕内容", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(
createEmptyState(),
"\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b]12;?\u001b\\",
options,
{
defaultForeground: "#112233",
defaultBackground: "#445566",
defaultCursor: "#778899"
}
);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
expect(result.responses).toEqual([
"\u001b]10;rgb:1111/2222/3333\u001b\\",
"\u001b]11;rgb:4444/5555/6666\u001b\\",
"\u001b]12;rgb:7777/8888/9999\u001b\\"
]);
});
it("normal buffer 的绝对定位会以当前可视尾部为基准,而不是写回历史顶部", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
const result = applyTerminalOutput(base.state, "\u001b[1;1HX", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"X",
"3",
"4",
"5"
]);
expect(result.responses).toEqual([]);
});
it("CSI B 在 normal buffer 中下移时会真实扩出目标行,而不是钳死在 viewport 内", () => {
const options = { bufferCols: 10, bufferRows: 2, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "\u001b[3BZ", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", "", "", "Z"]);
expect(result.state.cursorRow).toBe(3);
expect(result.state.cursorCol).toBe(1);
});
it("origin mode 下的 VPA/CUP 会以滚动区顶部为基准定位", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(
createEmptyState(),
"\u001b[2;4r\u001b[?6h\u001b[2dA\u001b[1;3HZ",
options
);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", " Z", "A"]);
expect(result.state.cursorRow).toBe(1);
expect(result.state.cursorCol).toBe(3);
});
it("normal buffer 有历史时,开启 origin mode 会把光标归位到当前滚动区顶部", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
const result = applyTerminalOutput(base.state, "\u001b[2;4r\u001b[?6hX", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"X",
"4",
"5"
]);
expect(result.state.cursorRow).toBe(2);
expect(result.state.cursorCol).toBe(1);
});
it("origin mode 下的 CUU/CUD/CNL/CPL 会被滚动区夹住,不越过固定区", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const movedUpDown = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[?6h\u001b[1;1H\u001b[AZ\u001b[3;1H\u001b[B#",
options
);
const movedPrevNextLine = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[?6h\u001b[1;3H\u001b[FZ\u001b[3;3H\u001b[EQ",
options
);
expect(movedUpDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "Z", "3", "#"]);
expect(movedPrevNextLine.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "Z", "3", "Q"]);
});
it("normal buffer 有历史时CUU/CPL 不会越过当前视口顶部并写回隐藏历史区", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
const movedUp = applyTerminalOutput(base.state, "\u001b[1;1H\u001b[AZ", options);
const movedPrevLine = applyTerminalOutput(base.state, "\u001b[1;3H\u001b[FQ", options);
expect(movedUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"Z",
"3",
"4",
"5"
]);
expect(movedUp.state.cursorRow).toBe(1);
expect(movedUp.state.cursorCol).toBe(1);
expect(movedPrevLine.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"Q",
"3",
"4",
"5"
]);
expect(movedPrevLine.state.cursorRow).toBe(1);
expect(movedPrevLine.state.cursorCol).toBe(1);
});
it("normal buffer 顶部滚动区上卷时会保留历史,并维持底部固定区", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4", options);
const result = applyTerminalOutput(base.state, "\u001b[1;3r\u001b[3;1H\n", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"3",
"",
"4"
]);
expect(result.state.cells.slice(-4).map((row: unknown[]) => lineCellsToText(row))).toEqual([
"2",
"3",
"",
"4"
]);
});
it("normal buffer 的 ESC M 会在局部滚动区顶部插入空行", () => {
const options = { bufferCols: 10, bufferRows: 6, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
const result = applyTerminalOutput(base.state, "\u001b[4;6r\u001b[4;1H\u001bM", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"3",
"",
"4",
"5"
]);
});
it("ESC D / ESC E / ESC M 在 alt buffer 固定头尾区域时,不会误滚中间滚动区", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const indResult = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[4;1H\u001bD#",
options
);
const nelResult = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[4;1H\u001bE#",
options
);
const riResult = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[1;1H\u001bM#",
options
);
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "2", "3", "#"]);
expect(indResult.state.cursorRow).toBe(3);
expect(indResult.state.cursorCol).toBe(1);
expect(nelResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "2", "3", "#"]);
expect(nelResult.state.cursorRow).toBe(3);
expect(nelResult.state.cursorCol).toBe(1);
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["#", "2", "3", "4"]);
expect(riResult.state.cursorRow).toBe(0);
expect(riResult.state.cursorCol).toBe(1);
});
it("ESC D / ESC M 在 normal buffer 固定头尾区域时,不会误滚正文区或回写历史区", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
const indResult = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[4;1H\u001bD#", options);
const riResult = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[1;1H\u001bM#", options);
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"3",
"4",
"5",
"#"
]);
expect(indResult.state.cursorRow).toBe(5);
expect(indResult.state.cursorCol).toBe(1);
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"#",
"4",
"5",
"6"
]);
expect(riResult.state.cursorRow).toBe(2);
expect(riResult.state.cursorCol).toBe(1);
});
it("normal buffer 的 CPR 会返回可视窗口内的行号,而不是历史绝对行号", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
const result = applyTerminalOutput(base.state, "\u001b[1;1H\u001b[6n", options);
expect(result.responses).toEqual(["\u001b[1;1R"]);
});
it("接近 codex /status 的 normal buffer 重排后,不会只停在 Permissions 这一行", () => {
const options = { bufferCols: 80, bufferRows: 24, maxEntries: 200, maxBytes: 8192 };
const historyText = Array.from({ length: 30 }, (_, index) => `pre${index + 1}`).join("\r\n");
const base = applyTerminalOutput(createEmptyState(), historyText, options);
const statusPayload =
"\u001b[18;1H\u001b[J\u001b[18;24r\u001b[18;1H\u001bM\u001bM\u001b[r\u001b[1;19r\u001b[17;1H" +
"\r\n/status\r\n\r\nStatusHeader\r\nModel\r\nDirectory\r\nPermissions\r\nAgents\r\nAccount\r\n" +
"Collaboration\r\nSession\r\nLimit5h\r\nLimitReset\r\nWeekly\r\nWeeklyReset\r\n" +
"\u001b[r\u001b[21;3H";
const result = applyTerminalOutput(base.state, statusPayload, options);
const visibleTail = result.state.cells.slice(-24).map((row: unknown[]) => lineCellsToText(row));
expect(visibleTail).toContain("Permissions");
expect(visibleTail).toContain("Weekly");
expect(visibleTail).toContain("WeeklyReset");
});
it("DECRQM 会按当前实现真实维护的模式位返回状态", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(
createEmptyState(),
"\u001b[?1004h\u001b[4h\u001b[?1049$p\u001b[?1004$p\u001b[4$p\u001b[?2026$p",
options
);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
expect(result.responses).toEqual([
"\u001b[?1049;2$y",
"\u001b[?1004;1$y",
"\u001b[4;1$y",
"\u001b[?2026;0$y"
]);
});
it("DCS $ q 状态字符串查询会返回最小可用响应", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(
createEmptyState(),
"\u001bP$qm\u001b\\\u001bP$qr\u001b\\\u001bP$q q\u001b\\\u001bP$qz\u001b\\",
options
);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
expect(result.responses).toEqual([
"\u001bP1$r0m\u001b\\",
"\u001bP1$r1;4r\u001b\\",
"\u001bP1$r2 q\u001b\\",
"\u001bP0$r\u001b\\"
]);
});
it("DECSTR 会软重置模式位、样式和滚动区域,但保留现有屏幕内容", () => {
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h\u001b[2;3r\u001b[?25l\u001b[?1h\u001b[?2004h\u001b[31mX\u001b[!p",
options
);
const active = getActiveTerminalBuffer(result.state);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["X", "", "", ""]);
expect(result.state.ansiState).toEqual(ANSI_RESET_STATE);
expect(result.state.modes).toMatchObject({
applicationCursorKeys: false,
originMode: false,
wraparound: true,
cursorHidden: false,
bracketedPasteMode: false
});
expect(active.scrollTop).toBe(0);
expect(active.scrollBottom).toBe(3);
expect(active.savedCursorRow).toBe(0);
expect(active.savedCursorCol).toBe(0);
expect(active.savedAnsiState).toEqual(ANSI_RESET_STATE);
});
it("CSI 2J 清屏后保留当前光标位置,后续输出不会错位到左上角", () => {
const options = { bufferCols: 6, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "ABCD\u001b[1;3H\u001b[2JX", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([" X", "", "", ""]);
expect(result.state.cursorRow).toBe(0);
expect(result.state.cursorCol).toBe(3);
});
it("带背景色的清屏会把整屏空白位也染成当前擦除背景,而不是只给文字底部上色", () => {
const options = { bufferCols: 6, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "\u001b[100m\u001b[2JX", options);
const firstRow = result.state.cells[0];
const secondRow = result.state.cells[1];
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["X", "", "", ""]);
expect(firstRow[1]).toMatchObject({
placeholder: true,
style: { bg: "#666666" }
});
expect(secondRow[0]).toMatchObject({
placeholder: true,
style: { bg: "#666666" }
});
});
it("CSI X 会从当前光标起按列擦除字符而不左移后续内容", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "abcdef\u001b[1;3H\u001b[2X", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["ab ef"]);
expect(result.state.cursorRow).toBe(0);
expect(result.state.cursorCol).toBe(2);
});
it("CSI @ / P 会按当前光标插删行内字符,而不是重写整行", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const inserted = applyTerminalOutput(createEmptyState(), "abcd\u001b[1;3H\u001b[@Z", options);
const deleted = applyTerminalOutput(createEmptyState(), "abcdef\u001b[1;3H\u001b[2P", options);
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZcd"]);
expect(inserted.state.cursorRow).toBe(0);
expect(inserted.state.cursorCol).toBe(3);
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abef"]);
expect(deleted.state.cursorRow).toBe(0);
expect(deleted.state.cursorCol).toBe(2);
});
it("CSI @ / P 切进宽字符中间时,不会留下悬空 continuation 或半个宽字符", () => {
const options = { bufferCols: 5, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const inserted = applyTerminalOutput(createEmptyState(), "A中BC\u001b[1;3H\u001b[@", options);
const deleted = applyTerminalOutput(createEmptyState(), "A中BC\u001b[1;2H\u001b[P", options);
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["A B"]);
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["A BC"]);
expect(
inserted.state.cells[0].some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)
).toBe(false);
expect(
deleted.state.cells[0].some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)
).toBe(false);
});
it("normal buffer 有历史和固定头尾时CSI L / M / S / T 只作用于当前滚动区", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
const inserted = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[2;1H\u001b[L", options);
const deleted = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[2;1H\u001b[M", options);
const scrolledUp = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[S", options);
const scrolledDown = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[T", options);
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"3",
"",
"4",
"6"
]);
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"3",
"5",
"",
"6"
]);
expect(scrolledUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"3",
"5",
"",
"6"
]);
expect(scrolledDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
"1",
"2",
"3",
"",
"4",
"6"
]);
});
it("CSI L / M 会在 alt buffer 当前行插删整行", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const inserted = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;1H\u001b[L",
options
);
const deleted = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;1H\u001b[M",
options
);
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "", "2", "3"]);
expect(inserted.state.cursorRow).toBe(1);
expect(inserted.state.cursorCol).toBe(0);
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
expect(deleted.state.cursorRow).toBe(1);
expect(deleted.state.cursorCol).toBe(0);
});
it("CSI S / T 会在当前滚动区内上卷和下卷,而不改动其它语义", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const scrolledUp = applyTerminalOutput(createEmptyState(), "\u001b[?1049h1\n2\n3\n4\u001b[S", options);
const scrolledDown = applyTerminalOutput(createEmptyState(), "\u001b[?1049h1\n2\n3\n4\u001b[T", options);
expect(scrolledUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["2", "3", "4", ""]);
expect(scrolledUp.state.cursorRow).toBe(3);
expect(scrolledUp.state.cursorCol).toBe(1);
expect(scrolledDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", "1", "2", "3"]);
expect(scrolledDown.state.cursorRow).toBe(3);
expect(scrolledDown.state.cursorCol).toBe(1);
});
it("CSI r 只给顶部参数时会把底边默认到最后一行", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4", options);
const result = applyTerminalOutput(base.state, "\u001b[2r\u001b[4;1H\n", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
expect(result.state.cursorRow).toBe(3);
expect(result.state.cursorCol).toBe(0);
});
it("insert mode 打开后,普通打印会按当前光标位置插入而不是覆盖", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "abcd\u001b[1;3H\u001b[4hZ", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZcd"]);
expect(result.state.cursorRow).toBe(0);
expect(result.state.cursorCol).toBe(3);
expect(result.state.modes.insertMode).toBe(true);
});
it("ESC D / ESC E / ESC M 会按滚动区域语义推进全屏缓冲", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const indResult = applyTerminalOutput(
createEmptyState(),
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[4;1H\u001bD",
options
);
const nelResult = applyTerminalOutput(indResult.state, "\u001bE#", options);
const riResult = applyTerminalOutput(nelResult.state, "\u001b[2;1H\u001bM", options);
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
expect(nelResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "4", "", "#"]);
expect(nelResult.state.cursorRow).toBe(3);
expect(nelResult.state.cursorCol).toBe(1);
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "", "4", ""]);
expect(riResult.state.cursorRow).toBe(1);
expect(riResult.state.cursorCol).toBe(0);
});
it("CSI s/u 与 ESC 7/8 会恢复之前保存的光标位置", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const csiResult = applyTerminalOutput(createEmptyState(), "ab\u001b[scd\u001b[uZ", options);
const escResult = applyTerminalOutput(createEmptyState(), "ab\u001b7cd\u001b8Z", options);
expect(csiResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZd"]);
expect(csiResult.state.cursorRow).toBe(0);
expect(csiResult.state.cursorCol).toBe(3);
expect(escResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZd"]);
expect(escResult.state.cursorRow).toBe(0);
expect(escResult.state.cursorCol).toBe(3);
});
it("带 > 私有标记的 CSI u 不应误当成光标恢复", () => {
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
const result = applyTerminalOutput(createEmptyState(), "abc\u001b[>7uZ", options);
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcZ"]);
expect(result.state.cursorRow).toBe(0);
expect(result.state.cursorCol).toBe(4);
});
});
function createEmptyState() {
return {
cells: [[]],
ansiState: { ...ANSI_RESET_STATE },
cursorRow: 0,
cursorCol: 0
};
}

View File

@@ -0,0 +1,296 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TerminalCaretSnapshot = {
left: number;
top: number;
height: number;
visible: boolean;
cursorRow: number;
cursorCol: number;
scrollTop: number;
rawTop: number;
rawLeft: number;
rectWidth: number;
rectHeight: number;
lineHeight: number;
charWidth: number;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
activeTerminalStdoutTask: Record<string, unknown> | null;
terminalStableCaretSnapshot: TerminalCaretSnapshot | null;
terminalPendingCaretSnapshot: TerminalCaretSnapshot | null;
terminalPendingCaretSince: number;
shellLineHeightPx?: number;
terminalPerf?: Record<string, unknown> | null;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
resolveStableTerminalCaret: (
caret: TerminalCaretSnapshot,
options?: Record<string, unknown>
) => TerminalCaretSnapshot | null;
resolveActivationBandFromCaretSnapshot: (
rect: Record<string, unknown>,
caret: TerminalCaretSnapshot | null,
cursorMetrics?: Record<string, unknown> | null
) => { top: number; height: number };
resetTerminalCaretStabilityState: () => void;
syncTerminalOverlay: (options?: Record<string, unknown>, callback?: (perf?: Record<string, unknown>) => void) => void;
resolveOutputScrollTopForRect: ReturnType<typeof vi.fn>;
getTerminalModes: ReturnType<typeof vi.fn>;
shouldLogTerminalPerfFrame: ReturnType<typeof vi.fn>;
logTerminalPerf: ReturnType<typeof vi.fn>;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
function buildCaretSnapshot(top: number, left = 12): TerminalCaretSnapshot {
return {
left,
top,
height: 21,
visible: true,
cursorRow: Math.max(0, Math.round(top / 21)),
cursorCol: Math.max(0, Math.round(left / 9)),
scrollTop: 0,
rawTop: top,
rawLeft: left,
rectWidth: 320,
rectHeight: 480,
lineHeight: 21,
charWidth: 9
};
}
function createTerminalPageHarness() {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const noop = () => {};
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getStorageSync: vi.fn(() => undefined),
setStorageSync: vi.fn(),
removeStorageSync: vi.fn(),
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
})),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false)
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
activeTerminalStdoutTask: null,
terminalStableCaretSnapshot: null,
terminalPendingCaretSnapshot: null,
terminalPendingCaretSince: 0,
shellLineHeightPx: 21,
terminalPerf: null,
setData(patch: Record<string, unknown>, callback?: () => void) {
Object.assign(this.data, patch);
callback?.();
}
} as TerminalPageInstance;
page.resolveOutputScrollTopForRect = vi.fn(() => 0);
page.getTerminalModes = vi.fn(() => ({ cursorHidden: false }));
page.shouldLogTerminalPerfFrame = vi.fn(() => false);
page.logTerminalPerf = vi.fn();
return { page };
}
describe("terminal caret stability", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("stdout in-flight 时,短时间跳到新位置仍保留上一个稳定 caret", () => {
let now = 1000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const { page } = createTerminalPageHarness();
page.activeTerminalStdoutTask = {
remainingText: "working"
};
const first = page.resolveStableTerminalCaret(buildCaretSnapshot(210), {
stabilizeDuringStdout: true
});
now = 1040;
const second = page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
stabilizeDuringStdout: true
});
expect(first?.top).toBe(210);
expect(second?.top).toBe(210);
});
it("同一位置超过稳定窗口后,会提交新的 caret 位置", () => {
let now = 2000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const { page } = createTerminalPageHarness();
page.activeTerminalStdoutTask = {
remainingText: "working"
};
page.resolveStableTerminalCaret(buildCaretSnapshot(210), {
stabilizeDuringStdout: true
});
now = 2040;
page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
stabilizeDuringStdout: true
});
now = 2180;
const stabilized = page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
stabilizeDuringStdout: true
});
expect(stabilized?.top).toBe(252);
});
it("最终帧会强制提交最新 caret不再继续冻结旧位置", () => {
let now = 3000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const { page } = createTerminalPageHarness();
page.activeTerminalStdoutTask = {
remainingText: "working"
};
page.resolveStableTerminalCaret(buildCaretSnapshot(210), {
stabilizeDuringStdout: true
});
now = 3040;
page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
stabilizeDuringStdout: true
});
now = 3060;
const finalCaret = page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
stabilizeDuringStdout: true,
forceCommit: true
});
expect(finalCaret?.top).toBe(252);
});
it("stdout in-flight 时,激活框应跟随稳定 caret而不是按实时位置单独跳动", () => {
let now = 4000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const { page } = createTerminalPageHarness();
page.activeTerminalStdoutTask = {
remainingText: "working"
};
page.data.statusClass = "connected";
page.data.activationDebugEnabled = true;
page.data.activationDebugVisible = true;
page.data.terminalCaretVisible = false;
page.data.terminalCaretTopPx = 0;
page.data.activationDebugTopPx = 0;
page.data.activationDebugHeightPx = 0;
const rect = {
width: 320,
height: 480
};
page.syncTerminalOverlay({
rect,
cursorMetrics: {
lineHeight: 21,
charWidth: 9,
paddingLeft: 12,
paddingRight: 8,
cursorRow: 10,
cursorCol: 1,
rows: 20
},
stabilizeCaretDuringStdout: true
});
expect(page.data.terminalCaretTopPx).toBe(210);
expect(page.data.activationDebugTopPx).toBe(168);
now = 4040;
page.syncTerminalOverlay({
rect,
cursorMetrics: {
lineHeight: 21,
charWidth: 9,
paddingLeft: 12,
paddingRight: 8,
cursorRow: 12,
cursorCol: 1,
rows: 20
},
stabilizeCaretDuringStdout: true
});
expect(page.data.terminalCaretTopPx).toBe(210);
expect(page.data.activationDebugTopPx).toBe(168);
});
});

View File

@@ -0,0 +1,638 @@
/* global module */
/**
* 终端 cell / 光标 / 视觉行的纯函数模型。
*
* 顶级约束(后续扩展 VT / DEC private mode 时不得回退):
* 1. 逻辑光标始终按“列”推进,而不是按字符串长度推进。
* 2. 宽字符占 2 列,并在缓冲区中留下一个 continuation 占位格。
* 3. 组合字符只附着到前一个 owner cell不额外推进列。
* 4. 视觉行由 JS 统一切分,避免输出展示和光标锚点使用两套换行系统。
* 5. 后续若补 alternate screen、scroll region 或更多 CSI/DECSET也必须继续复用同一套
* cell 契约,不能把“私有模式支持”做成字符串级补丁。
*/
function isWideCodePoint(codePoint) {
if (!Number.isFinite(codePoint) || codePoint <= 0) return false;
return (
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
);
}
function isZeroWidthCodePoint(codePoint) {
if (!Number.isFinite(codePoint) || codePoint <= 0) return false;
return (
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0x0483 && codePoint <= 0x0489) ||
(codePoint >= 0x0591 && codePoint <= 0x05bd) ||
codePoint === 0x05bf ||
(codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
(codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
codePoint === 0x05c7 ||
(codePoint >= 0x0610 && codePoint <= 0x061a) ||
(codePoint >= 0x064b && codePoint <= 0x065f) ||
codePoint === 0x0670 ||
(codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
(codePoint >= 0x06df && codePoint <= 0x06e4) ||
(codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
(codePoint >= 0x06ea && codePoint <= 0x06ed) ||
(codePoint >= 0x0711 && codePoint <= 0x0711) ||
(codePoint >= 0x0730 && codePoint <= 0x074a) ||
(codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
(codePoint >= 0x07eb && codePoint <= 0x07f3) ||
(codePoint >= 0x0816 && codePoint <= 0x0819) ||
(codePoint >= 0x081b && codePoint <= 0x0823) ||
(codePoint >= 0x0825 && codePoint <= 0x0827) ||
(codePoint >= 0x0829 && codePoint <= 0x082d) ||
(codePoint >= 0x0859 && codePoint <= 0x085b) ||
(codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
(codePoint >= 0x08e3 && codePoint <= 0x0903) ||
(codePoint >= 0x093a && codePoint <= 0x093c) ||
codePoint === 0x0941 ||
codePoint === 0x0942 ||
(codePoint >= 0x094d && codePoint <= 0x094d) ||
(codePoint >= 0x0951 && codePoint <= 0x0957) ||
(codePoint >= 0x0962 && codePoint <= 0x0963) ||
(codePoint >= 0x0981 && codePoint <= 0x0981) ||
(codePoint >= 0x09bc && codePoint <= 0x09bc) ||
codePoint === 0x09cd ||
(codePoint >= 0x09e2 && codePoint <= 0x09e3) ||
codePoint === 0x0a01 ||
codePoint === 0x0a02 ||
codePoint === 0x0a3c ||
codePoint === 0x0a41 ||
codePoint === 0x0a42 ||
codePoint === 0x0a47 ||
codePoint === 0x0a48 ||
codePoint === 0x0a4b ||
codePoint === 0x0a4c ||
codePoint === 0x0a4d ||
(codePoint >= 0x0a51 && codePoint <= 0x0a51) ||
(codePoint >= 0x0a70 && codePoint <= 0x0a71) ||
(codePoint >= 0x0a75 && codePoint <= 0x0a75) ||
codePoint === 0x0abc ||
codePoint === 0x0ac1 ||
codePoint === 0x0ac2 ||
codePoint === 0x0acd ||
(codePoint >= 0x0ae2 && codePoint <= 0x0ae3) ||
(codePoint >= 0x0b01 && codePoint <= 0x0b01) ||
codePoint === 0x0b3c ||
codePoint === 0x0b3f ||
codePoint === 0x0b41 ||
codePoint === 0x0b42 ||
codePoint === 0x0b4d ||
(codePoint >= 0x0b56 && codePoint <= 0x0b56) ||
(codePoint >= 0x0b62 && codePoint <= 0x0b63) ||
(codePoint >= 0x0b82 && codePoint <= 0x0b82) ||
codePoint === 0x0bc0 ||
codePoint === 0x0bcd ||
codePoint === 0x0c00 ||
codePoint === 0x0c04 ||
(codePoint >= 0x0c3e && codePoint <= 0x0c40) ||
codePoint === 0x0c46 ||
codePoint === 0x0c47 ||
codePoint === 0x0c4a ||
codePoint === 0x0c4b ||
codePoint === 0x0c4d ||
(codePoint >= 0x0c55 && codePoint <= 0x0c56) ||
(codePoint >= 0x0c62 && codePoint <= 0x0c63) ||
(codePoint >= 0x0c81 && codePoint <= 0x0c81) ||
codePoint === 0x0cbc ||
codePoint === 0x0cbf ||
codePoint === 0x0cc6 ||
codePoint === 0x0ccc ||
codePoint === 0x0ccd ||
(codePoint >= 0x0ce2 && codePoint <= 0x0ce3) ||
codePoint === 0x0d00 ||
codePoint === 0x0d01 ||
codePoint === 0x0d3b ||
codePoint === 0x0d3c ||
codePoint === 0x0d41 ||
codePoint === 0x0d42 ||
codePoint === 0x0d4d ||
(codePoint >= 0x0d62 && codePoint <= 0x0d63) ||
codePoint === 0x0dca ||
(codePoint >= 0x0dd2 && codePoint <= 0x0dd4) ||
codePoint === 0x0dd6 ||
codePoint === 0x0e31 ||
(codePoint >= 0x0e34 && codePoint <= 0x0e3a) ||
(codePoint >= 0x0e47 && codePoint <= 0x0e4e) ||
codePoint === 0x0eb1 ||
(codePoint >= 0x0eb4 && codePoint <= 0x0ebc) ||
(codePoint >= 0x0ec8 && codePoint <= 0x0ece) ||
codePoint === 0x0f18 ||
codePoint === 0x0f19 ||
codePoint === 0x0f35 ||
codePoint === 0x0f37 ||
codePoint === 0x0f39 ||
(codePoint >= 0x0f71 && codePoint <= 0x0f7e) ||
(codePoint >= 0x0f80 && codePoint <= 0x0f84) ||
(codePoint >= 0x0f86 && codePoint <= 0x0f87) ||
(codePoint >= 0x0f8d && codePoint <= 0x0f97) ||
(codePoint >= 0x0f99 && codePoint <= 0x0fbc) ||
codePoint === 0x0fc6 ||
(codePoint >= 0x102d && codePoint <= 0x1030) ||
codePoint === 0x1032 ||
(codePoint >= 0x1036 && codePoint <= 0x1037) ||
codePoint === 0x1039 ||
codePoint === 0x103a ||
(codePoint >= 0x103d && codePoint <= 0x103e) ||
(codePoint >= 0x1058 && codePoint <= 0x1059) ||
(codePoint >= 0x105e && codePoint <= 0x1060) ||
(codePoint >= 0x1071 && codePoint <= 0x1074) ||
codePoint === 0x1082 ||
(codePoint >= 0x1085 && codePoint <= 0x1086) ||
codePoint === 0x108d ||
codePoint === 0x109d ||
(codePoint >= 0x135d && codePoint <= 0x135f) ||
(codePoint >= 0x1712 && codePoint <= 0x1714) ||
(codePoint >= 0x1732 && codePoint <= 0x1734) ||
(codePoint >= 0x1752 && codePoint <= 0x1753) ||
(codePoint >= 0x1772 && codePoint <= 0x1773) ||
(codePoint >= 0x17b4 && codePoint <= 0x17b5) ||
(codePoint >= 0x17b7 && codePoint <= 0x17bd) ||
codePoint === 0x17c6 ||
(codePoint >= 0x17c9 && codePoint <= 0x17d3) ||
codePoint === 0x17dd ||
(codePoint >= 0x180b && codePoint <= 0x180f) ||
(codePoint >= 0x1885 && codePoint <= 0x1886) ||
codePoint === 0x18a9 ||
(codePoint >= 0x1920 && codePoint <= 0x1922) ||
(codePoint >= 0x1927 && codePoint <= 0x1928) ||
codePoint === 0x1932 ||
(codePoint >= 0x1939 && codePoint <= 0x193b) ||
(codePoint >= 0x1a17 && codePoint <= 0x1a18) ||
codePoint === 0x1a1b ||
codePoint === 0x1a56 ||
(codePoint >= 0x1a58 && codePoint <= 0x1a5e) ||
codePoint === 0x1a60 ||
codePoint === 0x1a62 ||
(codePoint >= 0x1a65 && codePoint <= 0x1a6c) ||
(codePoint >= 0x1a73 && codePoint <= 0x1a7c) ||
codePoint === 0x1a7f ||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
(codePoint >= 0x1b00 && codePoint <= 0x1b03) ||
codePoint === 0x1b34 ||
codePoint === 0x1b36 ||
codePoint === 0x1b37 ||
codePoint === 0x1b3c ||
codePoint === 0x1b42 ||
(codePoint >= 0x1b6b && codePoint <= 0x1b73) ||
(codePoint >= 0x1b80 && codePoint <= 0x1b81) ||
codePoint === 0x1ba2 ||
codePoint === 0x1ba5 ||
codePoint === 0x1ba8 ||
codePoint === 0x1ba9 ||
(codePoint >= 0x1bab && codePoint <= 0x1bad) ||
codePoint === 0x1be6 ||
codePoint === 0x1be8 ||
codePoint === 0x1be9 ||
codePoint === 0x1bed ||
(codePoint >= 0x1bef && codePoint <= 0x1bf1) ||
(codePoint >= 0x1c2c && codePoint <= 0x1c33) ||
codePoint === 0x1c36 ||
codePoint === 0x1c37 ||
(codePoint >= 0x1cd0 && codePoint <= 0x1cd2) ||
(codePoint >= 0x1cd4 && codePoint <= 0x1ce0) ||
(codePoint >= 0x1ce2 && codePoint <= 0x1ce8) ||
codePoint === 0x1ced ||
codePoint === 0x1cf4 ||
codePoint === 0x1cf8 ||
codePoint === 0x1cf9 ||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
codePoint === 0x202a ||
codePoint === 0x202b ||
codePoint === 0x202c ||
codePoint === 0x202d ||
codePoint === 0x202e ||
codePoint === 0x2060 ||
(codePoint >= 0x2066 && codePoint <= 0x206f) ||
codePoint === 0x200c ||
codePoint === 0x200d ||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
(codePoint >= 0x2cef && codePoint <= 0x2cf1) ||
codePoint === 0x2d7f ||
(codePoint >= 0x2de0 && codePoint <= 0x2dff) ||
(codePoint >= 0x302a && codePoint <= 0x302f) ||
codePoint === 0x3099 ||
codePoint === 0x309a ||
(codePoint >= 0xa66f && codePoint <= 0xa672) ||
codePoint === 0xa674 ||
codePoint === 0xa67d ||
codePoint === 0xa69e ||
codePoint === 0xa69f ||
(codePoint >= 0xa6f0 && codePoint <= 0xa6f1) ||
codePoint === 0xa802 ||
codePoint === 0xa806 ||
codePoint === 0xa80b ||
(codePoint >= 0xa825 && codePoint <= 0xa826) ||
codePoint === 0xa82c ||
(codePoint >= 0xa8c4 && codePoint <= 0xa8c5) ||
(codePoint >= 0xa8e0 && codePoint <= 0xa8f1) ||
codePoint === 0xa8ff ||
(codePoint >= 0xa926 && codePoint <= 0xa92d) ||
(codePoint >= 0xa947 && codePoint <= 0xa951) ||
(codePoint >= 0xa980 && codePoint <= 0xa982) ||
codePoint === 0xa9b3 ||
(codePoint >= 0xa9b6 && codePoint <= 0xa9b9) ||
codePoint === 0xa9bc ||
codePoint === 0xa9e5 ||
(codePoint >= 0xaa29 && codePoint <= 0xaa2e) ||
(codePoint >= 0xaa31 && codePoint <= 0xaa32) ||
(codePoint >= 0xaa35 && codePoint <= 0xaa36) ||
codePoint === 0xaa43 ||
codePoint === 0xaa4c ||
codePoint === 0xaa7c ||
codePoint === 0xaab0 ||
(codePoint >= 0xaab2 && codePoint <= 0xaab4) ||
(codePoint >= 0xaab7 && codePoint <= 0xaab8) ||
codePoint === 0xaabe ||
codePoint === 0xaabf ||
codePoint === 0xaac1 ||
(codePoint >= 0xaaec && codePoint <= 0xaaed) ||
codePoint === 0xaaf6 ||
(codePoint >= 0xabe5 && codePoint <= 0xabe5) ||
codePoint === 0xabe8 ||
codePoint === 0xabed ||
codePoint === 0xfb1e ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
(codePoint >= 0xfeff && codePoint <= 0xfeff) ||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
(codePoint >= 0x101fd && codePoint <= 0x101fd) ||
(codePoint >= 0x102e0 && codePoint <= 0x102e0) ||
(codePoint >= 0x10376 && codePoint <= 0x1037a) ||
(codePoint >= 0x10a01 && codePoint <= 0x10a03) ||
(codePoint >= 0x10a05 && codePoint <= 0x10a06) ||
(codePoint >= 0x10a0c && codePoint <= 0x10a0f) ||
(codePoint >= 0x10a38 && codePoint <= 0x10a3a) ||
codePoint === 0x10a3f ||
(codePoint >= 0x10ae5 && codePoint <= 0x10ae6) ||
(codePoint >= 0x11001 && codePoint <= 0x11001) ||
(codePoint >= 0x11038 && codePoint <= 0x11046) ||
(codePoint >= 0x1107f && codePoint <= 0x11081) ||
(codePoint >= 0x110b3 && codePoint <= 0x110b6) ||
(codePoint >= 0x110b9 && codePoint <= 0x110ba) ||
(codePoint >= 0x11100 && codePoint <= 0x11102) ||
(codePoint >= 0x11127 && codePoint <= 0x1112b) ||
(codePoint >= 0x1112d && codePoint <= 0x11134) ||
codePoint === 0x11173 ||
(codePoint >= 0x11180 && codePoint <= 0x11181) ||
(codePoint >= 0x111b6 && codePoint <= 0x111be) ||
codePoint === 0x111c9 ||
(codePoint >= 0x1122f && codePoint <= 0x11231) ||
codePoint === 0x11234 ||
(codePoint >= 0x11236 && codePoint <= 0x11237) ||
codePoint === 0x112df ||
(codePoint >= 0x112e3 && codePoint <= 0x112ea) ||
(codePoint >= 0x11300 && codePoint <= 0x11301) ||
codePoint === 0x1133c ||
(codePoint >= 0x11340 && codePoint <= 0x11340) ||
codePoint === 0x11366 ||
codePoint === 0x11367 ||
codePoint === 0x1136c ||
codePoint === 0x11370 ||
(codePoint >= 0x11438 && codePoint <= 0x1143f) ||
(codePoint >= 0x11442 && codePoint <= 0x11444) ||
codePoint === 0x11446 ||
codePoint === 0x1145e ||
(codePoint >= 0x114b3 && codePoint <= 0x114b8) ||
codePoint === 0x114ba ||
(codePoint >= 0x114bf && codePoint <= 0x114c0) ||
codePoint === 0x114c2 ||
(codePoint >= 0x115b2 && codePoint <= 0x115b5) ||
(codePoint >= 0x115bc && codePoint <= 0x115bd) ||
codePoint === 0x115bf ||
codePoint === 0x115c0 ||
(codePoint >= 0x11633 && codePoint <= 0x1163a) ||
codePoint === 0x1163d ||
codePoint === 0x1163f ||
codePoint === 0x11640 ||
(codePoint >= 0x116ab && codePoint <= 0x116ab) ||
codePoint === 0x116ad ||
(codePoint >= 0x116b0 && codePoint <= 0x116b5) ||
codePoint === 0x116b7 ||
(codePoint >= 0x1171d && codePoint <= 0x1171f) ||
(codePoint >= 0x11722 && codePoint <= 0x11725) ||
(codePoint >= 0x11727 && codePoint <= 0x1172b) ||
(codePoint >= 0x1182f && codePoint <= 0x11837) ||
codePoint === 0x11839 ||
codePoint === 0x11a01 ||
(codePoint >= 0x11a33 && codePoint <= 0x11a38) ||
(codePoint >= 0x11a3b && codePoint <= 0x11a3e) ||
codePoint === 0x11a47 ||
(codePoint >= 0x11a51 && codePoint <= 0x11a56) ||
codePoint === 0x11a59 ||
codePoint === 0x11a5b ||
codePoint === 0x11a8a ||
(codePoint >= 0x11a91 && codePoint <= 0x11a96) ||
codePoint === 0x11a98 ||
codePoint === 0x11c30 ||
(codePoint >= 0x11c38 && codePoint <= 0x11c3d) ||
codePoint === 0x11c3f ||
(codePoint >= 0x11c92 && codePoint <= 0x11ca7) ||
codePoint === 0x11caa ||
codePoint === 0x11cb0 ||
codePoint === 0x11cb2 ||
codePoint === 0x11cb3 ||
codePoint === 0x11cb5 ||
codePoint === 0x11cb6 ||
(codePoint >= 0x11d31 && codePoint <= 0x11d36) ||
codePoint === 0x11d3a ||
codePoint === 0x11d3c ||
codePoint === 0x11d3d ||
codePoint === 0x11d3f ||
codePoint === 0x11d40 ||
codePoint === 0x11d42 ||
(codePoint >= 0x11d44 && codePoint <= 0x11d45) ||
codePoint === 0x11d47 ||
(codePoint >= 0x16af0 && codePoint <= 0x16af4) ||
(codePoint >= 0x16b30 && codePoint <= 0x16b36) ||
(codePoint >= 0x16f8f && codePoint <= 0x16f92) ||
(codePoint >= 0x1bc9d && codePoint <= 0x1bc9e) ||
codePoint === 0x1d167 ||
codePoint === 0x1d168 ||
codePoint === 0x1d169 ||
(codePoint >= 0x1d17b && codePoint <= 0x1d182) ||
(codePoint >= 0x1d185 && codePoint <= 0x1d18b) ||
(codePoint >= 0x1d1aa && codePoint <= 0x1d1ad) ||
(codePoint >= 0x1d242 && codePoint <= 0x1d244) ||
(codePoint >= 0x1da00 && codePoint <= 0x1da36) ||
(codePoint >= 0x1da3b && codePoint <= 0x1da6c) ||
codePoint === 0x1da75 ||
codePoint === 0x1da84 ||
(codePoint >= 0x1da9b && codePoint <= 0x1da9f) ||
(codePoint >= 0x1daa1 && codePoint <= 0x1daaf) ||
(codePoint >= 0x1e000 && codePoint <= 0x1e006) ||
(codePoint >= 0x1e008 && codePoint <= 0x1e018) ||
(codePoint >= 0x1e01b && codePoint <= 0x1e021) ||
(codePoint >= 0x1e023 && codePoint <= 0x1e024) ||
(codePoint >= 0x1e026 && codePoint <= 0x1e02a) ||
(codePoint >= 0x1e130 && codePoint <= 0x1e136) ||
(codePoint >= 0x1e2ae && codePoint <= 0x1e2ae) ||
(codePoint >= 0x1e2ec && codePoint <= 0x1e2ef) ||
(codePoint >= 0x1e8d0 && codePoint <= 0x1e8d6) ||
(codePoint >= 0x1e944 && codePoint <= 0x1e94a) ||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
);
}
function measureCharDisplayColumns(ch) {
const text = String(ch || "");
if (!text) return 0;
if (text === "\t") return 4;
const codePoint = text.codePointAt(0);
if (!Number.isFinite(codePoint)) return 1;
if (codePoint <= 0x1f || codePoint === 0x7f) return 0;
if (isZeroWidthCodePoint(codePoint)) return 0;
return isWideCodePoint(codePoint) ? 2 : 1;
}
function createTerminalCell(text, style, width) {
return {
text: String(text || ""),
style: style || null,
width: Math.max(0, Math.min(2, Math.round(Number(width) || 0))),
continuation: false,
placeholder: false
};
}
/**
* 擦除/补位产生的“空白单元”需要占据一列,但不应在文本快照里永久变成尾随空格。
* 因此这里单独打上 placeholder 标记,供渲染层保留宽度,文本层按需裁剪。
*/
function createBlankCell(style) {
return {
text: "",
style: style || null,
width: 1,
continuation: false,
placeholder: true
};
}
function createContinuationCell(style) {
return {
text: "",
style: style || null,
width: 0,
continuation: true,
placeholder: false
};
}
function cloneTerminalCell(cell) {
return {
text: String((cell && cell.text) || ""),
style: cell && cell.style ? { ...cell.style } : null,
width: Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0))),
continuation: !!(cell && cell.continuation),
placeholder: !!(cell && cell.placeholder)
};
}
function lineCellsToText(lineCells) {
const cells = Array.isArray(lineCells) ? lineCells : [];
if (cells.length === 0) return "";
let lastMeaningfulIndex = -1;
for (let index = cells.length - 1; index >= 0; index -= 1) {
const cell = cells[index];
const width = Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0)));
if (width <= 0 || (cell && cell.continuation)) {
continue;
}
if (cell && cell.placeholder) {
continue;
}
lastMeaningfulIndex = index;
break;
}
if (lastMeaningfulIndex < 0) {
return "";
}
let result = "";
for (let index = 0; index <= lastMeaningfulIndex; index += 1) {
const cell = cells[index];
const width = Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0)));
if (width <= 0 || (cell && cell.continuation)) {
continue;
}
if (cell && cell.placeholder) {
result += " ";
continue;
}
result += String((cell && cell.text) || "");
}
return result;
}
function measureLineCellsDisplayColumns(lineCells) {
const cells = Array.isArray(lineCells) ? lineCells : [];
if (cells.length === 0) return 0;
let columns = 0;
for (let i = 0; i < cells.length; i += 1) {
const cell = cells[i];
const width = Math.max(0, Math.round(Number(cell && cell.width) || 0));
if (width > 0) {
columns += width;
}
}
return Math.max(0, columns);
}
function buildTerminalStyleSignature(style) {
const source = style || null;
if (!source) return "||0|0";
return `${source.fg || ""}|${source.bg || ""}|${source.bold ? 1 : 0}|${source.underline ? 1 : 0}`;
}
/**
* 若一整行的所有 render run 都共享同一个非空背景色,
* 则允许把背景提升到 line 容器层绘制,避免 text 行盒之间露出底色细缝。
*
* 约束:
* 1. 只在“整行统一背景”时返回颜色;
* 2. 一旦某个 run 没有背景或背景不同,立即回退为空串,保持旧语义。
*/
function resolveUniformLineBackground(runs) {
const source = Array.isArray(runs) ? runs : [];
let background = "";
let hasBackground = false;
for (let index = 0; index < source.length; index += 1) {
const run = source[index];
if (!run || Math.max(0, Math.round(Number(run.columns) || 0)) <= 0) {
continue;
}
const runBackground = String((run.style && run.style.bg) || "");
if (!runBackground) {
return "";
}
if (!hasBackground) {
background = runBackground;
hasBackground = true;
continue;
}
if (runBackground !== background) {
return "";
}
}
return hasBackground ? background : "";
}
/**
* 把一行固定列缓冲区转换成更适合 UI 渲染的 run
* 1. continuation 占位格不再直接落回自然文本流;
* 2. 宽字符 owner 独立成 fixed run供视图层按 2 列宽渲染;
* 3. 连续的窄字符仍按样式合并,避免节点数量爆炸。
*/
function buildLineCellRenderRuns(lineCells) {
const cells = Array.isArray(lineCells) ? lineCells : [];
if (cells.length === 0) return [];
const runs = [];
let pendingTextRun = null;
let pendingBlankRun = null;
const flushPendingTextRun = () => {
if (!pendingTextRun) return;
runs.push({
text: pendingTextRun.text,
style: pendingTextRun.style ? { ...pendingTextRun.style } : null,
columns: pendingTextRun.columns,
fixed: false
});
pendingTextRun = null;
};
const flushPendingBlankRun = () => {
if (!pendingBlankRun) return;
runs.push({
text: "",
style: pendingBlankRun.style ? { ...pendingBlankRun.style } : null,
columns: pendingBlankRun.columns,
fixed: true
});
pendingBlankRun = null;
};
for (let i = 0; i < cells.length; i += 1) {
const cell = cells[i];
const width = Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0)));
if (width <= 0 || (cell && cell.continuation)) {
continue;
}
const text = String((cell && cell.text) || "");
const style = cell && cell.style ? cell.style : null;
const styleKey = buildTerminalStyleSignature(style);
const isPlaceholder = !!(cell && cell.placeholder);
if (isPlaceholder) {
flushPendingTextRun();
if (!pendingBlankRun || pendingBlankRun.styleKey !== styleKey) {
flushPendingBlankRun();
pendingBlankRun = {
style,
styleKey,
columns: width
};
continue;
}
pendingBlankRun.columns += width;
continue;
}
flushPendingBlankRun();
if (width > 1) {
flushPendingTextRun();
runs.push({
text,
style: style ? { ...style } : null,
columns: width,
fixed: true
});
continue;
}
if (!pendingTextRun || pendingTextRun.styleKey !== styleKey) {
flushPendingTextRun();
pendingTextRun = {
text,
style,
styleKey,
columns: 1
};
continue;
}
pendingTextRun.text += text;
pendingTextRun.columns += 1;
}
flushPendingTextRun();
flushPendingBlankRun();
return runs;
}
module.exports = {
buildLineCellRenderRuns,
createBlankCell,
cloneTerminalCell,
createContinuationCell,
createTerminalCell,
lineCellsToText,
measureCharDisplayColumns,
measureLineCellsDisplayColumns,
resolveUniformLineBackground
};

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
const {
buildLineCellRenderRuns,
createBlankCell,
cloneTerminalCell,
createContinuationCell,
createTerminalCell,
lineCellsToText,
measureCharDisplayColumns,
measureLineCellsDisplayColumns,
resolveUniformLineBackground
} = require("./terminalCursorModel.js");
describe("terminalCursorModel", () => {
it("按 cell 列宽处理宽字符和组合字符", () => {
expect(measureCharDisplayColumns("a")).toBe(1);
expect(measureCharDisplayColumns("中")).toBe(2);
expect(measureCharDisplayColumns("\u0301")).toBe(0);
expect(measureCharDisplayColumns("😀")).toBe(2);
});
it("按固定列缓冲区统计显示列宽,不把 continuation 重复计数", () => {
const row = [
createTerminalCell("A", null, 1),
createTerminalCell("中", null, 2),
createContinuationCell(null),
createTerminalCell("B", null, 1)
];
expect(lineCellsToText(row)).toBe("A中B");
expect(measureLineCellsDisplayColumns(row)).toBe(4);
});
it("渲染 run 会把宽字符 owner 独立出来,并跳过 continuation 占位格", () => {
const styleA = { fg: "#fff", bg: "", bold: false, underline: false };
const styleB = { fg: "#0f0", bg: "", bold: false, underline: false };
const row = [
createTerminalCell("A", styleA, 1),
createTerminalCell("B", styleA, 1),
createTerminalCell("中", styleA, 2),
createContinuationCell(styleA),
createTerminalCell("C", styleB, 1)
];
expect(buildLineCellRenderRuns(row)).toEqual([
{ text: "AB", style: styleA, columns: 2, fixed: false },
{ text: "中", style: styleA, columns: 2, fixed: true },
{ text: "C", style: styleB, columns: 1, fixed: false }
]);
});
it("placeholder blank cell 会保留列宽背景,但不会把尾随补位写进文本快照", () => {
const style = { fg: "", bg: "#666666", bold: false, underline: false };
const row = [
createBlankCell(style),
createBlankCell(style),
createTerminalCell("X", style, 1),
createBlankCell(style),
createBlankCell(style)
];
expect(lineCellsToText(row)).toBe(" X");
expect(buildLineCellRenderRuns(row)).toEqual([
{ text: "", style, columns: 2, fixed: true },
{ text: "X", style, columns: 1, fixed: false },
{ text: "", style, columns: 2, fixed: true }
]);
});
it("整行 run 若共享同一个非空背景色,会返回可提升到 line 层的背景", () => {
const bg = "#203040";
const styleA = { fg: "#ffffff", bg, bold: false, underline: false };
const styleB = { fg: "#89c2ff", bg, bold: true, underline: false };
const row = [
createBlankCell(styleA),
createTerminalCell(">", styleA, 1),
createTerminalCell(" ", styleA, 1),
createTerminalCell("U", styleB, 1),
createTerminalCell("s", styleB, 1),
createTerminalCell("e", styleB, 1),
createBlankCell(styleB)
];
expect(resolveUniformLineBackground(buildLineCellRenderRuns(row))).toBe(bg);
});
it("只要一行里存在无背景或不同背景的 run就不会提升到 line 层", () => {
const rowWithGap = [
createTerminalCell("A", { fg: "#fff", bg: "#111111", bold: false, underline: false }, 1),
createTerminalCell("B", { fg: "#fff", bg: "", bold: false, underline: false }, 1)
];
const rowWithMixedBg = [
createTerminalCell("A", { fg: "#fff", bg: "#111111", bold: false, underline: false }, 1),
createTerminalCell("B", { fg: "#fff", bg: "#222222", bold: false, underline: false }, 1)
];
expect(resolveUniformLineBackground(buildLineCellRenderRuns(rowWithGap))).toBe("");
expect(resolveUniformLineBackground(buildLineCellRenderRuns(rowWithMixedBg))).toBe("");
});
it("克隆 cell 时会复制样式对象,避免覆盖时污染原对象", () => {
const original = createTerminalCell("A", { fg: "#fff", bold: true }, 1);
const cloned = cloneTerminalCell(original);
cloned.style.fg = "#000";
expect(original.style.fg).toBe("#fff");
expect(cloned.style.fg).toBe("#000");
expect(cloned.width).toBe(1);
});
});

View File

@@ -0,0 +1,220 @@
/* global module */
const DEFAULT_TERMINAL_KEY_MODES = Object.freeze({
applicationCursorKeys: false,
applicationKeypad: false,
bracketedPasteMode: false
});
const DEFAULT_TERMINAL_KEY_MODIFIERS = Object.freeze({
shift: false
});
const CTRL_KEY_MAP = Object.freeze({
ctrla: "\u0001",
ctrlc: "\u0003",
ctrld: "\u0004",
ctrle: "\u0005",
ctrlk: "\u000b",
ctrll: "\u000c",
ctrlu: "\u0015",
ctrlw: "\u0017",
ctrlz: "\u001a"
});
/**
* 触屏方向区直接对应 Figma frame 2250 内部的四向布局。
* 这里保留静态资源路径作为单一真相源,页面层可按主题把路径再映射为 data URI。
*/
const TERMINAL_TOUCH_DIRECTION_KEYS = Object.freeze([
Object.freeze({
key: "up",
icon: "/assets/icons/up.svg",
slotClass: "terminal-touch-direction-btn-up"
}),
Object.freeze({
key: "left",
icon: "/assets/icons/left.svg",
slotClass: "terminal-touch-direction-btn-left"
}),
Object.freeze({
key: "down",
icon: "/assets/icons/down.svg",
slotClass: "terminal-touch-direction-btn-down"
}),
Object.freeze({
key: "right",
icon: "/assets/icons/right.svg",
slotClass: "terminal-touch-direction-btn-right"
})
]);
/**
* SH 键盘区只保留最常用的辅助键:
* 1. 仅保留移动端高频键,`home` 复用为“回到服务器工作目录”快捷键;
* 2. Figma 这一版左列示意用了 backspace/shift带 backspace 图标的按钮发送真实退格序列;
* 3. 页面层会把 shift 当成“输入框大写状态键”,这里仅保留图标和基础 key
* 4. 方向键承担基础导航,纵向操作区保留 enter/home/shift/backspace/paste/esc/ctrl+c/tab
* 5. paste 走独立剪贴板链路home 走页面层 shell 命令,其余按钮统一映射为 VT 控制序列。
*/
const TERMINAL_TOUCH_ACTION_BUTTONS = Object.freeze([
Object.freeze({
key: "enter",
icon: "/assets/icons/enter.svg",
action: "control",
slotClass: "terminal-touch-action-btn-enter"
}),
Object.freeze({
key: "home",
icon: "/assets/icons/home.svg",
action: "control",
slotClass: "terminal-touch-action-btn-home"
}),
Object.freeze({
key: "shift",
icon: "/assets/icons/shift.svg",
action: "modifier",
slotClass: "terminal-touch-action-btn-shift"
}),
Object.freeze({
key: "backspace",
icon: "/assets/icons/backspace.svg",
action: "control",
slotClass: "terminal-touch-action-btn-delete"
}),
Object.freeze({
key: "paste",
icon: "/assets/icons/paste.svg",
action: "paste",
slotClass: "terminal-touch-action-btn-paste"
}),
Object.freeze({
key: "esc",
icon: "/assets/icons/esc.svg",
action: "control",
slotClass: "terminal-touch-action-btn-esc"
}),
Object.freeze({
key: "ctrlc",
icon: "/assets/icons/ctrlc.svg",
action: "control",
slotClass: "terminal-touch-action-btn-ctrlc"
}),
Object.freeze({
key: "tab",
icon: "/assets/icons/tab.svg",
action: "control",
slotClass: "terminal-touch-action-btn-tab"
})
]);
function normalizeTerminalKeyModes(input) {
const source = input && typeof input === "object" ? input : null;
return {
applicationCursorKeys:
source && source.applicationCursorKeys !== undefined
? !!source.applicationCursorKeys
: DEFAULT_TERMINAL_KEY_MODES.applicationCursorKeys,
applicationKeypad:
source && source.applicationKeypad !== undefined
? !!source.applicationKeypad
: DEFAULT_TERMINAL_KEY_MODES.applicationKeypad,
bracketedPasteMode:
source && source.bracketedPasteMode !== undefined
? !!source.bracketedPasteMode
: DEFAULT_TERMINAL_KEY_MODES.bracketedPasteMode
};
}
function normalizeTerminalKeyModifiers(input) {
const source = input && typeof input === "object" ? input : null;
return {
shift: source && source.shift !== undefined ? !!source.shift : DEFAULT_TERMINAL_KEY_MODIFIERS.shift
};
}
/**
* Alt/Meta 在终端里通常表现为“额外前置一个 ESC”
* 因此这里把组合键统一编码成 `ESC + 基础键序列`。
*/
function encodeTerminalAltSequence(normalizedKey, modes) {
const match = /^(alt|meta)(?:[+:-]|_)?(.+)$/.exec(normalizedKey);
if (!match) return "";
const nestedKey = String(match[2] || "")
.trim()
.toLowerCase();
if (!nestedKey) return "";
if (nestedKey.length === 1) {
return `\u001b${nestedKey}`;
}
const nestedSequence = encodeTerminalKey(nestedKey, modes);
return nestedSequence ? `\u001b${nestedSequence}` : "";
}
function encodeShiftModifiedKey(normalizedKey) {
if (normalizedKey === "tab") return "\u001b[Z";
if (normalizedKey === "up") return "\u001b[1;2A";
if (normalizedKey === "down") return "\u001b[1;2B";
if (normalizedKey === "right") return "\u001b[1;2C";
if (normalizedKey === "left") return "\u001b[1;2D";
if (normalizedKey === "home") return "\u001b[1;2H";
if (normalizedKey === "end") return "\u001b[1;2F";
if (normalizedKey === "insert") return "\u001b[2;2~";
if (normalizedKey === "delete") return "\u001b[3;2~";
if (normalizedKey === "pageup") return "\u001b[5;2~";
if (normalizedKey === "pagedown") return "\u001b[6;2~";
return "";
}
function encodeTerminalKey(key, modes, modifiers) {
const normalizedKey = String(key || "")
.trim()
.toLowerCase();
const normalizedModes = normalizeTerminalKeyModes(modes);
const normalizedModifiers = normalizeTerminalKeyModifiers(modifiers);
const applicationPrefix = normalizedModes.applicationCursorKeys ? "\u001bO" : "\u001b[";
const altSequence = encodeTerminalAltSequence(normalizedKey, normalizedModes);
if (altSequence) return altSequence;
if (normalizedModifiers.shift) {
const shiftedSequence = encodeShiftModifiedKey(normalizedKey);
if (shiftedSequence) return shiftedSequence;
}
if (normalizedKey === "up") return `${applicationPrefix}A`;
if (normalizedKey === "down") return `${applicationPrefix}B`;
if (normalizedKey === "right") return `${applicationPrefix}C`;
if (normalizedKey === "left") return `${applicationPrefix}D`;
if (normalizedKey === "home") return `${applicationPrefix}H`;
if (normalizedKey === "end") return `${applicationPrefix}F`;
if (normalizedKey === "enter") return "\r";
if (normalizedKey === "tab") return "\t";
if (normalizedKey === "esc") return "\u001b";
if (normalizedKey === "backspace") return "\u007f";
if (normalizedKey === "delete") return "\u001b[3~";
if (normalizedKey === "insert") return "\u001b[2~";
if (normalizedKey === "pageup") return "\u001b[5~";
if (normalizedKey === "pagedown") return "\u001b[6~";
if (CTRL_KEY_MAP[normalizedKey]) return CTRL_KEY_MAP[normalizedKey];
return "";
}
function encodeTerminalPaste(text, modes) {
const value = String(text || "");
if (!value) return "";
const normalizedModes = normalizeTerminalKeyModes(modes);
if (!normalizedModes.bracketedPasteMode) {
return value;
}
return `\u001b[200~${value}\u001b[201~`;
}
module.exports = {
DEFAULT_TERMINAL_KEY_MODIFIERS,
DEFAULT_TERMINAL_KEY_MODES,
TERMINAL_TOUCH_DIRECTION_KEYS,
TERMINAL_TOUCH_ACTION_BUTTONS,
encodeTerminalKey,
encodeTerminalPaste,
normalizeTerminalKeyModifiers,
normalizeTerminalKeyModes
};

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
const {
TERMINAL_TOUCH_ACTION_BUTTONS,
TERMINAL_TOUCH_DIRECTION_KEYS,
encodeTerminalKey,
encodeTerminalPaste,
normalizeTerminalKeyModes
} = require("./terminalKeyEncoder.js");
describe("terminalKeyEncoder", () => {
it("方向键和 Home/End 会按 application cursor mode 切换编码", () => {
expect(encodeTerminalKey("up", { applicationCursorKeys: false })).toBe("\u001b[A");
expect(encodeTerminalKey("up", { applicationCursorKeys: true })).toBe("\u001bOA");
expect(encodeTerminalKey("home", { applicationCursorKeys: false })).toBe("\u001b[H");
expect(encodeTerminalKey("home", { applicationCursorKeys: true })).toBe("\u001bOH");
});
it("常用编辑键和 Ctrl 组合会编码成 VT 控制序列", () => {
expect(encodeTerminalKey("esc")).toBe("\u001b");
expect(encodeTerminalKey("backspace")).toBe("\u007f");
expect(encodeTerminalKey("delete")).toBe("\u001b[3~");
expect(encodeTerminalKey("insert")).toBe("\u001b[2~");
expect(encodeTerminalKey("pageup")).toBe("\u001b[5~");
expect(encodeTerminalKey("ctrla")).toBe("\u0001");
expect(encodeTerminalKey("ctrlc")).toBe("\u0003");
expect(encodeTerminalKey("ctrle")).toBe("\u0005");
expect(encodeTerminalKey("ctrlw")).toBe("\u0017");
expect(encodeTerminalKey("ctrlz")).toBe("\u001a");
});
it("Alt/Meta 组合会编码为 ESC 前缀加基础键序列", () => {
expect(encodeTerminalKey("alt-a")).toBe("\u001ba");
expect(encodeTerminalKey("meta-z")).toBe("\u001bz");
expect(encodeTerminalKey("alt-up", { applicationCursorKeys: false })).toBe("\u001b\u001b[A");
expect(encodeTerminalKey("meta-home", { applicationCursorKeys: true })).toBe("\u001b\u001bOH");
});
it("Shift 修饰键会编码常用的反向 tab 和方向键序列", () => {
expect(encodeTerminalKey("tab", undefined, { shift: true })).toBe("\u001b[Z");
expect(encodeTerminalKey("up", undefined, { shift: true })).toBe("\u001b[1;2A");
expect(encodeTerminalKey("right", undefined, { shift: true })).toBe("\u001b[1;2C");
expect(encodeTerminalKey("delete", undefined, { shift: true })).toBe("\u001b[3;2~");
});
it("开启 bracketed paste 后,粘贴文本会自动包裹 2004 序列", () => {
expect(encodeTerminalPaste("hello", { bracketedPasteMode: false })).toBe("hello");
expect(encodeTerminalPaste("hello", { bracketedPasteMode: true })).toBe("\u001b[200~hello\u001b[201~");
});
it("模式位归一化会补齐默认值", () => {
expect(normalizeTerminalKeyModes({ applicationCursorKeys: true })).toEqual({
applicationCursorKeys: true,
applicationKeypad: false,
bracketedPasteMode: false
});
});
it("触屏键盘区配置符合 SH 精简集", () => {
expect(TERMINAL_TOUCH_DIRECTION_KEYS.map((item) => item.key)).toEqual(["up", "left", "down", "right"]);
expect(TERMINAL_TOUCH_ACTION_BUTTONS.map((item) => item.key)).toEqual([
"enter",
"home",
"shift",
"backspace",
"paste",
"esc",
"ctrlc",
"tab"
]);
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "paste")?.action).toBe("paste");
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "backspace")?.icon).toBe(
"/assets/icons/backspace.svg"
);
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "shift")?.icon).toBe(
"/assets/icons/shift.svg"
);
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "home")?.icon).toBe(
"/assets/icons/home.svg"
);
});
});

View File

@@ -0,0 +1,500 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const {
ANSI_RESET_STATE,
cloneAnsiState,
createEmptyTerminalBufferState
} = require("./terminalBufferState.js");
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
outputCursorRow: number;
outputCursorCol: number;
outputCells: unknown[][];
outputReplayText: string;
outputReplayBytes: number;
outputAnsiState: Record<string, unknown>;
outputTerminalState: Record<string, unknown>;
terminalCols: number;
terminalRows: number;
terminalBufferMaxEntries: number;
terminalBufferMaxBytes: number;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
applyTerminalBufferState: (
state: Record<string, unknown>,
runtimeOptions?: Record<string, unknown>
) => Record<string, unknown>;
applyTerminalBufferRuntimeState: (
state: Record<string, unknown>,
runtimeOptions?: Record<string, unknown>
) => Record<string, unknown>;
syncTerminalReplayBuffer: (cleanText: string) => void;
createQueuedTerminalOutputTask: (request: Record<string, unknown>) => Record<string, any>;
applyQueuedTerminalOutputBatch: (request: Record<string, unknown>) => Record<string, any>;
refreshOutputLayout: (options: Record<string, unknown>, callback?: (viewState: unknown) => void) => void;
queryOutputRect: ReturnType<typeof vi.fn>;
buildTerminalLayoutState: ReturnType<typeof vi.fn>;
runAfterTerminalLayout: ReturnType<typeof vi.fn>;
shouldLogTerminalPerfFrame: ReturnType<typeof vi.fn>;
logTerminalPerf: ReturnType<typeof vi.fn>;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
function createTerminalPageHarness() {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const noop = () => {};
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getStorageSync: vi.fn(() => undefined),
setStorageSync: vi.fn(),
removeStorageSync: vi.fn(),
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
})),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false)
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
setData(patch: Record<string, unknown>, callback?: () => void) {
Object.assign(this.data, patch);
callback?.();
}
} as TerminalPageInstance;
return { page };
}
function initTerminalPageOutputRuntime(page: TerminalPageInstance) {
page.terminalCols = 80;
page.terminalRows = 24;
page.terminalBufferMaxEntries = 5000;
page.terminalBufferMaxBytes = 4 * 1024 * 1024;
page.outputCursorRow = 0;
page.outputCursorCol = 0;
page.outputCells = [[]];
page.outputReplayText = "";
page.outputReplayBytes = 0;
page.outputAnsiState = cloneAnsiState(ANSI_RESET_STATE);
page.outputTerminalState = createEmptyTerminalBufferState({
bufferCols: page.terminalCols,
bufferRows: page.terminalRows
});
page.applyTerminalBufferState(page.outputTerminalState);
}
describe("terminal layout rect reuse", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("stdout 连续 slice 复用 rect 时不应重复查询输出区几何", () => {
const { page } = createTerminalPageHarness();
const cachedRect = {
left: 0,
top: 0,
right: 320,
bottom: 480,
width: 320,
height: 480
};
page.queryOutputRect = vi.fn();
page.buildTerminalLayoutState = vi.fn(() => ({
lineHeight: 21,
charWidth: 9,
paddingLeft: 8,
paddingRight: 8,
renderLines: [{ lineStyle: "", segments: [] }],
renderStartRow: 24,
renderEndRow: 25,
contentRowCount: 120,
topSpacerHeight: 504,
bottomSpacerHeight: 1995,
keyboardInsetHeight: 0,
maxScrollTop: 2079,
nextScrollTop: 0,
cursorRow: 0,
cursorCol: 0,
cols: 33,
rows: 22,
rect: cachedRect
}));
page.runAfterTerminalLayout = vi.fn((callback?: () => void) => {
callback?.();
});
page.shouldLogTerminalPerfFrame = vi.fn(() => false);
page.logTerminalPerf = vi.fn();
const done = vi.fn();
page.refreshOutputLayout(
{
rect: cachedRect,
reuseRect: true,
skipPostLayoutRectQuery: true
},
done
);
expect(page.queryOutputRect).not.toHaveBeenCalled();
expect(page.buildTerminalLayoutState).toHaveBeenCalledWith(
cachedRect,
expect.objectContaining({
rect: cachedRect,
reuseRect: true,
skipPostLayoutRectQuery: true
})
);
expect(page.data.outputRenderLines).toEqual([{ lineStyle: "", segments: [] }]);
expect(page.data.outputTopSpacerPx).toBe(504);
expect(page.data.outputBottomSpacerPx).toBe(1995);
expect(done).toHaveBeenCalledWith(
expect.objectContaining({
rect: cachedRect
})
);
});
it("滚动补刷窗口时不应回写 outputScrollTop避免打断手势滚动", () => {
const { page } = createTerminalPageHarness();
const cachedRect = {
left: 0,
top: 0,
right: 320,
bottom: 480,
width: 320,
height: 480
};
const patches: Record<string, unknown>[] = [];
const originalSetData = page.setData;
page.setData = function setDataWithSpy(patch: Record<string, unknown>, callback?: () => void) {
patches.push({ ...patch });
originalSetData.call(this, patch, callback);
};
page.currentOutputScrollTop = 960;
page.queryOutputRect = vi.fn();
page.buildTerminalLayoutState = vi.fn(() => ({
lineHeight: 21,
charWidth: 9,
paddingLeft: 8,
paddingRight: 8,
renderLines: [{ lineStyle: "", segments: [] }],
renderStartRow: 40,
renderEndRow: 80,
contentRowCount: 200,
topSpacerHeight: 840,
bottomSpacerHeight: 2520,
keyboardInsetHeight: 0,
maxScrollTop: 3129,
nextScrollTop: 960,
cursorRow: 50,
cursorCol: 0,
cols: 33,
rows: 22,
rect: cachedRect
}));
page.runAfterTerminalLayout = vi.fn((callback?: () => void) => {
callback?.();
});
page.shouldLogTerminalPerfFrame = vi.fn(() => false);
page.logTerminalPerf = vi.fn();
page.refreshOutputLayout({
rect: cachedRect,
reuseRect: true,
skipPostLayoutRectQuery: true,
preserveScrollTop: true
});
expect(patches[0]).not.toHaveProperty("outputScrollTop");
expect(page.currentOutputScrollTop).toBe(960);
});
it("scroll 过程中不应立刻同步 overlay而是走节流定时器", () => {
vi.useFakeTimers();
const { page } = createTerminalPageHarness();
page.outputRectSnapshot = {
left: 0,
top: 0,
right: 320,
bottom: 480,
width: 320,
height: 480
};
page.syncTerminalOverlay = vi.fn();
page.refreshOutputLayout = vi.fn();
page.onOutputScroll({
detail: {
scrollTop: 480
}
});
expect(page.syncTerminalOverlay).not.toHaveBeenCalled();
vi.advanceTimersByTime(31);
expect(page.syncTerminalOverlay).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(page.syncTerminalOverlay).toHaveBeenCalledTimes(1);
expect(page.refreshOutputLayout).not.toHaveBeenCalled();
});
it("接近窗口边缘时会在滚动中提前补刷正文窗口", () => {
vi.useFakeTimers();
const { page } = createTerminalPageHarness();
const cachedRect = {
left: 0,
top: 0,
right: 320,
bottom: 480,
width: 320,
height: 480
};
page.outputRectSnapshot = cachedRect;
page.outputViewportWindow = {
renderStartRow: 40,
renderEndRow: 100,
contentRowCount: 200,
lineHeight: 20,
visibleRows: 20
};
page.syncTerminalOverlay = vi.fn();
page.refreshOutputLayout = vi.fn(
(options: Record<string, unknown>, callback?: (viewState: unknown) => void) => {
callback?.({
rect: cachedRect,
lineHeight: 20,
charWidth: 9,
paddingLeft: 8,
paddingRight: 8,
cursorRow: 80,
cursorCol: 0,
rows: 20
});
}
);
page.onOutputScroll({
detail: {
scrollTop: 1500
}
});
expect(page.refreshOutputLayout).not.toHaveBeenCalled();
vi.advanceTimersByTime(15);
expect(page.refreshOutputLayout).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(page.refreshOutputLayout).toHaveBeenCalledWith(
expect.objectContaining({
preserveScrollTop: true,
scrollViewport: true,
rect: cachedRect,
reuseRect: true,
skipPostLayoutRectQuery: true
}),
expect.any(Function)
);
});
it("可视区已经落进顶部 spacer 时会立刻补刷正文,避免先看到空白", () => {
vi.useFakeTimers();
const { page } = createTerminalPageHarness();
const cachedRect = {
left: 0,
top: 0,
right: 320,
bottom: 480,
width: 320,
height: 480
};
page.outputRectSnapshot = cachedRect;
page.outputViewportWindow = {
renderStartRow: 40,
renderEndRow: 100,
contentRowCount: 200,
lineHeight: 20,
visibleRows: 20
};
page.syncTerminalOverlay = vi.fn();
page.refreshOutputLayout = vi.fn(
(options: Record<string, unknown>, callback?: (viewState: unknown) => void) => {
callback?.({
rect: cachedRect,
lineHeight: 20,
charWidth: 9,
paddingLeft: 8,
paddingRight: 8,
cursorRow: 40,
cursorCol: 0,
rows: 20
});
}
);
page.onOutputScroll({
detail: {
scrollTop: 300
}
});
expect(page.refreshOutputLayout).toHaveBeenCalledWith(
expect.objectContaining({
preserveScrollTop: true,
scrollViewport: true,
rect: cachedRect,
reuseRect: true,
skipPostLayoutRectQuery: true
}),
expect.any(Function)
);
});
it("stdout defer slice 只更新运行态,不应立刻同步 replay 文本和可视 rows", () => {
let now = 1000;
vi.spyOn(Date, "now").mockImplementation(() => {
now += 4;
return now;
});
const { page } = createTerminalPageHarness();
initTerminalPageOutputRuntime(page);
const syncReplaySpy = vi.spyOn(page, "syncTerminalReplayBuffer");
const applyStateSpy = vi.spyOn(page, "applyTerminalBufferState");
const applyRuntimeSpy = vi.spyOn(page, "applyTerminalBufferRuntimeState");
const text = "a".repeat(10 * 1024);
const request = {
options: {},
stdoutSamples: [
{
text,
appendStartedAt: 1,
visibleBytes: text.length,
visibleFrameCount: 1
}
]
};
request.stdoutTask = page.createQueuedTerminalOutputTask(request);
request.stdoutTask.lastRenderCompletedAt = Date.now();
const result = page.applyQueuedTerminalOutputBatch(request);
expect(result.shouldRender).toBe(false);
expect(syncReplaySpy).not.toHaveBeenCalled();
expect(applyStateSpy).not.toHaveBeenCalled();
expect(applyRuntimeSpy).toHaveBeenCalledTimes(1);
expect(page.outputReplayText).toBe("");
expect(page.outputReplayBytes).toBe(0);
expect(request.stdoutTask.pendingReplayBytes).toBeGreaterThan(0);
});
it("stdout 真正 render 时会一次性提交 defer 期间累积的 replay 文本", () => {
let now = 2000;
vi.spyOn(Date, "now").mockImplementation(() => {
now += 4;
return now;
});
const { page } = createTerminalPageHarness();
initTerminalPageOutputRuntime(page);
const syncReplaySpy = vi.spyOn(page, "syncTerminalReplayBuffer");
const applyStateSpy = vi.spyOn(page, "applyTerminalBufferState");
const text = "a".repeat(10 * 1024);
const request = {
options: {},
stdoutSamples: [
{
text,
appendStartedAt: 1,
visibleBytes: text.length,
visibleFrameCount: 1
}
]
};
request.stdoutTask = page.createQueuedTerminalOutputTask(request);
request.stdoutTask.lastRenderCompletedAt = Date.now();
const deferred = page.applyQueuedTerminalOutputBatch(request);
expect(deferred.shouldRender).toBe(false);
request.stdoutTask.slicesSinceLastRender = 7;
request.stdoutTask.lastRenderCompletedAt = Date.now() - 1000;
const rendered = page.applyQueuedTerminalOutputBatch(request);
expect(rendered.shouldRender).toBe(true);
expect(syncReplaySpy).toHaveBeenCalledTimes(1);
expect(syncReplaySpy).toHaveBeenCalledWith("a".repeat(2048));
expect(applyStateSpy).toHaveBeenCalledTimes(1);
expect(page.outputReplayBytes).toBe(2048);
expect(request.stdoutTask.pendingReplayBytes).toBe(0);
expect(request.stdoutTask.pendingReplayText).toBe("");
});
});

View File

@@ -0,0 +1,238 @@
/* global module, setTimeout, clearTimeout */
const PERF_SCORE_KEYS = [
"totalCostMs",
"costMs",
"driftMs",
"queueWaitMs",
"schedulerWaitMs",
"cloneCostMs",
"setDataCostMs",
"layoutCostMs",
"overlayCostMs",
"applyCostMs",
"trimCostMs",
"stateApplyCostMs",
"buildCostMs",
"renderBuildCostMs",
"queryCostMs",
"postLayoutCostMs",
"waitMs",
"batchWaitMs"
];
function pickPerfScore(record) {
const source = record && typeof record === "object" ? record : {};
let scoreMs = 0;
for (let index = 0; index < PERF_SCORE_KEYS.length; index += 1) {
const key = PERF_SCORE_KEYS[index];
const value = Number(source[key]);
if (Number.isFinite(value) && value > scoreMs) {
scoreMs = value;
}
}
return scoreMs;
}
function buildCompactRecord(record, scoreMs) {
const source = record && typeof record === "object" ? record : {};
const compact = {
event: String(source.event || ""),
scoreMs
};
for (let index = 0; index < PERF_SCORE_KEYS.length; index += 1) {
const key = PERF_SCORE_KEYS[index];
const value = Number(source[key]);
if (Number.isFinite(value) && value > 0) {
compact[key] = value;
}
}
if (source.renderReason) {
compact.renderReason = String(source.renderReason);
}
if (source.lastRenderDecisionReason) {
compact.lastRenderDecisionReason = String(source.lastRenderDecisionReason);
}
if (source.lastRenderDecisionPolicy) {
compact.lastRenderDecisionPolicy = String(source.lastRenderDecisionPolicy);
}
if (source.suspectedBottleneck) {
compact.suspectedBottleneck = String(source.suspectedBottleneck);
}
if (Number.isFinite(Number(source.pendingStdoutSamples))) {
compact.pendingStdoutSamples = Number(source.pendingStdoutSamples);
}
if (Number.isFinite(Number(source.pendingStdoutBytes))) {
compact.pendingStdoutBytes = Number(source.pendingStdoutBytes);
}
if (Number.isFinite(Number(source.activeStdoutAgeMs))) {
compact.activeStdoutAgeMs = Number(source.activeStdoutAgeMs);
}
if (Number.isFinite(Number(source.activeStdoutBytes))) {
compact.activeStdoutBytes = Number(source.activeStdoutBytes);
}
if (Number.isFinite(Number(source.remainingBytes))) {
compact.remainingBytes = Number(source.remainingBytes);
}
if (Number.isFinite(Number(source.sliceCount))) {
compact.sliceCount = Number(source.sliceCount);
}
if (Number.isFinite(Number(source.chunkCount))) {
compact.chunkCount = Number(source.chunkCount);
}
if (Number.isFinite(Number(source.renderRowCount))) {
compact.renderRowCount = Number(source.renderRowCount);
}
if (Number.isFinite(Number(source.renderPassCount))) {
compact.renderPassCount = Number(source.renderPassCount);
}
if (Number.isFinite(Number(source.layoutPassCount))) {
compact.layoutPassCount = Number(source.layoutPassCount);
}
if (Number.isFinite(Number(source.overlayPassCount))) {
compact.overlayPassCount = Number(source.overlayPassCount);
}
if (Number.isFinite(Number(source.deferredRenderPassCount))) {
compact.deferredRenderPassCount = Number(source.deferredRenderPassCount);
}
if (Number.isFinite(Number(source.skippedOverlayPassCount))) {
compact.skippedOverlayPassCount = Number(source.skippedOverlayPassCount);
}
if (Number.isFinite(Number(source.activeRowCount))) {
compact.activeRowCount = Number(source.activeRowCount);
}
if (Number.isFinite(Number(source.activeCellCount))) {
compact.activeCellCount = Number(source.activeCellCount);
}
if (Number.isFinite(Number(source.totalCellCount))) {
compact.totalCellCount = Number(source.totalCellCount);
}
if (Number.isFinite(Number(source.layoutSeq))) {
compact.layoutSeq = Number(source.layoutSeq);
}
if (Number.isFinite(Number(source.overlaySeq))) {
compact.overlaySeq = Number(source.overlaySeq);
}
return compact;
}
function buildTopEvents(eventCounts) {
return Object.entries(eventCounts || {})
.sort((left, right) => {
if (right[1] !== left[1]) {
return right[1] - left[1];
}
return String(left[0]).localeCompare(String(right[0]));
})
.slice(0, 5)
.map(([event, count]) => ({ event, count }));
}
/**
* 终端 perf 日志默认按窗口聚合:
* 1. 高频 stdout / layout / overlay 事件只在窗口结束时输出 1 条摘要;
* 2. 摘要保留“最常见事件 + 最慢事件 + 最新事件”,便于复盘卡顿;
* 3. 这样既能在真机上抓现场,又不会让 console 自身成为性能噪声。
*/
function createTerminalPerfLogBuffer(options) {
const config = options && typeof options === "object" ? options : {};
const now = typeof config.now === "function" ? config.now : () => Date.now();
const setTimer = typeof config.setTimer === "function" ? config.setTimer : setTimeout;
const clearTimer = typeof config.clearTimer === "function" ? config.clearTimer : clearTimeout;
const write = typeof config.write === "function" ? config.write : null;
const windowMs =
Number.isFinite(Number(config.windowMs)) && Number(config.windowMs) >= 1000
? Math.round(Number(config.windowMs))
: 5000;
if (!write) {
throw new TypeError("terminal perf log buffer 缺少 write");
}
let flushTimer = null;
let bucket = null;
function clearFlushTimer() {
if (!flushTimer) {
return;
}
clearTimer(flushTimer);
flushTimer = null;
}
function ensureBucket(record) {
if (bucket) {
return bucket;
}
const startedAt = Number(record && record.at) || now();
bucket = {
startedAt,
latestAt: startedAt,
latestSinceLoadMs: Number(record && record.sinceLoadMs) || 0,
latestStatus: String((record && record.status) || ""),
count: 0,
eventCounts: {},
slowest: null,
latest: null
};
flushTimer = setTimer(() => {
flush("interval");
}, windowMs);
return bucket;
}
function push(record) {
const source = record && typeof record === "object" ? record : {};
const scoreMs = pickPerfScore(source);
const activeBucket = ensureBucket(source);
const event = String(source.event || "unknown");
activeBucket.count += 1;
activeBucket.eventCounts[event] = (activeBucket.eventCounts[event] || 0) + 1;
activeBucket.latestAt = Number(source.at) || now();
activeBucket.latestSinceLoadMs = Number(source.sinceLoadMs) || activeBucket.latestSinceLoadMs;
activeBucket.latestStatus = String(source.status || activeBucket.latestStatus || "");
activeBucket.latest = buildCompactRecord(source, scoreMs);
if (!activeBucket.slowest || scoreMs >= Number(activeBucket.slowest.scoreMs || 0)) {
activeBucket.slowest = buildCompactRecord(source, scoreMs);
}
}
function flush(reason) {
if (!bucket) {
return null;
}
clearFlushTimer();
const endedAt = now();
const summary = {
event: "perf.summary",
reason: String(reason || "manual"),
at: endedAt,
sinceLoadMs: bucket.latestSinceLoadMs,
status: bucket.latestStatus,
windowMs: Math.max(0, endedAt - bucket.startedAt),
count: bucket.count,
topEvents: buildTopEvents(bucket.eventCounts),
slowest: bucket.slowest,
latest: bucket.latest
};
bucket = null;
write(summary);
return summary;
}
function clear() {
clearFlushTimer();
bucket = null;
}
return {
push,
flush,
clear
};
}
module.exports = {
createTerminalPerfLogBuffer,
pickPerfScore
};

View File

@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { createTerminalPerfLogBuffer, pickPerfScore } = require("./terminalPerfLogBuffer.js");
describe("terminalPerfLogBuffer", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("会从常见耗时字段中选出最大的 score", () => {
expect(
pickPerfScore({
totalCostMs: 120,
setDataCostMs: 320,
overlayCostMs: 180
})
).toBe(320);
expect(
pickPerfScore({
applyCostMs: 220,
trimCostMs: 380,
cloneCostMs: 260
})
).toBe(380);
});
it("会把 5 秒窗口内的高频事件聚合成 1 条摘要", () => {
const writes = [];
const buffer = createTerminalPerfLogBuffer({
windowMs: 5000,
write(summary) {
writes.push(summary);
}
});
buffer.push({
event: "stdout.slice",
at: 1000,
sinceLoadMs: 100,
status: "connected",
totalCostMs: 240
});
buffer.push({
event: "layout.refresh.long",
at: 1800,
sinceLoadMs: 900,
status: "connected",
totalCostMs: 1400,
setDataCostMs: 1200
});
buffer.push({
event: "stdout.slice",
at: 2200,
sinceLoadMs: 1300,
status: "connected",
totalCostMs: 180
});
vi.advanceTimersByTime(5000);
expect(writes).toHaveLength(1);
expect(writes[0]).toMatchObject({
event: "perf.summary",
count: 3,
status: "connected"
});
expect(writes[0].topEvents[0]).toEqual({ event: "stdout.slice", count: 2 });
expect(writes[0].slowest).toMatchObject({
event: "layout.refresh.long",
scoreMs: 1400
});
expect(writes[0].latest).toMatchObject({
event: "stdout.slice"
});
});
it("支持在页面收起前手动 flush避免丢掉最后一个窗口", () => {
const writes = [];
const buffer = createTerminalPerfLogBuffer({
windowMs: 5000,
write(summary) {
writes.push(summary);
}
});
buffer.push({
event: "overlay.sync.long",
at: 3000,
sinceLoadMs: 2000,
status: "connected",
costMs: 65000
});
const summary = buffer.flush("page_hide");
expect(summary).toMatchObject({
event: "perf.summary",
reason: "page_hide",
count: 1
});
expect(writes).toHaveLength(1);
});
});

View File

@@ -0,0 +1,239 @@
/* global module, setTimeout, clearTimeout */
function utf8ByteLength(text) {
const value = String(text || "");
let total = 0;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x7f) {
total += 1;
continue;
}
if (code <= 0x7ff) {
total += 2;
continue;
}
if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
total += 4;
index += 1;
continue;
}
}
total += 3;
}
return total;
}
/**
* 统一规范终端渲染选项:
* 目前页面层只暴露 `sendResize`,后续若增加其他布尔开关,也应在这里集中合并。
*/
function mergeTerminalRenderOptions(base, incoming) {
const previous = base && typeof base === "object" ? base : {};
const next = incoming && typeof incoming === "object" ? incoming : {};
return {
sendResize: !!(previous.sendResize || next.sendResize)
};
}
function normalizeStdoutSample(sample) {
const source = sample && typeof sample === "object" ? sample : {};
const text = String(source.text || "");
return {
text,
rawBytes: utf8ByteLength(text),
appendStartedAt: Number(source.appendStartedAt) || 0,
visibleBytes: Number(source.visibleBytes) || 0,
visibleFrameCount: Number(source.visibleFrameCount) || 0
};
}
function createPendingRequest(now) {
return {
options: mergeTerminalRenderOptions(),
callbacks: [],
stdoutSamples: [],
requestedAt: now()
};
}
/**
* 终端渲染调度器职责只有两件事:
* 1. stdout 高频输出时,按一个很短的窗口合并成一轮真实渲染;
* 2. 若上一轮渲染尚未完成,只保留“下一轮需要再跑一次”的脏标记,避免把 scroll-view 刷新堆成风暴。
*
* 注意:
* - 调度器不负责真正的布局/overlay 逻辑,页面层通过 `runRender` 注入;
* - stdout 合批会把多段文本交给页面层一次性处理,再统一进入 layout/overlay。
*/
function createTerminalRenderScheduler(options) {
const config = options && typeof options === "object" ? options : {};
const now = typeof config.now === "function" ? config.now : () => Date.now();
const setTimer = typeof config.setTimer === "function" ? config.setTimer : setTimeout;
const clearTimer = typeof config.clearTimer === "function" ? config.clearTimer : clearTimeout;
const onError = typeof config.onError === "function" ? config.onError : null;
const batchWindowMs =
Number.isFinite(Number(config.batchWindowMs)) && Number(config.batchWindowMs) >= 0
? Math.round(Number(config.batchWindowMs))
: 16;
const runRender = typeof config.runRender === "function" ? config.runRender : null;
if (!runRender) {
throw new TypeError("terminal render scheduler 缺少 runRender");
}
let inFlight = false;
let stdoutTimer = null;
let pendingRequest = null;
let activeRequest = null;
function reportError(error) {
if (onError) {
onError(error);
return;
}
throw error;
}
function ensurePendingRequest() {
if (!pendingRequest) {
pendingRequest = createPendingRequest(now);
}
return pendingRequest;
}
function clearStdoutTimer() {
if (!stdoutTimer) return;
clearTimer(stdoutTimer);
stdoutTimer = null;
}
function finalizeRequestCallbacks(request, result) {
const callbacks = Array.isArray(request && request.callbacks) ? request.callbacks.slice() : [];
callbacks.forEach((callback) => {
if (typeof callback !== "function") return;
try {
callback(result, request);
} catch (error) {
reportError(error);
}
});
}
function buildRequestSnapshot(request, timestamp) {
if (!request || typeof request !== "object") {
return null;
}
const samples = Array.isArray(request.stdoutSamples) ? request.stdoutSamples : [];
return {
reason: String(request.reason || ""),
requestedAt: Number(request.requestedAt) || 0,
startedAt: Number(request.startedAt) || 0,
waitMs:
timestamp && Number(request.requestedAt)
? Math.max(0, (Number(request.startedAt) || Number(timestamp)) - Number(request.requestedAt))
: 0,
ageMs:
timestamp && Number(request.startedAt)
? Math.max(0, Number(timestamp) - Number(request.startedAt))
: 0,
stdoutSampleCount: samples.length,
stdoutRawBytes: samples.reduce(
(sum, sample) => sum + Math.max(0, Number(sample && sample.rawBytes) || 0),
0
),
stdoutVisibleBytes: samples.reduce(
(sum, sample) => sum + Math.max(0, Number(sample && sample.visibleBytes) || 0),
0
),
callbackCount: Array.isArray(request.callbacks) ? request.callbacks.length : 0
};
}
function startNextRun(reason) {
if (inFlight || !pendingRequest) {
return false;
}
clearStdoutTimer();
const request = pendingRequest;
pendingRequest = null;
request.reason = String(reason || "");
request.startedAt = now();
inFlight = true;
activeRequest = request;
try {
runRender(request, (result) => {
request.completedAt = now();
inFlight = false;
activeRequest = null;
finalizeRequestCallbacks(request, result);
if (pendingRequest) {
startNextRun(pendingRequest.stdoutSamples.length > 0 ? "pending_stdout" : "pending_immediate");
}
});
} catch (error) {
inFlight = false;
activeRequest = null;
reportError(error);
if (pendingRequest) {
startNextRun("recover_after_error");
}
}
return true;
}
function scheduleStdoutFlush() {
if (inFlight || stdoutTimer || !pendingRequest) {
return;
}
if (batchWindowMs <= 0) {
startNextRun("stdout_immediate");
return;
}
stdoutTimer = setTimer(() => {
stdoutTimer = null;
startNextRun("stdout_batch");
}, batchWindowMs);
}
return {
requestImmediate(options, callback) {
const request = ensurePendingRequest();
request.options = mergeTerminalRenderOptions(request.options, options);
if (typeof callback === "function") {
request.callbacks.push(callback);
}
clearStdoutTimer();
if (!inFlight) {
startNextRun("immediate");
}
},
requestStdout(sample) {
const request = ensurePendingRequest();
request.stdoutSamples.push(normalizeStdoutSample(sample));
scheduleStdoutFlush();
},
clearPending() {
clearStdoutTimer();
pendingRequest = null;
},
getSnapshot() {
const timestamp = now();
return {
inFlight,
pending: buildRequestSnapshot(pendingRequest, timestamp),
active: buildRequestSnapshot(activeRequest, timestamp)
};
}
};
}
module.exports = {
createTerminalRenderScheduler,
mergeTerminalRenderOptions
};

View File

@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { createTerminalRenderScheduler, mergeTerminalRenderOptions } = require("./terminalRenderScheduler.js");
describe("terminalRenderScheduler", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("会把渲染选项按单一真相源合并", () => {
expect(mergeTerminalRenderOptions(null, null)).toEqual({ sendResize: false });
expect(mergeTerminalRenderOptions({ sendResize: false }, { sendResize: true })).toEqual({
sendResize: true
});
expect(mergeTerminalRenderOptions({ sendResize: true }, { sendResize: false })).toEqual({
sendResize: true
});
});
it("stdout 高频输出会在一个批窗口内合并成一次渲染", () => {
const runs = [];
const scheduler = createTerminalRenderScheduler({
batchWindowMs: 16,
runRender(request, done) {
runs.push(request);
done({ ok: true });
}
});
scheduler.requestStdout({ appendStartedAt: 10, visibleBytes: 12 });
scheduler.requestStdout({ appendStartedAt: 12, visibleBytes: 18 });
expect(runs).toHaveLength(0);
vi.advanceTimersByTime(16);
expect(runs).toHaveLength(1);
expect(runs[0].reason).toBe("stdout_batch");
expect(runs[0].stdoutSamples).toHaveLength(2);
expect(runs[0].stdoutSamples[0]).toMatchObject({ appendStartedAt: 10, visibleBytes: 12 });
expect(runs[0].stdoutSamples[1]).toMatchObject({ appendStartedAt: 12, visibleBytes: 18 });
});
it("进行中的渲染完成后,只会补跑一轮合并后的后续请求", () => {
const runs = [];
const finishes = [];
const callbackMarks = [];
const scheduler = createTerminalRenderScheduler({
batchWindowMs: 16,
runRender(request, done) {
runs.push(request);
finishes.push(done);
}
});
scheduler.requestImmediate({}, (_result, request) => {
callbackMarks.push(`first:${request.reason}`);
});
expect(runs).toHaveLength(1);
scheduler.requestImmediate({ sendResize: true }, (_result, request) => {
callbackMarks.push(`second:${request.reason}:${request.stdoutSamples.length}`);
});
scheduler.requestStdout({ appendStartedAt: 20, visibleBytes: 5 });
expect(runs).toHaveLength(1);
finishes.shift()({ ok: true });
expect(runs).toHaveLength(2);
expect(runs[1].reason).toBe("pending_stdout");
expect(runs[1].options).toEqual({ sendResize: true });
expect(runs[1].stdoutSamples).toHaveLength(1);
finishes.shift()({ ok: true });
expect(callbackMarks).toEqual(["first:immediate", "second:pending_stdout:1"]);
});
it("普通立即渲染会抢占尚未触发的 stdout 定时批处理", () => {
const runs = [];
const scheduler = createTerminalRenderScheduler({
batchWindowMs: 16,
runRender(request, done) {
runs.push(request);
done({ ok: true });
}
});
scheduler.requestStdout({ appendStartedAt: 10, visibleBytes: 3 });
scheduler.requestImmediate({ sendResize: true });
expect(runs).toHaveLength(1);
expect(runs[0].reason).toBe("immediate");
expect(runs[0].options).toEqual({ sendResize: true });
expect(runs[0].stdoutSamples).toHaveLength(1);
vi.advanceTimersByTime(16);
expect(runs).toHaveLength(1);
});
it("支持输出当前 pending 与 in-flight 的调度快照,便于慢场景诊断", () => {
const finishes = [];
let now = 100;
const scheduler = createTerminalRenderScheduler({
batchWindowMs: 16,
now: () => now,
runRender(request, done) {
finishes.push(done);
}
});
scheduler.requestStdout({ text: "你好", appendStartedAt: 80, visibleBytes: 6 });
now = 140;
expect(scheduler.getSnapshot()).toMatchObject({
inFlight: false,
pending: {
waitMs: 40,
stdoutSampleCount: 1,
stdoutRawBytes: 6
},
active: null
});
vi.advanceTimersByTime(16);
now = 180;
expect(scheduler.getSnapshot()).toMatchObject({
inFlight: true,
pending: null,
active: {
reason: "stdout_batch",
ageMs: 40,
waitMs: 40,
stdoutSampleCount: 1,
stdoutRawBytes: 6
}
});
finishes.shift()({ ok: true });
expect(scheduler.getSnapshot()).toMatchObject({
inFlight: false,
pending: null,
active: null
});
});
});

View File

@@ -0,0 +1,136 @@
/* global module */
/**
* 会话信息浮层只展示当前终端会话已经拥有的静态配置。
* 这里保持纯函数,避免点击工具栏时再触发额外网络请求或依赖页面实例状态。
*/
function normalizeDisplayText(value) {
const normalized = String(value || "").trim();
return normalized;
}
function buildTerminalServerAddress(serverInput) {
const server = serverInput && typeof serverInput === "object" ? serverInput : {};
const username = normalizeDisplayText(server.username);
const host = normalizeDisplayText(server.host);
const port = normalizeDisplayText(server.port);
if (!host) {
return "";
}
const authority = username ? `${username}@${host}` : host;
return port ? `${authority}:${port}` : authority;
}
function resolveSessionConnectionValue(sessionInfoCopy, connected) {
const onValue = normalizeDisplayText(sessionInfoCopy.connectedValue) || "连接";
const offValue = normalizeDisplayText(sessionInfoCopy.disconnectedValue) || "断开";
return connected ? onValue : offValue;
}
function resolveAiProviderLabel(activeAiProvider) {
const normalized = normalizeDisplayText(activeAiProvider).toLowerCase();
if (normalized === "copilot") return "Copilot";
if (normalized === "codex") return "Codex";
return "";
}
/**
* 工具栏浮层需要稳定输出静态会话信息:
* 1. 顶部 hero 区聚焦“当前是哪台机器、通过哪条链路进入”;
* 2. SSH / AI 连接态拆成两枚并排胶囊,便于一眼判断双通道状态;
* 3. hero 已承载入口 / 跳转链路后,下方信息卡只保留不重复的目录信息;
* 4. 缺省值统一在这里兜底,页面层只负责展示。
*/
function buildTerminalSessionInfoModel(input) {
const source = input && typeof input === "object" ? input : {};
const copy = source.copy && typeof source.copy === "object" ? source.copy : {};
const sessionInfoCopy = copy.sessionInfo && typeof copy.sessionInfo === "object" ? copy.sessionInfo : {};
const fallbackCopy = copy.fallback && typeof copy.fallback === "object" ? copy.fallback : {};
const server = source.server && typeof source.server === "object" ? source.server : {};
const jumpHost = server.jumpHost && typeof server.jumpHost === "object" ? server.jumpHost : null;
const hasJumpHost = !!(jumpHost && jumpHost.enabled);
const serverLabel = normalizeDisplayText(source.serverLabel);
const statusText = normalizeDisplayText(source.statusText);
const activeAiProvider = normalizeDisplayText(source.activeAiProvider);
const emptyValue = normalizeDisplayText(sessionInfoCopy.emptyValue) || "-";
const serverName =
normalizeDisplayText(server.name) ||
serverLabel ||
normalizeDisplayText(fallbackCopy.unnamedServer) ||
emptyValue;
const projectPath =
normalizeDisplayText(server.projectPath) || normalizeDisplayText(fallbackCopy.noProject) || emptyValue;
const address =
(hasJumpHost ? buildTerminalServerAddress(jumpHost) : buildTerminalServerAddress(server)) || emptyValue;
const jumpTarget = hasJumpHost ? buildTerminalServerAddress(server) || emptyValue : "";
const sshConnected = statusText === "connected";
const aiConnected = !!activeAiProvider;
const sshConnection = resolveSessionConnectionValue(sessionInfoCopy, statusText === "connected");
const aiConnection = resolveSessionConnectionValue(sessionInfoCopy, aiConnected);
const aiProviderLabel = resolveAiProviderLabel(activeAiProvider);
const hero = {
eyebrow: hasJumpHost ? "双跳通道" : "直连通道",
name: serverName,
subtitle: address,
routeLabel: normalizeDisplayText(sessionInfoCopy.jumpTargetLabel) || "跳至服务器",
route: hasJumpHost ? jumpTarget : ""
};
const statusChips = [
{
key: "sshConnection",
label: normalizeDisplayText(sessionInfoCopy.sshConnectionLabel) || "SSH连接",
value: sshConnection,
badge: sshConnected ? "LIVE" : "IDLE",
note: sshConnected ? "终端链路已就绪" : "等待重新建立",
connected: sshConnected
},
{
key: "aiConnection",
label: normalizeDisplayText(sessionInfoCopy.aiConnectionLabel) || "AI连接",
value: aiConnection,
badge: aiConnected ? aiProviderLabel : "STANDBY",
note: aiConnected ? `${aiProviderLabel} 正在前台` : "尚未接管终端",
connected: aiConnected
}
];
/**
* 链路信息已经在 hero 区完整展示:
* 1. 直连时 subtitle 就是目标服务器;
* 2. 跳板时 subtitle + route 已同时覆盖入口与目标;
* 3. 同一弹层里不再重复渲染“入口 / 目标”卡片,只留下工作目录。
*/
const detailItems = [
{
key: "project",
accent: "目录",
label: normalizeDisplayText(sessionInfoCopy.projectLabel) || "工作目录",
value: projectPath,
wide: true
}
];
return {
title: normalizeDisplayText(sessionInfoCopy.title) || "会话信息",
hero,
statusChips,
detailItems,
items: detailItems
.map((item) => ({
key: item.key,
label: item.label,
value: item.value
}))
.concat(
statusChips.map((item) => ({
key: item.key,
label: item.label,
value: item.value
}))
)
};
}
module.exports = {
buildTerminalServerAddress,
buildTerminalSessionInfoModel
};

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
const { buildTerminalServerAddress, buildTerminalSessionInfoModel } = require("./terminalSessionInfo.js");
describe("terminalSessionInfo", () => {
it("启用跳转主机后会把链路信息收敛到 hero 区并保留连接状态", () => {
const model = buildTerminalSessionInfoModel({
serverLabel: "prod-shell",
statusText: "connected",
activeAiProvider: "codex",
server: {
name: "生产环境",
username: "deploy",
host: "10.0.0.8",
port: 22,
projectPath: "/srv/apps/remoteconn",
jumpHost: {
enabled: true,
username: "bastion",
host: "10.0.0.2",
port: 2222
}
},
copy: {
sessionInfo: {
title: "会话信息",
nameLabel: "服务器名称",
projectLabel: "工作目录",
addressLabel: "服务器地址",
jumpTargetLabel: "跳至服务器",
sshConnectionLabel: "SSH连接",
aiConnectionLabel: "AI连接",
connectedValue: "连接",
disconnectedValue: "断开"
},
fallback: {
noProject: "未设置项目",
unnamedServer: "未命名服务器"
}
}
});
expect(model.title).toBe("会话信息");
expect(model.hero).toEqual({
eyebrow: "双跳通道",
name: "生产环境",
subtitle: "bastion@10.0.0.2:2222",
routeLabel: "跳至服务器",
route: "deploy@10.0.0.8:22"
});
expect(model.statusChips).toEqual([
{
key: "sshConnection",
label: "SSH连接",
value: "连接",
badge: "LIVE",
note: "终端链路已就绪",
connected: true
},
{
key: "aiConnection",
label: "AI连接",
value: "连接",
badge: "Codex",
note: "Codex 正在前台",
connected: true
}
]);
expect(model.detailItems).toEqual([
{ key: "project", accent: "目录", label: "工作目录", value: "/srv/apps/remoteconn", wide: true }
]);
});
it("缺失配置时会回退到本地文案和断开状态", () => {
const model = buildTerminalSessionInfoModel({
serverLabel: "",
statusText: "disconnected",
activeAiProvider: "",
server: {
username: "",
host: "",
port: "",
projectPath: ""
},
copy: {
sessionInfo: {
sshConnectionLabel: "SSH连接",
aiConnectionLabel: "AI连接",
connectedValue: "连接",
disconnectedValue: "断开",
emptyValue: "--"
},
fallback: {
noProject: "未设置项目",
unnamedServer: "未命名服务器"
}
}
});
expect(model.hero).toEqual({
eyebrow: "直连通道",
name: "未命名服务器",
subtitle: "--",
routeLabel: "跳至服务器",
route: ""
});
expect(model.statusChips).toEqual([
{
key: "sshConnection",
label: "SSH连接",
value: "断开",
badge: "IDLE",
note: "等待重新建立",
connected: false
},
{
key: "aiConnection",
label: "AI连接",
value: "断开",
badge: "STANDBY",
note: "尚未接管终端",
connected: false
}
]);
expect(model.detailItems).toEqual([
{ key: "project", accent: "目录", label: "工作目录", value: "未设置项目", wide: true }
]);
expect(buildTerminalServerAddress({ host: "srv.example.com", port: 2200 })).toBe("srv.example.com:2200");
});
});

View File

@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
function createTerminalPageHarness() {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const noop = () => {};
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
})),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false),
showToast: vi.fn()
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
setData(patch: Record<string, unknown>, callback?: () => void) {
Object.assign(this.data, patch);
callback?.();
}
} as TerminalPageInstance;
return { page };
}
describe("terminal session info reconnect", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("点击 SSH 卡片会复用连接开关逻辑", () => {
const { page } = createTerminalPageHarness();
const onConnectionAction = vi.fn();
page.onConnectionAction = onConnectionAction;
page.data.connectionActionDisabled = false;
page.onSessionInfoStatusTap({
currentTarget: {
dataset: {
key: "sshConnection"
}
}
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
expect(onConnectionAction).toHaveBeenCalledTimes(1);
});
it("点击 AI 卡片会复用 AI 按钮逻辑", () => {
const { page } = createTerminalPageHarness();
const onOpenCodex = vi.fn();
page.onOpenCodex = onOpenCodex;
page.data.aiLaunchBusy = false;
page.onSessionInfoStatusTap({
currentTarget: {
dataset: {
key: "aiConnection"
}
}
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
expect(onOpenCodex).toHaveBeenCalledTimes(1);
});
it("连接开关禁用时点击 SSH 卡片不会触发连接动作", () => {
const { page } = createTerminalPageHarness();
const onConnectionAction = vi.fn();
page.onConnectionAction = onConnectionAction;
page.data.connectionActionDisabled = true;
page.onSessionInfoStatusTap({
currentTarget: {
dataset: {
key: "sshConnection"
}
}
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
expect(onConnectionAction).not.toHaveBeenCalled();
});
it("AI 正在启动时点击 AI 卡片不会重复触发", () => {
const { page } = createTerminalPageHarness();
const onOpenCodex = vi.fn();
page.onOpenCodex = onOpenCodex;
page.data.aiLaunchBusy = true;
page.onSessionInfoStatusTap({
currentTarget: {
dataset: {
key: "aiConnection"
}
}
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
expect(onOpenCodex).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
keyboardVisibleHeightPx: number;
keyboardSessionActive: boolean;
keyboardRestoreScrollTop: number | null;
shellInputPassiveBlurPending: boolean;
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
onShellInputBlur: () => void;
onShellInputFocus: (event?: Record<string, unknown>) => void;
handleShellKeyboardHeightChange: (height: number) => void;
finalizeShellInputBlur: (options?: Record<string, unknown>) => void;
restoreOutputScrollAfterKeyboard: ReturnType<typeof vi.fn>;
adjustOutputScrollForKeyboard: ReturnType<typeof vi.fn>;
sendFocusModeReport: ReturnType<typeof vi.fn>;
clearTouchShiftState: ReturnType<typeof vi.fn>;
syncTerminalOverlay: ReturnType<typeof vi.fn>;
markTerminalUserInput: ReturnType<typeof vi.fn>;
getOutputScrollTop: () => number;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
function createTerminalPageHarness() {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const noop = () => {};
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getStorageSync: vi.fn(() => undefined),
setStorageSync: vi.fn(),
removeStorageSync: vi.fn(),
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
})),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false)
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
keyboardVisibleHeightPx: 0,
keyboardSessionActive: false,
keyboardRestoreScrollTop: null,
shellInputPassiveBlurPending: false,
setData(patch: Record<string, unknown>, callback?: () => void) {
Object.assign(this.data, patch);
callback?.();
}
} as TerminalPageInstance;
page.restoreOutputScrollAfterKeyboard = vi.fn((callback?: () => void) => {
callback?.();
});
page.adjustOutputScrollForKeyboard = vi.fn();
page.sendFocusModeReport = vi.fn();
page.clearTouchShiftState = vi.fn();
page.syncTerminalOverlay = vi.fn();
page.markTerminalUserInput = vi.fn();
page.getOutputScrollTop = () => 0;
return { page };
}
describe("terminal shell input blur guard", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("键盘仍可见时的被动 blur 不应立刻把 shell 输入框设为失焦", () => {
const { page } = createTerminalPageHarness();
page.data.statusClass = "connected";
page.data.shellInputFocus = true;
page.keyboardVisibleHeightPx = 240;
page.onShellInputBlur();
expect(page.shellInputPassiveBlurPending).toBe(true);
expect(page.data.shellInputFocus).toBe(true);
expect(page.restoreOutputScrollAfterKeyboard).not.toHaveBeenCalled();
});
it("被动 blur 后若键盘真正收起,才兑现为真实失焦", () => {
const { page } = createTerminalPageHarness();
page.data.statusClass = "connected";
page.data.shellInputFocus = true;
page.keyboardVisibleHeightPx = 240;
page.onShellInputBlur();
page.handleShellKeyboardHeightChange(0);
expect(page.shellInputPassiveBlurPending).toBe(false);
expect(page.data.shellInputFocus).toBe(false);
expect(page.restoreOutputScrollAfterKeyboard).toHaveBeenCalledTimes(1);
});
it("被动 blur 后若键盘继续保持可见,应清掉待定 blur 并继续保焦", () => {
const { page } = createTerminalPageHarness();
page.data.statusClass = "connected";
page.data.shellInputFocus = true;
page.keyboardVisibleHeightPx = 240;
page.onShellInputBlur();
page.handleShellKeyboardHeightChange(260);
expect(page.shellInputPassiveBlurPending).toBe(false);
expect(page.data.shellInputFocus).toBe(true);
expect(page.adjustOutputScrollForKeyboard).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,159 @@
/* global module, require */
const {
buildLineCellRenderRuns,
createBlankCell,
createContinuationCell,
createTerminalCell,
measureCharDisplayColumns
} = require("./terminalCursorModel.js");
/**
* 终端快照样式做最小化存储:
* 1. 仅保留当前渲染真正需要的 fg/bg/bold/underline
* 2. 同一份样式进入 style table 去重line runs 只保留索引;
* 3. 这样比直接存整行 cell 更省空间,也避免恢复时回退成纯文本。
*/
function normalizeSnapshotStyle(style) {
const source = style && typeof style === "object" ? style : null;
if (!source) return null;
const fg = String(source.fg || "").trim();
const bg = String(source.bg || "").trim();
const bold = source.bold === true;
const underline = source.underline === true;
if (!fg && !bg && !bold && !underline) {
return null;
}
return {
fg,
bg,
bold,
underline
};
}
function buildSnapshotStyleSignature(style) {
const normalized = normalizeSnapshotStyle(style);
if (!normalized) return "";
return `${normalized.fg || ""}|${normalized.bg || ""}|${normalized.bold ? 1 : 0}|${normalized.underline ? 1 : 0}`;
}
function cloneSnapshotStyle(style) {
const normalized = normalizeSnapshotStyle(style);
return normalized ? { ...normalized } : null;
}
function measureTextDisplayColumns(text) {
const value = String(text || "");
if (!value) return 0;
let columns = 0;
for (let index = 0; index < value.length; ) {
const codePoint = value.codePointAt(index);
if (!Number.isFinite(codePoint)) break;
const ch = String.fromCodePoint(codePoint);
index += ch.length;
const width = measureCharDisplayColumns(ch);
if (width > 0) {
columns += width;
}
}
return columns;
}
function appendStyledTextCells(cells, text, style) {
const value = String(text || "");
if (!value) return;
for (let index = 0; index < value.length; ) {
const codePoint = value.codePointAt(index);
if (!Number.isFinite(codePoint)) break;
const ch = String.fromCodePoint(codePoint);
index += ch.length;
const width = measureCharDisplayColumns(ch);
if (width <= 0) {
for (let ownerIndex = cells.length - 1; ownerIndex >= 0; ownerIndex -= 1) {
if (cells[ownerIndex] && !cells[ownerIndex].continuation) {
cells[ownerIndex].text = `${cells[ownerIndex].text || ""}${ch}`;
break;
}
}
continue;
}
cells.push(createTerminalCell(ch, cloneSnapshotStyle(style), width));
for (let rest = width - 1; rest > 0; rest -= 1) {
cells.push(createContinuationCell(cloneSnapshotStyle(style)));
}
}
}
function serializeTerminalSnapshotRows(rowsInput) {
const rows = Array.isArray(rowsInput) ? rowsInput : [];
const styleTable = [];
const styleIndexBySignature = new Map();
const styledLines = rows.map((lineCells) => {
const runs = buildLineCellRenderRuns(Array.isArray(lineCells) ? lineCells : []);
return runs.map((run) => {
const entry = {};
const text = String((run && run.text) || "");
const columns = Math.max(0, Math.round(Number(run && run.columns) || 0));
if (text) {
entry.t = text;
}
if (columns > 0) {
entry.c = columns;
}
if (run && run.fixed) {
entry.f = 1;
}
const styleSignature = buildSnapshotStyleSignature(run && run.style);
if (styleSignature) {
let styleIndex = styleIndexBySignature.get(styleSignature);
if (!Number.isInteger(styleIndex)) {
styleIndex = styleTable.length;
styleIndexBySignature.set(styleSignature, styleIndex);
styleTable.push(cloneSnapshotStyle(run.style));
}
entry.s = styleIndex;
}
return entry;
});
});
return {
styleTable,
styledLines
};
}
function deserializeTerminalSnapshotRows(linesInput, styleTableInput) {
const styleTable = Array.isArray(styleTableInput) ? styleTableInput.map((style) => cloneSnapshotStyle(style)) : [];
const lines = Array.isArray(linesInput) ? linesInput : [];
return lines.map((lineRuns) => {
const cells = [];
const runs = Array.isArray(lineRuns) ? lineRuns : [];
runs.forEach((run) => {
const source = run && typeof run === "object" ? run : {};
const text = String(source.t || "");
const columns = Math.max(
0,
Math.round(Number(source.c !== undefined ? source.c : measureTextDisplayColumns(text)) || 0)
);
const styleIndex = Number(source.s);
const style =
Number.isInteger(styleIndex) && styleIndex >= 0 && styleIndex < styleTable.length
? styleTable[styleIndex]
: null;
if (!text) {
for (let index = 0; index < columns; index += 1) {
cells.push(createBlankCell(cloneSnapshotStyle(style)));
}
return;
}
appendStyledTextCells(cells, text, style);
});
return cells;
});
}
module.exports = {
deserializeTerminalSnapshotRows,
serializeTerminalSnapshotRows
};

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
const {
createBlankCell,
createContinuationCell,
createTerminalCell,
lineCellsToText
} = require("./terminalCursorModel.js");
const {
deserializeTerminalSnapshotRows,
serializeTerminalSnapshotRows
} = require("./terminalSnapshotCodec.js");
describe("terminalSnapshotCodec", () => {
it("会用压缩 run 快照往返恢复 ANSI 样式与占位空白", () => {
const styleA = { fg: "#ff5f56", bg: "#1f2937", bold: true, underline: false };
const styleB = { fg: "#5bd2ff", bg: "", bold: false, underline: true };
const rows = [
[
createTerminalCell("E", styleA, 1),
createTerminalCell("R", styleA, 1),
createTerminalCell("R", styleA, 1),
createBlankCell(styleA),
createTerminalCell("中", styleB, 2),
createContinuationCell(styleB),
createTerminalCell("A", styleB, 1)
]
];
const snapshot = serializeTerminalSnapshotRows(rows);
const restored = deserializeTerminalSnapshotRows(snapshot.styledLines, snapshot.styleTable);
const restoredRow = restored[0] || [];
expect(lineCellsToText(restoredRow)).toBe("ERR 中A");
expect(restoredRow[0]?.style).toEqual(styleA);
expect(restoredRow[3]?.placeholder).toBe(true);
expect(restoredRow[3]?.style).toEqual(styleA);
expect(restoredRow[4]?.style).toEqual(styleB);
expect(restoredRow[5]?.continuation).toBe(true);
expect(restoredRow[6]?.style).toEqual(styleB);
});
});

View File

@@ -0,0 +1,284 @@
/* global module, require */
const {
DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
TTS_SEGMENT_MAX_CHARS,
TTS_SEGMENT_MAX_UTF8_BYTES,
normalizeTtsSpeakableMaxChars,
normalizeTtsSegmentMaxChars,
resolveTtsSpeakableUtf8ByteLimit,
resolveTtsSegmentUtf8ByteLimit
} = require("../../utils/ttsSettings");
const ANSI_ESCAPE_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const BOX_DRAWING_PATTERN = /[┌┐└┘├┤┬┴┼│─╭╮╯╰═║╔╗╚╝]/;
const COMMAND_PREFIX_PATTERN =
/^\s*(?:[$#>]|>>>\s|(?:cd|ls|pwd|git|npm|pnpm|yarn|bun|node|npx|cat|grep|sed|awk|ssh|scp|rm|mv|cp|mkdir|touch|python|pip|cargo|go|java|docker|kubectl)\b)/i;
const CODE_TOKEN_PATTERN = /(?:=>|::|===|!==|&&|\|\||\{|\}|\[|\]|<\/?|\/>|;)/g;
const PATH_LINE_PATTERN =
/^\s*(?:~?\/\S+|\.{1,2}\/\S+|[A-Za-z]:\\\S+|(?:[A-Za-z0-9._-]+\/){2,}[A-Za-z0-9._-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)\s*$/;
const URL_LINE_PATTERN = /^\s*https?:\/\/\S+\s*$/i;
const PROGRESS_LINE_PATTERN = /(?:\b\d{1,3}%\b|\[[=>.\- ]{3,}\]|\bETA\b|\b\d+\/\d+\b|spinner|loading)/i;
const CODEX_INPUT_LINE_PATTERN = /^\s*[›»❯➜]\s+/;
const CODEX_FOOTER_LINE_PATTERN =
/\b(?:gpt-\d(?:\.\d+)?|claude(?:-[a-z0-9.-]+)?|gemini(?:-[a-z0-9.-]+)?|deepseek(?:-[a-z0-9.-]+)?|o\d(?:-[a-z0-9.-]+)?|sonnet|haiku|opus)\b.*(?:\b\d{1,3}%\s+(?:left|context left)\b|~\/\S*)/i;
const CODEX_FOOTER_FRAGMENT_PATTERN =
/(?:\b\d{1,3}%\s+(?:left|context left)\b.*~\/\S*|~\/\S*.*\b\d{1,3}%\s+(?:left|context left)\b)/i;
const CODEX_STATUS_LINE_PATTERN =
/^\s*(?:[!!⚠■●•]\s*)?(?:Working(?:\s|\(|$)|Tip:|Tips?:|Heads up\b|Conversation interrupted\b|Something went wrong\b|Hit\s+`?\/feedback`?\b|Booting MCP server:|MCP server:)/i;
const CHINESE_STATUS_LINE_PATTERN =
/^\s*(?:正在(?:分析|处理|读取|扫描|生成|检查|加载|连接|收集|整理|搜索)|(?:分析|处理|读取|加载|连接|生成)(?:中|中\.\.\.|中…+))[^。!?!?]{0,80}(?:\.\.\.|…+)?\s*$/;
const NATURAL_TEXT_PATTERN = /[\u3400-\u9fff]|[A-Za-z]{3,}/;
const SYMBOL_CHAR_PATTERN = /[\\\/[\]{}()<>_=+*`|#@$%^~]/g;
const MAX_SPEAKABLE_CHARS = DEFAULT_TTS_SPEAKABLE_MAX_CHARS;
const MAX_SPEAKABLE_UTF8_BYTES = resolveTtsSpeakableUtf8ByteLimit(DEFAULT_TTS_SPEAKABLE_MAX_CHARS);
function stripTerminalAnsi(text) {
return String(text || "")
.replace(/\r/g, "")
.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeSpeakableLine(line) {
return stripTerminalAnsi(line)
.replace(/[ \t\f\v]+/g, " ")
.replace(/\u00a0/g, " ")
.trim();
}
function cleanSpeakableLine(line) {
return String(line || "")
.replace(/^\s*(?:(?:[-*+]\s+|[•●○◦▪■·]\s*|\d+[.)、]\s+))/, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/\s{2,}/g, " ")
.trim();
}
function isCommandLikeLine(line) {
return COMMAND_PREFIX_PATTERN.test(line);
}
function isCodeLikeLine(line) {
if (!line) return false;
if (/^\s*```/.test(line)) return true;
if (/^\s*(?:const|let|var|function|class|import|export|return|if|for|while)\b/.test(line)) return true;
const codeTokenCount = (line.match(CODE_TOKEN_PATTERN) || []).length;
return codeTokenCount >= 3;
}
function hasHighSymbolDensity(line) {
const visible = String(line || "").replace(/\s/g, "");
if (!visible) return false;
const symbols = (visible.match(SYMBOL_CHAR_PATTERN) || []).length;
return symbols / visible.length >= 0.22;
}
function isSpeakableLine(line) {
if (!line) return false;
if (!NATURAL_TEXT_PATTERN.test(line)) return false;
if (BOX_DRAWING_PATTERN.test(line)) return false;
if (/^[-=_*]{4,}$/.test(line)) return false;
if (PROGRESS_LINE_PATTERN.test(line)) return false;
if (CODEX_INPUT_LINE_PATTERN.test(line)) return false;
if (CODEX_FOOTER_LINE_PATTERN.test(line)) return false;
if (CODEX_FOOTER_FRAGMENT_PATTERN.test(line)) return false;
if (CODEX_STATUS_LINE_PATTERN.test(line)) return false;
if (CHINESE_STATUS_LINE_PATTERN.test(line)) return false;
if (PATH_LINE_PATTERN.test(line) || URL_LINE_PATTERN.test(line)) return false;
if (isCommandLikeLine(line) || isCodeLikeLine(line)) return false;
if (hasHighSymbolDensity(line)) return false;
return true;
}
function collapseSpeakableText(text) {
return String(text || "")
.replace(/\s*\n\s*/g, " ")
.replace(/\s{2,}/g, " ")
.replace(/([,。!?;:,.!?;:])\1{1,}/g, "$1")
.replace(/([,。!?;:,.!?;:])\s+([A-Za-z\u3400-\u9fff])/g, "$1$2")
.replace(/([\u3400-\u9fff])\s+([\u3400-\u9fff])/g, "$1$2")
.trim();
}
function utf8ByteLength(text) {
let total = 0;
const source = String(text || "");
for (const char of source) {
const codePoint = char.codePointAt(0) || 0;
if (codePoint <= 0x7f) {
total += 1;
} else if (codePoint <= 0x7ff) {
total += 2;
} else if (codePoint <= 0xffff) {
total += 3;
} else {
total += 4;
}
}
return total;
}
function trimSpeakableText(text, maxChars, maxUtf8Bytes) {
const source = String(text || "");
const charLimit = normalizeTtsSpeakableMaxChars(maxChars);
const utf8Limit = Math.max(1, Math.round(Number(maxUtf8Bytes) || resolveTtsSpeakableUtf8ByteLimit(charLimit)));
if (source.length <= charLimit && utf8ByteLength(source) <= utf8Limit) {
return source;
}
let result = "";
let usedBytes = 0;
for (const char of source) {
if (result.length >= charLimit) {
break;
}
const nextBytes = utf8ByteLength(char);
if (usedBytes + nextBytes > utf8Limit) {
break;
}
result += char;
usedBytes += nextBytes;
}
return result
.replace(/[,、;:,.!?;:\s]+$/g, "")
.trim();
}
function splitSpeakableTextForTts(text, options) {
const config = options && typeof options === "object" ? options : {};
const source = collapseSpeakableText(text);
if (!source) {
return [];
}
const maxChars = normalizeTtsSegmentMaxChars(config.maxChars || TTS_SEGMENT_MAX_CHARS);
const maxUtf8Bytes = Math.max(
1,
Math.round(Number(config.maxUtf8Bytes) || resolveTtsSegmentUtf8ByteLimit(maxChars))
);
const chars = Array.from(source);
const segments = [];
let cursor = 0;
/**
* 分段策略优先找句号/问号/分号等强断点;
* 如果当前窗口里没有完整句子,再退回逗号或空白,避免整段都卡到硬切。
*/
while (cursor < chars.length) {
while (cursor < chars.length && /[\s,.!?;:]/.test(chars[cursor])) {
cursor += 1;
}
if (cursor >= chars.length) {
break;
}
let usedBytes = 0;
let end = cursor;
let lastStrongBreak = -1;
let lastSoftBreak = -1;
while (end < chars.length) {
const char = chars[end];
const nextBytes = utf8ByteLength(char);
if (end - cursor >= maxChars || usedBytes + nextBytes > maxUtf8Bytes) {
break;
}
usedBytes += nextBytes;
end += 1;
if (/[。!?!?;:]/.test(char)) {
lastStrongBreak = end;
} else if (/[,、,.]/.test(char) || /\s/.test(char)) {
lastSoftBreak = end;
}
}
let nextEnd = end;
const consumedChars = end - cursor;
const strongBreakFloor = Math.max(12, Math.floor(maxChars * 0.55));
const softBreakFloor = Math.max(12, Math.floor(maxChars * 0.45));
if (end < chars.length) {
if (lastStrongBreak >= cursor + strongBreakFloor) {
nextEnd = lastStrongBreak;
} else if (lastSoftBreak >= cursor + softBreakFloor) {
nextEnd = lastSoftBreak;
}
}
if (nextEnd <= cursor) {
nextEnd = Math.max(cursor + 1, end);
}
const segment = chars.slice(cursor, nextEnd).join("").trim();
if (!segment && consumedChars > 0) {
segments.push(chars.slice(cursor, end).join("").trim());
cursor = end;
continue;
}
if (segment) {
segments.push(segment);
}
cursor = nextEnd;
}
return segments.filter((segment) => !!segment);
}
/**
* 从一轮终端可见输出中抽取“最近一批适合朗读的自然语言”:
* 1. 仍然优先保留轮次尾部最近内容,但不再要求必须是单个连续段;
* 2. 中间若夹杂代码、路径、状态行,直接跳过并继续向上回溯;
* 3. 收口逻辑保持在短文本范围内,避免把整轮历史都送进 TTS。
*/
function buildSpeakableTerminalText(source, options) {
const config = options && typeof options === "object" ? options : {};
const maxChars = normalizeTtsSpeakableMaxChars(config.maxChars);
const maxUtf8Bytes = Math.max(
1,
Math.round(Number(config.maxUtf8Bytes) || resolveTtsSpeakableUtf8ByteLimit(maxChars))
);
const text = Array.isArray(source) ? source.join("\n") : String(source || "");
const normalized = stripTerminalAnsi(text);
if (!normalized.trim()) {
return "";
}
const lines = normalized.split(/\n+/).map(normalizeSpeakableLine);
const collected = [];
let collectedChars = 0;
let collectedBytes = 0;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line) {
continue;
}
if (!isSpeakableLine(line)) {
continue;
}
const cleaned = cleanSpeakableLine(line);
if (!cleaned) {
continue;
}
const separatorChars = collected.length > 0 ? 1 : 0;
const nextChars = cleaned.length + separatorChars;
const nextBytes = utf8ByteLength(cleaned) + separatorChars;
if (collected.length > 0 && (collectedChars + nextChars > maxChars || collectedBytes + nextBytes > maxUtf8Bytes)) {
break;
}
if (collected.length === 0 && (cleaned.length > maxChars || utf8ByteLength(cleaned) > maxUtf8Bytes)) {
collected.unshift(trimSpeakableText(cleaned, maxChars, maxUtf8Bytes));
break;
}
collected.unshift(cleaned);
collectedChars += nextChars;
collectedBytes += nextBytes;
}
return trimSpeakableText(collapseSpeakableText(collected.join("\n")), maxChars, maxUtf8Bytes);
}
function isSpeakableTextLikelyComplete(text) {
return /(?:[。!?!?:]|\.{1}|。{1})\s*$/.test(String(text || "").trim());
}
module.exports = {
MAX_SPEAKABLE_CHARS,
buildSpeakableTerminalText,
isSpeakableTextLikelyComplete,
splitSpeakableTextForTts,
stripTerminalAnsi
};

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
const {
buildSpeakableTerminalText,
isSpeakableTextLikelyComplete,
splitSpeakableTextForTts,
stripTerminalAnsi
} = require("./terminalSpeakableText.js");
describe("terminalSpeakableText", () => {
it("应提取最近一段自然语言并跳过命令与代码", () => {
const source = [
"$ codex ask",
"正在分析项目结构...",
"const answer = computeResult();",
"",
"请先检查 gateway 的环境变量配置。",
"确认 TTS_SECRET_ID、TTS_SECRET_KEY 与 TTS_REGION 是否一致。"
].join("\n");
expect(buildSpeakableTerminalText(source)).toBe(
"请先检查 gateway 的环境变量配置。确认 TTS_SECRET_ID、TTS_SECRET_KEY 与 TTS_REGION 是否一致。"
);
});
it("只有命令、路径和进度条时应返回空文本", () => {
const source = [
"$ npm run build",
"/Users/demo/project/src/index.ts",
"[====== ] 60%",
"spinner loading"
].join("\n");
expect(buildSpeakableTerminalText(source)).toBe("");
});
it("应跳过 Codex 输入行、状态行与底部 footer只保留回答正文", () => {
const source = [
" Summarize recent commits",
"Working (0s · esc to interrupt)",
"■ Conversation interrupted - tell the model what to do differently.",
"Something went wrong? Hit `/feedback` to report the issue.",
"本次修改主要收口了 TTS 播放链路。",
"同时移除了小程序侧重复下载缓存文件的绕路实现。",
"gpt-5.4 xhigh · 100% left · ~/remoteconn"
].join("\n");
expect(buildSpeakableTerminalText(source)).toBe(
"本次修改主要收口了 TTS 播放链路。同时移除了小程序侧重复下载缓存文件的绕路实现。"
);
});
it("应跳过被终端换行拆开的 footer 残片", () => {
const source = [
"本次修改已经完成。",
"42% left · ~/remoteconn"
].join("\n");
expect(buildSpeakableTerminalText(source)).toBe("本次修改已经完成。");
});
it("应去掉列表项目符号,避免 TTS 在首句前卡住", () => {
const source = [
"• 第一条消息。",
" 第二条消息。",
" 第三条消息。",
" 第四条消息。"
].join("\n");
expect(buildSpeakableTerminalText(source)).toBe("第一条消息。第二条消息。第三条消息。第四条消息。");
});
it("应去掉紧贴正文的项目符号前缀", () => {
const source = ["•第一条消息。", "•第二条消息。"].join("\n");
expect(buildSpeakableTerminalText(source)).toBe("第一条消息。第二条消息。");
});
it("中间夹杂代码块时应继续向上回收同轮正文,而不是只读最后一小段", () => {
const source = [
"第一段说明,先交代修复背景。",
"第二段说明,描述影响范围。",
"```ts",
"const demo = true;",
"```",
"第三段说明,给出处理方式。",
"第四段说明,提醒重启服务。"
].join("\n");
expect(buildSpeakableTerminalText(source, { maxChars: 200 })).toBe(
"第一段说明,先交代修复背景。第二段说明,描述影响范围。第三段说明,给出处理方式。第四段说明,提醒重启服务。"
);
});
it("长中文文本应保留到总量配置上限,并支持后续分段", () => {
const source = "这是一个较长的测试输出。".repeat(40);
const result = buildSpeakableTerminalText(source);
const segments = splitSpeakableTextForTts(result);
expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(1500);
expect(result.length).toBeLessThanOrEqual(500);
expect(result.length).toBeGreaterThan(0);
expect(segments.length).toBeGreaterThan(1);
segments.forEach((segment: string) => {
expect(Buffer.byteLength(segment, "utf8")).toBeLessThanOrEqual(240);
expect(segment.length).toBeLessThanOrEqual(80);
});
});
it("应支持按自定义总长度提取正文", () => {
const source = "这是一个较长的测试输出。".repeat(40);
const result = buildSpeakableTerminalText(source, { maxChars: 120 });
expect(result.length).toBeLessThanOrEqual(120);
expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(360);
});
it("应支持去除 ANSI 并判断句子是否收束", () => {
expect(stripTerminalAnsi("\u001b[32m请先检查配置。\u001b[0m")).toBe("请先检查配置。");
expect(isSpeakableTextLikelyComplete("请先检查配置。")).toBe(true);
expect(isSpeakableTextLikelyComplete("请先检查配置")).toBe(false);
});
});

View File

@@ -0,0 +1,233 @@
/* global module */
const MEDIUM_BACKLOG_MIN_REMAINING_BYTES = 8 * 1024;
const MEDIUM_BACKLOG_MIN_PENDING_BYTES = 8 * 1024;
const MEDIUM_BACKLOG_MAX_SLICES = 8;
const MEDIUM_BACKLOG_RENDER_COOLDOWN_MS = 220;
const LARGE_BACKLOG_MIN_REMAINING_BYTES = 64 * 1024;
const LARGE_BACKLOG_MIN_PENDING_BYTES = 32 * 1024;
const LARGE_BACKLOG_MAX_SLICES = 32;
const LARGE_BACKLOG_RENDER_COOLDOWN_MS = 320;
const CRITICAL_BACKLOG_MIN_TOTAL_BYTES = 64 * 1024;
const CRITICAL_BACKLOG_MIN_PENDING_BYTES = 24 * 1024;
const CRITICAL_BACKLOG_MIN_PENDING_SAMPLES = 128;
const CRITICAL_BACKLOG_MIN_SCHEDULER_WAIT_MS = 1200;
const CRITICAL_BACKLOG_MIN_ACTIVE_AGE_MS = 1200;
const CRITICAL_BACKLOG_FRAME_PENDING_BYTES = 24 * 1024;
const CRITICAL_BACKLOG_FRAME_MAX_SLICES = 20;
const CRITICAL_BACKLOG_RENDER_COOLDOWN_MS = 520;
const STDOUT_OVERLAY_SYNC_COOLDOWN_MS = 240;
function normalizeNonNegativeInteger(value, fallback) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return Math.max(0, Math.round(Number(fallback) || 0));
}
return Math.max(0, Math.round(numeric));
}
function shouldUseCriticalBacklogPolicy(options) {
const source = options && typeof options === "object" ? options : {};
const totalRawBytes = normalizeNonNegativeInteger(source.totalRawBytes, 0);
const pendingStdoutBytes = normalizeNonNegativeInteger(source.pendingStdoutBytes, 0);
const pendingStdoutSamples = normalizeNonNegativeInteger(source.pendingStdoutSamples, 0);
const schedulerWaitMs = normalizeNonNegativeInteger(source.schedulerWaitMs, 0);
const activeStdoutAgeMs = normalizeNonNegativeInteger(source.activeStdoutAgeMs, 0);
return (
pendingStdoutBytes >= CRITICAL_BACKLOG_MIN_PENDING_BYTES ||
pendingStdoutSamples >= CRITICAL_BACKLOG_MIN_PENDING_SAMPLES ||
schedulerWaitMs >= CRITICAL_BACKLOG_MIN_SCHEDULER_WAIT_MS ||
(totalRawBytes >= CRITICAL_BACKLOG_MIN_TOTAL_BYTES &&
activeStdoutAgeMs >= CRITICAL_BACKLOG_MIN_ACTIVE_AGE_MS)
);
}
/**
* stdout 的真正瓶颈不是 VT 解析,而是每个 slice 都把整份 `outputRenderLines`
* 重新通过 `setData` 送去视图层。
*
* 这里按 backlog 做两档降频:
* 1. 中等 backlog累计到 8KB 或 8 个 slice 再提交一次;
* 2. 大 backlog累计到 32KB 或 32 个 slice 再提交一次。
*
* 若用户刚有输入,或当前 slice 触发了终端响应帧,则立即提交,避免交互被延后。
*/
function resolveTerminalStdoutRenderDecision(options) {
const source = options && typeof options === "object" ? options : {};
if (source.yieldedToUserInput) {
return {
defer: false,
reason: "user_input",
policy: "interactive"
};
}
if (normalizeNonNegativeInteger(source.pendingResponseCount, 0) > 0) {
return {
defer: false,
reason: "pending_response",
policy: "interactive"
};
}
const remainingBytes = normalizeNonNegativeInteger(source.remainingBytes, 0);
const pendingReplayBytes = normalizeNonNegativeInteger(source.pendingReplayBytes, 0);
const nextSlicesSinceLastRender = normalizeNonNegativeInteger(source.nextSlicesSinceLastRender, 0);
const timeSinceLastRenderMs = normalizeNonNegativeInteger(
source.timeSinceLastRenderMs,
Number.MAX_SAFE_INTEGER
);
const taskDone = !!source.taskDone;
const usingCriticalPolicy = shouldUseCriticalBacklogPolicy(source);
if (usingCriticalPolicy) {
if (taskDone) {
return {
defer: false,
reason: "task_complete",
policy: "critical_backlog"
};
}
if (
nextSlicesSinceLastRender > 0 &&
timeSinceLastRenderMs < CRITICAL_BACKLOG_RENDER_COOLDOWN_MS
) {
return {
defer: true,
reason: "render_cooldown",
policy: "critical_backlog"
};
}
if (pendingReplayBytes >= CRITICAL_BACKLOG_FRAME_PENDING_BYTES) {
return {
defer: false,
reason: "pending_bytes_threshold",
policy: "critical_backlog"
};
}
if (nextSlicesSinceLastRender >= CRITICAL_BACKLOG_FRAME_MAX_SLICES) {
return {
defer: false,
reason: "slice_threshold",
policy: "critical_backlog"
};
}
return {
defer: true,
reason: "defer_critical_backlog",
policy: "critical_backlog"
};
}
const usingLargePolicy =
remainingBytes >= LARGE_BACKLOG_MIN_REMAINING_BYTES ||
pendingReplayBytes >= LARGE_BACKLOG_MIN_PENDING_BYTES;
const minRemainingBytes = usingLargePolicy
? LARGE_BACKLOG_MIN_REMAINING_BYTES
: MEDIUM_BACKLOG_MIN_REMAINING_BYTES;
const minPendingBytes = usingLargePolicy
? LARGE_BACKLOG_MIN_PENDING_BYTES
: MEDIUM_BACKLOG_MIN_PENDING_BYTES;
const maxSlicesSinceRender = usingLargePolicy ? LARGE_BACKLOG_MAX_SLICES : MEDIUM_BACKLOG_MAX_SLICES;
const renderCooldownMs = usingLargePolicy
? LARGE_BACKLOG_RENDER_COOLDOWN_MS
: MEDIUM_BACKLOG_RENDER_COOLDOWN_MS;
const policy = usingLargePolicy ? "large_backlog" : "medium_backlog";
if (taskDone) {
return {
defer: false,
reason: "task_complete",
policy
};
}
if (
nextSlicesSinceLastRender > 0 &&
timeSinceLastRenderMs < renderCooldownMs &&
pendingReplayBytes < minPendingBytes &&
nextSlicesSinceLastRender < maxSlicesSinceRender
) {
return {
defer: true,
reason: "render_cooldown",
policy
};
}
if (remainingBytes < minRemainingBytes) {
return {
defer: false,
reason: "remaining_below_threshold",
policy
};
}
if (pendingReplayBytes >= minPendingBytes) {
return {
defer: false,
reason: "pending_bytes_threshold",
policy
};
}
if (nextSlicesSinceLastRender >= maxSlicesSinceRender) {
return {
defer: false,
reason: "slice_threshold",
policy
};
}
return {
defer: true,
reason: "defer_backlog",
policy
};
}
function resolveTerminalStdoutOverlayDecision(options) {
const source = options && typeof options === "object" ? options : {};
if (source.isFinalRender) {
return {
sync: true,
reason: "task_complete"
};
}
if (source.yieldedToUserInput) {
return {
sync: true,
reason: "user_input"
};
}
const overlayPassCount = normalizeNonNegativeInteger(source.overlayPassCount, 0);
if (overlayPassCount <= 0) {
return {
sync: true,
reason: "first_render"
};
}
const timeSinceLastOverlayMs = normalizeNonNegativeInteger(
source.timeSinceLastOverlayMs,
Number.MAX_SAFE_INTEGER
);
if (timeSinceLastOverlayMs >= STDOUT_OVERLAY_SYNC_COOLDOWN_MS) {
return {
sync: true,
reason: "overlay_cooldown_elapsed"
};
}
return {
sync: false,
reason: "overlay_throttled"
};
}
function shouldDeferTerminalStdoutRender(options) {
return resolveTerminalStdoutRenderDecision(options).defer;
}
module.exports = {
resolveTerminalStdoutOverlayDecision,
resolveTerminalStdoutRenderDecision,
shouldDeferTerminalStdoutRender
};

View File

@@ -0,0 +1,292 @@
import { describe, expect, it } from "vitest";
const {
resolveTerminalStdoutOverlayDecision,
resolveTerminalStdoutRenderDecision,
shouldDeferTerminalStdoutRender
} = require("./terminalStdoutRenderPolicy.js");
describe("terminalStdoutRenderPolicy", () => {
it("小 backlog 不会延后视图提交", () => {
expect(
shouldDeferTerminalStdoutRender({
remainingBytes: 4096,
pendingReplayBytes: 1024,
nextSlicesSinceLastRender: 1,
pendingResponseCount: 0,
yieldedToUserInput: false
})
).toBe(false);
});
it("中等 backlog 会先累计到阈值再提交", () => {
expect(
shouldDeferTerminalStdoutRender({
remainingBytes: 24 * 1024,
pendingReplayBytes: 3 * 1024,
nextSlicesSinceLastRender: 3,
pendingResponseCount: 0,
yieldedToUserInput: false
})
).toBe(true);
expect(
shouldDeferTerminalStdoutRender({
remainingBytes: 24 * 1024,
pendingReplayBytes: 8 * 1024,
nextSlicesSinceLastRender: 3,
pendingResponseCount: 0,
yieldedToUserInput: false
})
).toBe(false);
});
it("大 backlog 会使用更高阈值,避免频繁整包 setData", () => {
expect(
shouldDeferTerminalStdoutRender({
remainingBytes: 512 * 1024,
pendingReplayBytes: 16 * 1024,
nextSlicesSinceLastRender: 12,
pendingResponseCount: 0,
yieldedToUserInput: false
})
).toBe(true);
expect(
shouldDeferTerminalStdoutRender({
remainingBytes: 512 * 1024,
pendingReplayBytes: 16 * 1024,
nextSlicesSinceLastRender: 32,
pendingResponseCount: 0,
yieldedToUserInput: false
})
).toBe(false);
});
it("用户输入或终端响应存在时必须立即提交", () => {
expect(
shouldDeferTerminalStdoutRender({
remainingBytes: 512 * 1024,
pendingReplayBytes: 1024,
nextSlicesSinceLastRender: 1,
pendingResponseCount: 1,
yieldedToUserInput: false
})
).toBe(false);
expect(
shouldDeferTerminalStdoutRender({
remainingBytes: 512 * 1024,
pendingReplayBytes: 1024,
nextSlicesSinceLastRender: 1,
pendingResponseCount: 0,
yieldedToUserInput: true
})
).toBe(false);
});
it("会给出本轮提交或延后的决策原因,便于诊断 render 频率", () => {
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 512 * 1024,
pendingReplayBytes: 1024,
nextSlicesSinceLastRender: 1,
pendingResponseCount: 0,
yieldedToUserInput: true
})
).toMatchObject({
defer: false,
reason: "user_input",
policy: "interactive"
});
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 24 * 1024,
pendingReplayBytes: 3 * 1024,
nextSlicesSinceLastRender: 3,
pendingResponseCount: 0,
yieldedToUserInput: false
})
).toMatchObject({
defer: true,
reason: "defer_backlog",
policy: "medium_backlog"
});
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 24 * 1024,
pendingReplayBytes: 8 * 1024,
nextSlicesSinceLastRender: 3,
pendingResponseCount: 0,
yieldedToUserInput: false
})
).toMatchObject({
defer: false,
reason: "pending_bytes_threshold",
policy: "medium_backlog"
});
});
it("render 冷却期间即使进入尾段,也会继续延后视图提交", () => {
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 4096,
pendingReplayBytes: 2048,
nextSlicesSinceLastRender: 2,
pendingResponseCount: 0,
yieldedToUserInput: false,
timeSinceLastRenderMs: 80,
taskDone: false
})
).toMatchObject({
defer: true,
reason: "render_cooldown",
policy: "medium_backlog"
});
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 4096,
pendingReplayBytes: 2048,
nextSlicesSinceLastRender: 2,
pendingResponseCount: 0,
yieldedToUserInput: false,
timeSinceLastRenderMs: 280,
taskDone: false
})
).toMatchObject({
defer: false,
reason: "remaining_below_threshold",
policy: "medium_backlog"
});
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 0,
pendingReplayBytes: 1024,
nextSlicesSinceLastRender: 2,
pendingResponseCount: 0,
yieldedToUserInput: false,
timeSinceLastRenderMs: 20,
taskDone: true
})
).toMatchObject({
defer: false,
reason: "task_complete"
});
});
it("高 backlog 时会进入更激进的降级模式,优先丢弃中间帧", () => {
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 4096,
pendingReplayBytes: 4096,
nextSlicesSinceLastRender: 4,
pendingResponseCount: 0,
yieldedToUserInput: false,
timeSinceLastRenderMs: 600,
taskDone: false,
totalRawBytes: 80 * 1024,
pendingStdoutBytes: 48 * 1024,
pendingStdoutSamples: 160,
schedulerWaitMs: 2400,
activeStdoutAgeMs: 1800
})
).toMatchObject({
defer: true,
reason: "defer_critical_backlog",
policy: "critical_backlog"
});
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 4096,
pendingReplayBytes: 28 * 1024,
nextSlicesSinceLastRender: 4,
pendingResponseCount: 0,
yieldedToUserInput: false,
timeSinceLastRenderMs: 600,
taskDone: false,
totalRawBytes: 80 * 1024,
pendingStdoutBytes: 48 * 1024,
pendingStdoutSamples: 160,
schedulerWaitMs: 2400,
activeStdoutAgeMs: 1800
})
).toMatchObject({
defer: false,
reason: "pending_bytes_threshold",
policy: "critical_backlog"
});
expect(
resolveTerminalStdoutRenderDecision({
remainingBytes: 4096,
pendingReplayBytes: 4096,
nextSlicesSinceLastRender: 24,
pendingResponseCount: 0,
yieldedToUserInput: false,
timeSinceLastRenderMs: 600,
taskDone: false,
totalRawBytes: 80 * 1024,
pendingStdoutBytes: 48 * 1024,
pendingStdoutSamples: 160,
schedulerWaitMs: 2400,
activeStdoutAgeMs: 1800
})
).toMatchObject({
defer: false,
reason: "slice_threshold",
policy: "critical_backlog"
});
});
it("stdout 持续输出时会对 overlay 做节流,但最终一帧仍会同步", () => {
expect(
resolveTerminalStdoutOverlayDecision({
isFinalRender: false,
yieldedToUserInput: false,
overlayPassCount: 0,
timeSinceLastOverlayMs: 0
})
).toMatchObject({
sync: true,
reason: "first_render"
});
expect(
resolveTerminalStdoutOverlayDecision({
isFinalRender: false,
yieldedToUserInput: false,
overlayPassCount: 2,
timeSinceLastOverlayMs: 80
})
).toMatchObject({
sync: false,
reason: "overlay_throttled"
});
expect(
resolveTerminalStdoutOverlayDecision({
isFinalRender: false,
yieldedToUserInput: false,
overlayPassCount: 2,
timeSinceLastOverlayMs: 260
})
).toMatchObject({
sync: true,
reason: "overlay_cooldown_elapsed"
});
expect(
resolveTerminalStdoutOverlayDecision({
isFinalRender: true,
yieldedToUserInput: false,
overlayPassCount: 2,
timeSinceLastOverlayMs: 80
})
).toMatchObject({
sync: true,
reason: "task_complete"
});
});
});

View File

@@ -0,0 +1,280 @@
/* global module */
/**
* 终端视口层只关心两件事:
* 1. 当前到底应该渲染多少行,避免 normal buffer 在 prompt 下方保留虚假空白尾部;
* 2. 当前内容理论上的最大滚动值,保证 scroll-view 与 overlay 使用同一套边界。
*
* 这里不改 VT buffer 本身,只做页面投影。
*/
const TERMINAL_VIEWPORT_TARGET_RENDER_ROWS = 160;
const TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS = 24;
const TERMINAL_VIEWPORT_SCROLL_TARGET_RENDER_ROWS = 224;
const TERMINAL_VIEWPORT_SCROLL_MIN_EDGE_BUFFER_ROWS = 40;
const TERMINAL_VIEWPORT_DIRECTIONAL_LEAD_RATIO = 0.7;
function normalizeNonNegativeInteger(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.round(parsed));
}
function normalizeRows(rows) {
return Array.isArray(rows) && rows.length > 0 ? rows : [[]];
}
function normalizeOptionalNonNegativeInteger(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return Math.max(0, Math.round(parsed));
}
function normalizeActiveBufferName(activeBufferName) {
return activeBufferName === "alt" ? "alt" : "normal";
}
function normalizeScrollDirection(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
if (parsed > 0) return 1;
if (parsed < 0) return -1;
return 0;
}
function lineHasVisibleText(line) {
if (!Array.isArray(line) || line.length === 0) {
return false;
}
return line.some((cell) => {
if (!cell || cell.continuation) {
return false;
}
return String(cell.text || "").length > 0;
});
}
function resolveLastNonEmptyRow(rows) {
for (let index = rows.length - 1; index >= 0; index -= 1) {
if (lineHasVisibleText(rows[index])) {
return index;
}
}
return 0;
}
/**
* normal buffer 的 live tail 应该以当前 cursor 行为上界。
* 否则哪怕 prompt 已经到底scroll-view 仍会因为尾部空行继续给出可滚动空间。
*
* alternate screen 维持整屏语义,不做裁剪。
*/
function resolveTerminalRenderRows(bufferRows, cursorRow, activeBufferName) {
const rows = normalizeRows(bufferRows);
if (normalizeActiveBufferName(activeBufferName) === "alt") {
return rows;
}
const cursorExclusive = normalizeNonNegativeInteger(cursorRow, 0) + 1;
const contentExclusive = resolveLastNonEmptyRow(rows) + 1;
const tailExclusive = Math.max(1, Math.min(rows.length, Math.max(cursorExclusive, contentExclusive)));
return rows.slice(0, tailExclusive);
}
function resolveTerminalMaxScrollTop(renderRowCount, visibleRows, lineHeight) {
const rows = Math.max(1, normalizeNonNegativeInteger(renderRowCount, 1));
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
const px = Math.max(1, normalizeNonNegativeInteger(lineHeight, 1));
return Math.max(0, (rows - viewportRows) * px);
}
function resolveTerminalTargetRenderRows(contentRowCount, visibleRows, minEdgeBufferRows, targetRenderRows) {
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
const edgeBufferRows = Math.max(
0,
normalizeNonNegativeInteger(minEdgeBufferRows, TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS)
);
const targetRows = Math.max(
1,
normalizeNonNegativeInteger(targetRenderRows, TERMINAL_VIEWPORT_TARGET_RENDER_ROWS)
);
const minimumRows = viewportRows + edgeBufferRows * 2;
return Math.min(rows, Math.max(minimumRows, targetRows));
}
function resolveTerminalDirectionalBuffers(extraRows, direction, minEdgeBufferRows) {
const remainingRows = Math.max(0, normalizeNonNegativeInteger(extraRows, 0));
const normalizedDirection = normalizeScrollDirection(direction);
const edgeBufferRows = Math.max(
0,
normalizeNonNegativeInteger(minEdgeBufferRows, TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS)
);
if (normalizedDirection === 0) {
const backwardRows = Math.floor(remainingRows / 2);
return {
backwardRows,
forwardRows: remainingRows - backwardRows
};
}
const trailingRows = Math.min(
remainingRows,
Math.max(
edgeBufferRows,
Math.floor(remainingRows * (1 - TERMINAL_VIEWPORT_DIRECTIONAL_LEAD_RATIO))
)
);
const leadingRows = Math.max(0, remainingRows - trailingRows);
return normalizedDirection > 0
? {
backwardRows: trailingRows,
forwardRows: leadingRows
}
: {
backwardRows: leadingRows,
forwardRows: trailingRows
};
}
function fillTerminalRenderWindow(renderStartRow, renderEndRow, contentRowCount, targetRenderRows) {
let startRow = Math.max(0, normalizeNonNegativeInteger(renderStartRow, 0));
let endRow = Math.max(startRow, normalizeNonNegativeInteger(renderEndRow, startRow));
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
const targetRows = Math.max(1, normalizeNonNegativeInteger(targetRenderRows, 1));
let missingRows = Math.max(0, targetRows - (endRow - startRow));
if (missingRows <= 0) {
return {
renderStartRow: startRow,
renderEndRow: endRow
};
}
const extendBackward = Math.min(startRow, missingRows);
startRow -= extendBackward;
missingRows -= extendBackward;
if (missingRows > 0) {
const extendForward = Math.min(rows - endRow, missingRows);
endRow += extendForward;
missingRows -= extendForward;
}
if (missingRows > 0) {
const extendBackwardAgain = Math.min(startRow, missingRows);
startRow -= extendBackwardAgain;
}
return {
renderStartRow: startRow,
renderEndRow: endRow
};
}
/**
* 小程序 scroll-view 一旦挂上几百行富文本,`setData` 和布局都会明显变重。
* 这里基于“当前目标 scrollTop + 固定总预算”只截出一个窗口,靠上下 spacer 维持完整滚动高度。
* 预算固定后,再根据最近滚动方向把更多余量分配到前方,兼顾性能与快速滑动时的预取需求。
*/
function resolveTerminalRenderWindow(contentRowCount, visibleRows, lineHeight, options) {
const source = options && typeof options === "object" ? options : {};
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
const px = Math.max(1, normalizeNonNegativeInteger(lineHeight, 1));
const maxScrollTop = resolveTerminalMaxScrollTop(rows, viewportRows, px);
const normalizedScrollTop = normalizeOptionalNonNegativeInteger(source.scrollTop);
const followTail = source.followTail === true;
const scrollDirection = normalizeScrollDirection(source.scrollDirection);
const scrollViewport = source.scrollViewport === true;
const minEdgeBufferRows = scrollViewport
? TERMINAL_VIEWPORT_SCROLL_MIN_EDGE_BUFFER_ROWS
: TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS;
const targetRenderRows = resolveTerminalTargetRenderRows(
rows,
viewportRows,
minEdgeBufferRows,
scrollViewport ? TERMINAL_VIEWPORT_SCROLL_TARGET_RENDER_ROWS : TERMINAL_VIEWPORT_TARGET_RENDER_ROWS
);
let clampedScrollTop = 0;
let windowed = false;
if (followTail) {
clampedScrollTop = maxScrollTop;
windowed = true;
} else if (normalizedScrollTop !== null) {
clampedScrollTop = Math.min(maxScrollTop, normalizedScrollTop);
windowed = true;
}
if (!windowed) {
return {
clampedScrollTop,
visibleStartRow: 0,
visibleEndRow: rows,
renderStartRow: 0,
renderEndRow: rows,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
backwardBufferRows: 0,
forwardBufferRows: 0
};
}
const maxVisibleStart = Math.max(0, rows - viewportRows);
const visibleStartRow = Math.min(maxVisibleStart, Math.max(0, Math.floor(clampedScrollTop / px)));
const visibleEndRow = Math.min(rows, visibleStartRow + viewportRows);
const extraRows = Math.max(0, targetRenderRows - viewportRows);
const directionalBuffers = resolveTerminalDirectionalBuffers(extraRows, scrollDirection, minEdgeBufferRows);
const filledWindow = fillTerminalRenderWindow(
Math.max(0, visibleStartRow - directionalBuffers.backwardRows),
Math.min(rows, visibleEndRow + directionalBuffers.forwardRows),
rows,
targetRenderRows
);
const renderStartRow = filledWindow.renderStartRow;
const renderEndRow = filledWindow.renderEndRow;
return {
clampedScrollTop,
visibleStartRow,
visibleEndRow,
renderStartRow,
renderEndRow,
topSpacerHeight: renderStartRow * px,
bottomSpacerHeight: Math.max(0, rows - renderEndRow) * px,
backwardBufferRows: Math.max(0, visibleStartRow - renderStartRow),
forwardBufferRows: Math.max(0, renderEndRow - visibleEndRow)
};
}
function buildTerminalViewportState(options) {
const source = options && typeof options === "object" ? options : {};
const contentRows = resolveTerminalRenderRows(source.bufferRows, source.cursorRow, source.activeBufferName);
const contentRowCount = contentRows.length;
const windowState = resolveTerminalRenderWindow(
contentRowCount,
source.visibleRows,
source.lineHeight,
source
);
const renderRows = contentRows.slice(windowState.renderStartRow, windowState.renderEndRow);
const renderRowCount = renderRows.length;
const maxScrollTop = resolveTerminalMaxScrollTop(contentRowCount, source.visibleRows, source.lineHeight);
return {
activeBufferName: normalizeActiveBufferName(source.activeBufferName),
contentRows,
contentRowCount,
clampedScrollTop: Math.min(maxScrollTop, Math.max(0, windowState.clampedScrollTop)),
renderRows,
renderRowCount,
visibleStartRow: windowState.visibleStartRow,
visibleEndRow: windowState.visibleEndRow,
renderStartRow: windowState.renderStartRow,
renderEndRow: windowState.renderEndRow,
topSpacerHeight: windowState.topSpacerHeight,
bottomSpacerHeight: windowState.bottomSpacerHeight,
backwardBufferRows: windowState.backwardBufferRows,
forwardBufferRows: windowState.forwardBufferRows,
maxScrollTop
};
}
module.exports = {
buildTerminalViewportState,
normalizeActiveBufferName,
resolveTerminalMaxScrollTop,
resolveTerminalRenderRows
};

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
const {
buildTerminalViewportState,
resolveTerminalMaxScrollTop,
resolveTerminalRenderRows
} = require("./terminalViewportModel.js");
describe("terminalViewportModel", () => {
it("normal buffer 会裁掉 cursor 行之后的虚假尾部,避免 prompt 下方继续可滚动", () => {
const rows = [[{ text: "a" }], [{ text: "b" }], [], []];
const renderRows = resolveTerminalRenderRows(rows, 1, "normal");
expect(renderRows).toHaveLength(2);
expect(renderRows).toEqual(rows.slice(0, 2));
});
it("alternate screen 保留整屏行数,不裁掉底部空白", () => {
const rows = [[{ text: "a" }], [], [], []];
const renderRows = resolveTerminalRenderRows(rows, 0, "alt");
expect(renderRows).toHaveLength(4);
expect(renderRows).toEqual(rows);
});
it("normal buffer 在 cursor 行之后若仍有真实 footer会保留到最后一个非空行", () => {
const rows = [[{ text: "prompt" }], [], [{ text: "footer" }], []];
const renderRows = resolveTerminalRenderRows(rows, 0, "normal");
expect(renderRows).toHaveLength(3);
expect(renderRows).toEqual(rows.slice(0, 3));
});
it("最大滚动值基于最终渲染行数,而不是旧尾部空行", () => {
const viewport = buildTerminalViewportState({
bufferRows: [[{ text: "a" }], [{ text: "b" }], [], []],
cursorRow: 1,
activeBufferName: "normal",
visibleRows: 1,
lineHeight: 20
});
expect(viewport.renderRowCount).toBe(2);
expect(viewport.maxScrollTop).toBe(20);
expect(resolveTerminalMaxScrollTop(2, 1, 20)).toBe(20);
});
it("最大滚动值会把 cursor 后的真实 footer 也算进去,而不是只看 cursor 行", () => {
const viewport = buildTerminalViewportState({
bufferRows: [[{ text: "prompt" }], [], [{ text: "footer" }], []],
cursorRow: 0,
activeBufferName: "normal",
visibleRows: 1,
lineHeight: 20
});
expect(viewport.renderRowCount).toBe(3);
expect(viewport.maxScrollTop).toBe(40);
});
it("followTail 模式只渲染底部可视区附近窗口,并用 spacer 保留完整滚动高度", () => {
const rows = Array.from({ length: 400 }, (_, index) => [{ text: `row-${index}` }]);
const viewport = buildTerminalViewportState({
bufferRows: rows,
cursorRow: 399,
activeBufferName: "normal",
visibleRows: 5,
lineHeight: 10,
followTail: true,
scrollDirection: 1
});
expect(viewport.contentRowCount).toBe(400);
expect(viewport.maxScrollTop).toBe(3950);
expect(viewport.clampedScrollTop).toBe(3950);
expect(viewport.renderStartRow).toBe(240);
expect(viewport.renderEndRow).toBe(400);
expect(viewport.renderRowCount).toBe(160);
expect(viewport.topSpacerHeight).toBe(2400);
expect(viewport.bottomSpacerHeight).toBe(0);
expect(viewport.backwardBufferRows).toBe(155);
expect(viewport.forwardBufferRows).toBe(0);
expect(viewport.renderRows[0]).toEqual(rows[240]);
expect(viewport.renderRows.at(-1)).toEqual(rows[399]);
});
it("传入 scrollTop 时,会围绕当前滚动窗口裁出中段正文", () => {
const rows = Array.from({ length: 400 }, (_, index) => [{ text: `row-${index}` }]);
const viewport = buildTerminalViewportState({
bufferRows: rows,
cursorRow: 399,
activeBufferName: "normal",
visibleRows: 5,
lineHeight: 10,
scrollTop: 1000,
scrollDirection: 1
});
expect(viewport.clampedScrollTop).toBe(1000);
expect(viewport.renderStartRow).toBe(54);
expect(viewport.renderEndRow).toBe(214);
expect(viewport.renderRowCount).toBe(160);
expect(viewport.topSpacerHeight).toBe(540);
expect(viewport.bottomSpacerHeight).toBe(1860);
expect(viewport.backwardBufferRows).toBe(46);
expect(viewport.forwardBufferRows).toBe(109);
expect(viewport.renderRows[0]).toEqual(rows[54]);
expect(viewport.renderRows.at(-1)).toEqual(rows[213]);
});
it("滚动补刷模式会扩大窗口预算,减少快速滑动时频繁换窗", () => {
const rows = Array.from({ length: 400 }, (_, index) => [{ text: `row-${index}` }]);
const viewport = buildTerminalViewportState({
bufferRows: rows,
cursorRow: 399,
activeBufferName: "normal",
visibleRows: 5,
lineHeight: 10,
scrollTop: 1000,
scrollDirection: -1,
scrollViewport: true
});
expect(viewport.renderRowCount).toBe(224);
expect(viewport.renderStartRow).toBe(0);
expect(viewport.renderEndRow).toBe(224);
expect(viewport.topSpacerHeight).toBe(0);
expect(viewport.bottomSpacerHeight).toBe(1760);
});
});

View File

@@ -0,0 +1,123 @@
/* global module */
const TOUCH_SHIFT_MODE_OFF = "off";
const TOUCH_SHIFT_MODE_ONCE = "once";
const TOUCH_SHIFT_MODE_LOCK = "lock";
const TOUCH_SHIFT_DOUBLE_TAP_MS = 320;
function normalizeTouchShiftMode(value) {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === TOUCH_SHIFT_MODE_ONCE) return TOUCH_SHIFT_MODE_ONCE;
if (normalized === TOUCH_SHIFT_MODE_LOCK) return TOUCH_SHIFT_MODE_LOCK;
return TOUCH_SHIFT_MODE_OFF;
}
function isTouchShiftActive(mode) {
const normalized = normalizeTouchShiftMode(mode);
return normalized === TOUCH_SHIFT_MODE_ONCE || normalized === TOUCH_SHIFT_MODE_LOCK;
}
function isAsciiLetter(value) {
return /[A-Za-z]/.test(String(value || ""));
}
function resolveTouchShiftModeOnTap(currentMode, lastTapAt, now, doubleTapWindowMs) {
const normalizedMode = normalizeTouchShiftMode(currentMode);
const currentTime = Number.isFinite(Number(now)) ? Number(now) : Date.now();
const previousTapAt = Number(lastTapAt);
const windowMs = Number.isFinite(Number(doubleTapWindowMs))
? Math.max(0, Number(doubleTapWindowMs))
: TOUCH_SHIFT_DOUBLE_TAP_MS;
if (normalizedMode === TOUCH_SHIFT_MODE_LOCK) {
return TOUCH_SHIFT_MODE_OFF;
}
if (
normalizedMode === TOUCH_SHIFT_MODE_ONCE &&
Number.isFinite(previousTapAt) &&
currentTime >= previousTapAt &&
currentTime - previousTapAt <= windowMs
) {
return TOUCH_SHIFT_MODE_LOCK;
}
return TOUCH_SHIFT_MODE_ONCE;
}
function findCommonPrefixLength(previousValue, nextValue) {
const max = Math.min(previousValue.length, nextValue.length);
let index = 0;
while (index < max && previousValue[index] === nextValue[index]) {
index += 1;
}
return index;
}
function findCommonSuffixLength(previousValue, nextValue, prefixLength) {
const previousRemain = previousValue.length - prefixLength;
const nextRemain = nextValue.length - prefixLength;
const max = Math.min(previousRemain, nextRemain);
let index = 0;
while (
index < max &&
previousValue[previousValue.length - 1 - index] === nextValue[nextValue.length - 1 - index]
) {
index += 1;
}
return index;
}
function applyTouchShiftToValue(previousValue, nextValue, mode) {
const previousText = String(previousValue || "");
const nextText = String(nextValue || "");
const normalizedMode = normalizeTouchShiftMode(mode);
if (!isTouchShiftActive(normalizedMode) || !nextText) {
return {
value: nextText,
consumedOnce: false,
touchedLetter: false,
transformed: false
};
}
const prefixLength = findCommonPrefixLength(previousText, nextText);
const suffixLength = findCommonSuffixLength(previousText, nextText, prefixLength);
const insertedEnd = nextText.length - suffixLength;
const insertedText = nextText.slice(prefixLength, insertedEnd);
if (!insertedText) {
return {
value: nextText,
consumedOnce: false,
touchedLetter: false,
transformed: false
};
}
const touchedLetter = isAsciiLetter(insertedText);
const transformedInserted = insertedText.replace(/[a-z]/g, (match) => match.toUpperCase());
const transformed = transformedInserted !== insertedText;
const value = transformed
? `${nextText.slice(0, prefixLength)}${transformedInserted}${nextText.slice(insertedEnd)}`
: nextText;
return {
value,
consumedOnce: normalizedMode === TOUCH_SHIFT_MODE_ONCE && touchedLetter,
touchedLetter,
transformed
};
}
module.exports = {
TOUCH_SHIFT_DOUBLE_TAP_MS,
TOUCH_SHIFT_MODE_LOCK,
TOUCH_SHIFT_MODE_OFF,
TOUCH_SHIFT_MODE_ONCE,
applyTouchShiftToValue,
isTouchShiftActive,
normalizeTouchShiftMode,
resolveTouchShiftModeOnTap
};

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
const {
TOUCH_SHIFT_MODE_LOCK,
TOUCH_SHIFT_MODE_OFF,
TOUCH_SHIFT_MODE_ONCE,
applyTouchShiftToValue,
isTouchShiftActive,
normalizeTouchShiftMode,
resolveTouchShiftModeOnTap
} = require("./touchShiftState.js");
describe("touchShiftState", () => {
it("shift 单击进入单次大写,双击进入锁定,再点一次退出", () => {
expect(resolveTouchShiftModeOnTap(TOUCH_SHIFT_MODE_OFF, 0, 1000, 320)).toBe(TOUCH_SHIFT_MODE_ONCE);
expect(resolveTouchShiftModeOnTap(TOUCH_SHIFT_MODE_ONCE, 1000, 1200, 320)).toBe(TOUCH_SHIFT_MODE_LOCK);
expect(resolveTouchShiftModeOnTap(TOUCH_SHIFT_MODE_LOCK, 0, 1500, 320)).toBe(TOUCH_SHIFT_MODE_OFF);
});
it("状态归一化和激活判断正确", () => {
expect(normalizeTouchShiftMode("once")).toBe(TOUCH_SHIFT_MODE_ONCE);
expect(normalizeTouchShiftMode("lock")).toBe(TOUCH_SHIFT_MODE_LOCK);
expect(normalizeTouchShiftMode("unknown")).toBe(TOUCH_SHIFT_MODE_OFF);
expect(isTouchShiftActive(TOUCH_SHIFT_MODE_OFF)).toBe(false);
expect(isTouchShiftActive(TOUCH_SHIFT_MODE_ONCE)).toBe(true);
expect(isTouchShiftActive(TOUCH_SHIFT_MODE_LOCK)).toBe(true);
});
it("单次大写只把下一次英文输入转成大写,并在命中字母后消费", () => {
expect(applyTouchShiftToValue("", "a", TOUCH_SHIFT_MODE_ONCE)).toEqual({
value: "A",
consumedOnce: true,
touchedLetter: true,
transformed: true
});
expect(applyTouchShiftToValue("A", "A1", TOUCH_SHIFT_MODE_ONCE)).toEqual({
value: "A1",
consumedOnce: false,
touchedLetter: false,
transformed: false
});
});
it("锁定大写会持续转换后续英文输入", () => {
expect(applyTouchShiftToValue("A", "Ab", TOUCH_SHIFT_MODE_LOCK)).toEqual({
value: "AB",
consumedOnce: false,
touchedLetter: true,
transformed: true
});
});
it("替换中间文本时只转换新增的英文片段", () => {
expect(applyTouchShiftToValue("abZ", "acZ", TOUCH_SHIFT_MODE_ONCE)).toEqual({
value: "aCZ",
consumedOnce: true,
touchedLetter: true,
transformed: true
});
});
});

View File

@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type TerminalPageOptions = {
data?: Record<string, unknown>;
[key: string]: unknown;
};
type TtsQueueItem = {
ready?: boolean;
playbackUrl?: string;
remoteAudioUrl?: string;
useRemotePlayback?: boolean;
[key: string]: unknown;
};
type TerminalPageRuntime = {
playQueue: TtsQueueItem[];
playingSegmentIndex: number;
playbackPhase: string;
[key: string]: unknown;
};
type TerminalPageInstance = TerminalPageOptions & {
data: Record<string, unknown>;
ttsRuntime: TerminalPageRuntime;
setData: (patch: Record<string, unknown>) => void;
initTtsRuntime: () => void;
createTtsPlaybackJob: (segments: string[]) => number;
playTtsQueueSegment: (jobId: number, segmentIndex: number) => Promise<boolean>;
prepareTtsQueueItem: ReturnType<typeof vi.fn>;
localizeTerminalMessage: ReturnType<typeof vi.fn>;
showLocalizedToast: ReturnType<typeof vi.fn>;
applyTtsInnerAudioOptions: ReturnType<typeof vi.fn>;
prefetchNextTtsQueueItem: ReturnType<typeof vi.fn>;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: TerminalPageOptions) => void;
wx?: Record<string, unknown>;
};
type AudioHandlerName = "canplay" | "play" | "ended" | "stop" | "error";
function createAudioContextMock() {
const handlers: Partial<Record<AudioHandlerName, (payload?: unknown) => void>> = {};
const audioContext = {
src: "",
autoplay: false,
obeyMuteSwitch: false,
onCanplay(callback: (payload?: unknown) => void) {
handlers.canplay = callback;
},
onPlay(callback: (payload?: unknown) => void) {
handlers.play = callback;
},
onEnded(callback: (payload?: unknown) => void) {
handlers.ended = callback;
},
onStop(callback: (payload?: unknown) => void) {
handlers.stop = callback;
},
onError(callback: (payload?: unknown) => void) {
handlers.error = callback;
},
play: vi.fn(),
stop: vi.fn(() => {
handlers.stop?.();
}),
destroy: vi.fn(),
emit(name: AudioHandlerName, payload?: unknown) {
handlers[name]?.(payload);
}
};
return audioContext;
}
function flushMicrotasks(): Promise<void> {
return Promise.resolve().then(() => undefined);
}
function createTerminalPageHarness() {
const globalState = globalThis as MiniprogramGlobals;
let capturedPageOptions: TerminalPageOptions | null = null;
const audioContexts: ReturnType<typeof createAudioContextMock>[] = [];
const noop = () => {};
vi.resetModules();
delete require.cache[require.resolve("./index.js")];
globalState.Page = vi.fn((options: TerminalPageOptions) => {
capturedPageOptions = options;
});
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getRecorderManager: vi.fn(() => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
})),
createInnerAudioContext: vi.fn(() => {
const audioContext = createAudioContextMock();
audioContexts.push(audioContext);
return audioContext;
}),
setInnerAudioOption: vi.fn(),
createSelectorQuery: vi.fn(() => ({
in: vi.fn(() => ({
select: vi.fn(() => ({
boundingClientRect: vi.fn(() => ({
exec: noop
}))
}))
}))
})),
nextTick: vi.fn((callback?: () => void) => {
callback?.();
}),
getSystemInfoSync: vi.fn(() => ({})),
canIUse: vi.fn(() => false)
};
require("./index.js");
if (!capturedPageOptions) {
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions as TerminalPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
setData(patch: Record<string, unknown>) {
Object.assign(this.data, patch);
}
} as TerminalPageInstance;
page.localizeTerminalMessage = vi.fn((message: string) => String(message || ""));
page.showLocalizedToast = vi.fn();
page.applyTtsInnerAudioOptions = vi.fn();
page.prefetchNextTtsQueueItem = vi.fn();
page.initTtsRuntime();
page.setData({
ttsEnabled: true,
ttsState: "idle",
ttsErrorMessage: ""
});
return {
page,
audioContexts
};
}
describe("terminal ttsPlayback", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
vi.resetModules();
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("本地缓存播放失败时应自动回退到远端音频地址", async () => {
const { page, audioContexts } = createTerminalPageHarness();
const jobId = page.createTtsPlaybackJob(["第一段"]);
const item = page.ttsRuntime.playQueue[0];
item.ready = true;
item.playbackUrl = "/tmp/tts-cache-cache-1.mp3";
item.remoteAudioUrl = "https://gateway.example.com/api/miniprogram/tts/audio/cache-1?ticket=demo";
page.prepareTtsQueueItem = vi.fn().mockResolvedValue(item);
await page.playTtsQueueSegment(jobId, 0);
const localAudioContext = audioContexts[0];
expect(localAudioContext.src).toBe("/tmp/tts-cache-cache-1.mp3");
expect(page.data.ttsState).toBe("preparing");
localAudioContext.emit("error", { errCode: 10001 });
const remoteAudioContext = audioContexts[1];
expect(item.useRemotePlayback).toBe(true);
expect(localAudioContext.stop).toHaveBeenCalledTimes(1);
expect(localAudioContext.destroy).toHaveBeenCalledTimes(1);
expect(remoteAudioContext.src).toBe(
"https://gateway.example.com/api/miniprogram/tts/audio/cache-1?ticket=demo"
);
expect(page.data.ttsState).toBe("preparing");
expect(page.showLocalizedToast).not.toHaveBeenCalled();
/**
* 旧播放器实例的迟到错误事件不应把已经切到远端地址的新实例拉回失败态。
*/
localAudioContext.emit("error", { errCode: 10001 });
expect(page.data.ttsState).toBe("preparing");
expect(page.showLocalizedToast).not.toHaveBeenCalled();
remoteAudioContext.emit("play");
expect(page.data.ttsState).toBe("playing");
expect(page.data.ttsErrorMessage).toBe("");
});
it("旧播放器实例的迟到 stop/ended 事件不应打断下一段播放", async () => {
const { page, audioContexts } = createTerminalPageHarness();
const jobId = page.createTtsPlaybackJob(["第一段", "第二段", "第三段"]);
page.ttsRuntime.playQueue.forEach((item: TtsQueueItem, index: number) => {
item.ready = true;
item.playbackUrl = `/tmp/seg-${index + 1}.mp3`;
item.remoteAudioUrl = `https://gateway.example.com/seg-${index + 1}.mp3`;
});
page.prepareTtsQueueItem = vi.fn(
async (_jobId: number, segmentIndex: number) => page.ttsRuntime.playQueue[segmentIndex]
);
await page.playTtsQueueSegment(jobId, 0);
const firstAudioContext = audioContexts[0];
firstAudioContext.emit("play");
expect(page.ttsRuntime.playingSegmentIndex).toBe(0);
expect(page.data.ttsState).toBe("playing");
firstAudioContext.emit("ended");
await flushMicrotasks();
const secondAudioContext = audioContexts[1];
expect(page.ttsRuntime.playingSegmentIndex).toBe(1);
expect(page.ttsRuntime.playbackPhase).toBe("loading");
expect(secondAudioContext.src).toBe("/tmp/seg-2.mp3");
firstAudioContext.emit("stop");
firstAudioContext.emit("ended");
await flushMicrotasks();
expect(page.data.ttsState).toBe("preparing");
expect(page.ttsRuntime.playbackPhase).toBe("loading");
expect(page.ttsRuntime.playingSegmentIndex).toBe(1);
expect(secondAudioContext.src).toBe("/tmp/seg-2.mp3");
expect(page.prepareTtsQueueItem).toHaveBeenCalledTimes(2);
secondAudioContext.emit("play");
expect(page.data.ttsState).toBe("playing");
});
});

View File

@@ -0,0 +1,61 @@
/* global module, require */
const { resolveVoicePrivacyErrorMessage } = require("./voicePrivacy");
/**
* 统一收敛语音网关错误:
* 1. 隐私权限错误优先翻译,避免误报为网关问题;
* 2. `url not in domain list` 单独归类为 socket 合法域名问题;
* 3. 其他 `connectSocket:fail` 视为网络或网关配置问题,不再误导成域名问题。
*/
function normalizeMessage(input, fallback) {
if (typeof input === "string" && input.trim()) return input.trim();
return typeof fallback === "string" ? fallback : "";
}
function resolveVoiceGatewayErrorState(input, fallback) {
const raw = normalizeMessage(input, fallback);
if (!raw) {
return { message: "", showSocketDomainModal: false };
}
const privacyMessage = resolveVoicePrivacyErrorMessage(raw, raw);
if (privacyMessage !== raw) {
return { message: privacyMessage, showSocketDomainModal: false };
}
if (/auth deny|scope\.record|authorize/i.test(raw)) {
return {
message: "麦克风权限未开启,请在设置中允许录音",
showSocketDomainModal: false
};
}
if (/url not in domain list/i.test(raw)) {
return {
message: "语音网关连接失败,请检查小程序 socket 合法域名",
showSocketDomainModal: true
};
}
if (/ready_timeout|连接超时/i.test(raw)) {
return {
message: "语音服务连接超时,请稍后重试",
showSocketDomainModal: false
};
}
if (/connectSocket:fail/i.test(raw)) {
return {
message: "语音网关连接失败,请检查网络或网关配置",
showSocketDomainModal: false
};
}
return { message: raw, showSocketDomainModal: false };
}
module.exports = {
resolveVoiceGatewayErrorState
};

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
const { resolveVoiceGatewayErrorState } = require("./voiceGatewayError.js");
describe("voiceGatewayError", () => {
it("仅在域名白名单错误时提示 socket 合法域名", () => {
expect(resolveVoiceGatewayErrorState("connectSocket:fail url not in domain list")).toEqual({
message: "语音网关连接失败,请检查小程序 socket 合法域名",
showSocketDomainModal: true
});
});
it("将其他 connectSocket 失败归类为网络或网关配置问题", () => {
expect(
resolveVoiceGatewayErrorState("语音网关连接失败: connectSocket:fail SSL handshake failed")
).toEqual({
message: "语音网关连接失败,请检查网络或网关配置",
showSocketDomainModal: false
});
});
it("保留 ready_timeout 的独立超时提示", () => {
expect(resolveVoiceGatewayErrorState("ready_timeout")).toEqual({
message: "语音服务连接超时,请稍后重试",
showSocketDomainModal: false
});
});
});

View File

@@ -0,0 +1,47 @@
/**
* 统一收敛微信隐私相关错误,避免页面层散落字符串匹配。
* 这里保持纯函数,便于 Vitest 直接覆盖,不依赖小程序运行时。
*/
function normalizeMessage(input, fallback) {
if (typeof input === "string" && input.trim()) return input.trim();
return typeof fallback === "string" ? fallback : "";
}
function isPrivacyApiBannedMessage(message) {
return /appid privacy api banned/i.test(String(message || ""));
}
function isPrivacyScopeUndeclaredMessage(message) {
return /api scope is not declared in the privacy agreement/i.test(String(message || ""));
}
function isPrivacyAuthorizationDeniedMessage(message) {
return /privacy.*(deny|denied|disagree|reject|refuse)|errno["']?\s*[:=]\s*10[34]/i.test(
String(message || "")
);
}
/**
* 将微信侧原始隐私错误翻译成可执行的中文提示。
*/
function resolveVoicePrivacyErrorMessage(input, fallback) {
const message = normalizeMessage(input, fallback);
if (!message) return "";
if (isPrivacyApiBannedMessage(message)) {
return "小程序后台未完成隐私声明,录音接口已被微信平台禁用,请在微信公众平台补充用户隐私保护指引后重新提审并发布";
}
if (isPrivacyScopeUndeclaredMessage(message)) {
return "小程序隐私指引未声明录音相关用途,请在微信公众平台“服务内容声明-用户隐私保护指引”补充麦克风采集说明";
}
if (isPrivacyAuthorizationDeniedMessage(message)) {
return "未同意隐私协议,暂时无法使用录音";
}
return message;
}
module.exports = {
isPrivacyApiBannedMessage,
isPrivacyScopeUndeclaredMessage,
resolveVoicePrivacyErrorMessage
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
const { resolveVoicePrivacyErrorMessage } = require("./voicePrivacy.js");
describe("voicePrivacy", () => {
it("将平台回收权限错误翻译为后台配置提示", () => {
expect(resolveVoicePrivacyErrorMessage("operateRecorder:fail appid privacy api banned")).toContain(
"录音接口已被微信平台禁用"
);
});
it("将未声明隐私范围错误翻译为补充指引提示", () => {
expect(
resolveVoicePrivacyErrorMessage(
"getRecorderManager:fail api scope is not declared in the privacy agreement"
)
).toContain("补充麦克风采集说明");
});
it("保留非隐私错误原文,避免误伤其他录音异常", () => {
expect(resolveVoicePrivacyErrorMessage("录音采集失败")).toBe("录音采集失败");
});
});

View File

@@ -0,0 +1,558 @@
/* global module, require */
/**
* 轻量 VT 输入处理层:
* 1. 承接“模式位切换 / 查询响应 / 软重置”这类协议状态逻辑;
* 2. 不直接持有 buffer 数据结构,只通过上下文回调读写运行态;
* 3. 先把最容易继续膨胀的协议分支从 `terminalBufferState` 中拆出,为后续继续下沉 CSI/ESC 处理铺路。
*/
const { DEFAULT_TERMINAL_MODES } = require("./terminalBufferSet.js");
function createTerminalVtInputHandler(options) {
const source = options && typeof options === "object" ? options : {};
const responses = Array.isArray(source.responses) ? source.responses : [];
const runtimeColors = source.runtimeColors && typeof source.runtimeColors === "object" ? source.runtimeColors : {};
const runtimeState = source.runtimeState && typeof source.runtimeState === "object" ? source.runtimeState : {};
const bufferCols = Math.max(1, Math.round(Number(source.bufferCols) || 0));
const bufferRows = Math.max(1, Math.round(Number(source.bufferRows) || 0));
const cloneAnsiState =
typeof source.cloneAnsiState === "function"
? source.cloneAnsiState
: (state) => ({ ...(state && typeof state === "object" ? state : {}) });
const toOscRgbString =
typeof source.toOscRgbString === "function" ? source.toOscRgbString : () => "";
const getActiveBuffer =
typeof source.getActiveBuffer === "function" ? source.getActiveBuffer : () => null;
const setCursorRow = typeof source.setCursorRow === "function" ? source.setCursorRow : () => {};
const getCursorCol = typeof source.getCursorCol === "function" ? source.getCursorCol : () => 0;
const setCursorCol = typeof source.setCursorCol === "function" ? source.setCursorCol : () => {};
const getScrollTop = typeof source.getScrollTop === "function" ? source.getScrollTop : () => 0;
const setScrollTop = typeof source.setScrollTop === "function" ? source.setScrollTop : () => {};
const getScrollBottom =
typeof source.getScrollBottom === "function" ? source.getScrollBottom : () => Math.max(0, bufferRows - 1);
const setScrollBottom =
typeof source.setScrollBottom === "function" ? source.setScrollBottom : () => {};
const setAnsiState = typeof source.setAnsiState === "function" ? source.setAnsiState : () => {};
const moveCursorUp = typeof source.moveCursorUp === "function" ? source.moveCursorUp : () => {};
const moveCursorDown = typeof source.moveCursorDown === "function" ? source.moveCursorDown : () => {};
const moveCursorRight =
typeof source.moveCursorRight === "function" ? source.moveCursorRight : () => {};
const moveCursorLeft = typeof source.moveCursorLeft === "function" ? source.moveCursorLeft : () => {};
const moveCursorNextLine =
typeof source.moveCursorNextLine === "function" ? source.moveCursorNextLine : () => {};
const moveCursorPreviousLine =
typeof source.moveCursorPreviousLine === "function" ? source.moveCursorPreviousLine : () => {};
const setCursorColumn1 =
typeof source.setCursorColumn1 === "function" ? source.setCursorColumn1 : () => {};
const setCursorRow1 = typeof source.setCursorRow1 === "function" ? source.setCursorRow1 : () => {};
const setCursorPosition1 =
typeof source.setCursorPosition1 === "function" ? source.setCursorPosition1 : () => {};
const clearDisplayByMode =
typeof source.clearDisplayByMode === "function" ? source.clearDisplayByMode : () => {};
const clearLineByMode =
typeof source.clearLineByMode === "function" ? source.clearLineByMode : () => {};
const eraseChars = typeof source.eraseChars === "function" ? source.eraseChars : () => {};
const insertChars = typeof source.insertChars === "function" ? source.insertChars : () => {};
const deleteChars = typeof source.deleteChars === "function" ? source.deleteChars : () => {};
const insertLines = typeof source.insertLines === "function" ? source.insertLines : () => {};
const deleteLines = typeof source.deleteLines === "function" ? source.deleteLines : () => {};
const scrollRegionUp =
typeof source.scrollRegionUp === "function" ? source.scrollRegionUp : () => {};
const scrollRegionDown =
typeof source.scrollRegionDown === "function" ? source.scrollRegionDown : () => {};
const setScrollRegion =
typeof source.setScrollRegion === "function" ? source.setScrollRegion : () => {};
const resetCursorForOriginMode =
typeof source.resetCursorForOriginMode === "function" ? source.resetCursorForOriginMode : null;
const indexDown = typeof source.indexDown === "function" ? source.indexDown : () => {};
const nextLine = typeof source.nextLine === "function" ? source.nextLine : () => {};
const reverseIndex =
typeof source.reverseIndex === "function" ? source.reverseIndex : () => {};
const getScreenCursorRow =
typeof source.getScreenCursorRow === "function" ? source.getScreenCursorRow : () => 0;
const saveCurrentCursor =
typeof source.saveCurrentCursor === "function" ? source.saveCurrentCursor : () => {};
const restoreCurrentCursor =
typeof source.restoreCurrentCursor === "function" ? source.restoreCurrentCursor : () => {};
const switchActiveBuffer =
typeof source.switchActiveBuffer === "function" ? source.switchActiveBuffer : () => {};
const ansiResetState =
source.ansiResetState && typeof source.ansiResetState === "object" ? source.ansiResetState : {};
function pushDeviceStatusResponse(privateMarker, code) {
if (code === 5) {
responses.push(`${privateMarker === "?" ? "\u001b[?" : "\u001b["}0n`);
return;
}
if (code === 6) {
const row = getScreenCursorRow() + 1;
const col = Math.min(bufferCols, getCursorCol()) + 1;
responses.push(privateMarker === "?" ? `\u001b[?${row};${col}R` : `\u001b[${row};${col}R`);
}
}
/**
* 设备属性查询只回报当前实现真实支持的“最小可用口径”,
* 避免把不存在的终端特性伪装成已支持。
*/
function pushDeviceAttributesResponse(privateMarker, code) {
if (code > 0) {
return;
}
if (privateMarker === ">") {
responses.push("\u001b[>0;276;0c");
return;
}
responses.push("\u001b[?1;2c");
}
function pushOscColorReport(ident) {
let color = "";
if (ident === "10") {
color = runtimeColors.defaultForeground;
} else if (ident === "11") {
color = runtimeColors.defaultBackground;
} else if (ident === "12") {
color = runtimeColors.defaultCursor;
}
const rgb = toOscRgbString(color);
if (!rgb) {
return;
}
responses.push(`\u001b]${ident};${rgb}\u001b\\`);
}
/**
* OSC 10/11/12 允许把多个查询串在一条指令里,这里只对当前已实现的颜色槽位做查询响应。
*/
function handleOscSequence(ident, data) {
const base = Number(ident);
if (![10, 11, 12].includes(base)) {
return;
}
const slots = String(data || "").split(";");
for (let offset = 0; offset < slots.length; offset += 1) {
const slotIdent = String(base + offset);
if (!["10", "11", "12"].includes(slotIdent)) {
break;
}
if (slots[offset] === "?") {
pushOscColorReport(slotIdent);
}
}
}
function pushModeStatusResponse(privateMarker, mode, status) {
const prefix = privateMarker === "?" ? "?" : "";
responses.push(`\u001b[${prefix}${mode};${status}$y`);
}
/**
* 这里只回报当前运行态里真实维护的模式位。
* 未实现模式统一返回 0避免协议层把“未知能力”冒充成“已支持”。
*/
function resolveModeStatus(privateMarker, value) {
const mode = Math.round(Number(value) || 0);
if (!mode) return 0;
if (privateMarker !== "?") {
if (mode === 4) {
return runtimeState.modes.insertMode ? 1 : 2;
}
return 0;
}
if (mode === 1) return runtimeState.modes.applicationCursorKeys ? 1 : 2;
if (mode === 6) return runtimeState.modes.originMode ? 1 : 2;
if (mode === 7) return runtimeState.modes.wraparound ? 1 : 2;
if (mode === 25) return runtimeState.modes.cursorHidden ? 2 : 1;
if (mode === 45) return runtimeState.modes.reverseWraparound ? 1 : 2;
if (mode === 66) return runtimeState.modes.applicationKeypad ? 1 : 2;
if (mode === 47 || mode === 1047 || mode === 1049) {
const activeBuffer = getActiveBuffer();
return activeBuffer && activeBuffer.isAlt ? 1 : 2;
}
if (mode === 1048) return 1;
if (mode === 1004) return runtimeState.modes.sendFocus ? 1 : 2;
if (mode === 2004) return runtimeState.modes.bracketedPasteMode ? 1 : 2;
return 0;
}
function pushModeReport(privateMarker, value) {
const mode = Math.round(Number(value) || 0);
if (!mode) return;
pushModeStatusResponse(privateMarker, mode, resolveModeStatus(privateMarker, mode));
}
function pushStatusStringResponse(payload) {
const value = String(payload || "");
if (value === "m") {
responses.push("\u001bP1$r0m\u001b\\");
return;
}
if (value === "r") {
responses.push(`\u001bP1$r${getScrollTop() + 1};${getScrollBottom() + 1}r\u001b\\`);
return;
}
if (value === " q") {
responses.push("\u001bP1$r2 q\u001b\\");
return;
}
if (value === '"q') {
responses.push('\u001bP1$r0"q\u001b\\');
return;
}
if (value === '"p') {
responses.push('\u001bP1$r61;1"p\u001b\\');
return;
}
responses.push("\u001bP0$r\u001b\\");
}
/**
* DECSTR 只做软重置,不清屏、不搬动当前 cursor。
* 这部分逻辑必须保持和现有主链路一致,避免复杂 TUI 在软重置后发生可见跳变。
*/
function softResetTerminal() {
runtimeState.modes = { ...DEFAULT_TERMINAL_MODES };
setAnsiState(cloneAnsiState(ansiResetState));
setScrollTop(0);
setScrollBottom(Math.max(0, bufferRows - 1));
const activeBuffer = getActiveBuffer();
if (activeBuffer) {
activeBuffer.savedCursorRow = 0;
activeBuffer.savedCursorCol = 0;
activeBuffer.savedAnsiState = cloneAnsiState(ansiResetState);
}
}
function handlePrivateMode(enabled, value) {
const mode = Math.round(Number(value) || 0);
if (!mode) return;
if (mode === 1) {
runtimeState.modes.applicationCursorKeys = enabled;
return;
}
if (mode === 6) {
runtimeState.modes.originMode = enabled;
if (resetCursorForOriginMode) {
resetCursorForOriginMode(enabled);
return;
}
const activeBuffer = getActiveBuffer();
setCursorRow(enabled && activeBuffer && activeBuffer.isAlt ? getScrollTop() : 0);
setCursorCol(0);
return;
}
if (mode === 45) {
runtimeState.modes.reverseWraparound = enabled;
return;
}
if (mode === 66) {
runtimeState.modes.applicationKeypad = enabled;
return;
}
if (mode === 7) {
runtimeState.modes.wraparound = enabled;
return;
}
if (mode === 25) {
runtimeState.modes.cursorHidden = !enabled;
return;
}
if (mode === 47 || mode === 1047) {
switchActiveBuffer(enabled ? "alt" : "normal", enabled);
return;
}
if (mode === 1048) {
if (enabled) {
saveCurrentCursor();
} else {
restoreCurrentCursor();
}
return;
}
if (mode === 1049) {
if (enabled) {
saveCurrentCursor();
switchActiveBuffer("alt", true);
} else {
switchActiveBuffer("normal", false);
restoreCurrentCursor();
}
return;
}
if (mode === 2004) {
runtimeState.modes.bracketedPasteMode = enabled;
return;
}
if (mode === 1004) {
runtimeState.modes.sendFocus = enabled;
}
}
function handleAnsiMode(enabled, value) {
const mode = Math.round(Number(value) || 0);
if (!mode) return;
if (mode === 4) {
runtimeState.modes.insertMode = enabled;
}
}
function resolveCsiNumber(values, index, fallback) {
const value = values && Number(values[index]);
if (!Number.isFinite(value)) return fallback;
const normalized = Math.round(value);
if (normalized < 0) return 0;
return normalized;
}
/**
* 这里只下沉“CSI -> 光标动作”的协议解释:
* 1. handler 负责默认参数、1-based 行列语义和 final byte 分派;
* 2. bufferState 仍负责真实行列边界、history/alt buffer 与 origin mode 的具体落点。
*/
function handleCursorControl(final, values) {
const code = String(final || "");
if (code === "A") {
moveCursorUp(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "B") {
moveCursorDown(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "C") {
moveCursorRight(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "D") {
moveCursorLeft(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "E") {
moveCursorNextLine(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "F") {
moveCursorPreviousLine(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "G") {
setCursorColumn1(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "d") {
setCursorRow1(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "H" || code === "f") {
setCursorPosition1(
Math.max(1, resolveCsiNumber(values, 0, 1)),
Math.max(1, resolveCsiNumber(values, 1, 1))
);
return true;
}
return false;
}
/**
* 擦除类 CSI 只在这里解释参数语义:
* 1. `J / K` 走“显示/行清除模式”;
* 2. `X` 走“从当前光标起擦除 N 列”,默认值保持 1。
*/
function handleEraseControl(final, values) {
const code = String(final || "");
if (code === "J") {
clearDisplayByMode(resolveCsiNumber(values, 0, 0));
return true;
}
if (code === "K") {
clearLineByMode(resolveCsiNumber(values, 0, 0));
return true;
}
if (code === "X") {
eraseChars(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
return false;
}
/**
* 编辑/滚动类 CSI 仍然只解释“协议参数 -> buffer 动作”的映射,
* 真正的 cell 变更、滚动区约束和 viewport/history 处理继续留在 bufferState。
*/
function handleEditControl(final, values) {
const code = String(final || "");
if (code === "@") {
insertChars(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "P") {
deleteChars(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "L") {
insertLines(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "M") {
deleteLines(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "S") {
scrollRegionUp(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "T") {
scrollRegionDown(Math.max(1, resolveCsiNumber(values, 0, 1)));
return true;
}
if (code === "r") {
const top = Math.max(1, resolveCsiNumber(values, 0, 1));
const rawBottom = values && values.length > 1 ? resolveCsiNumber(values, 1, bufferRows) : bufferRows;
const bottom = rawBottom > 0 ? rawBottom : bufferRows;
setScrollRegion(top, bottom);
return true;
}
return false;
}
/**
* 保存/恢复光标这类协议动作目前同时存在于 CSI 与 ESC 两条入口:
* 1. `CSI s / u`
* 2. `ESC 7 / 8`
* 这里统一只做协议语义分派,真实保存内容仍由 bufferState 维护。
*/
function handleCursorSaveRestoreControl(final) {
const code = String(final || "");
if (code === "s" || code === "7") {
saveCurrentCursor();
return true;
}
if (code === "u" || code === "8") {
restoreCurrentCursor();
return true;
}
return false;
}
/**
* 查询类 CSI 统一在这里做协议分派,减少主循环里零散的条件判断:
* 1. `DECRQM``CSI Ps $ p` / `CSI ? Ps $ p`
* 2. `DSR / CPR``CSI n` / `CSI ? n`
* 3. `DA1 / DA2``CSI c` / `CSI > c`
*/
function handleQueryControl(privateMarker, intermediates, final, values) {
const marker = String(privateMarker || "");
const middle = String(intermediates || "");
const code = String(final || "");
if (middle === "$" && code === "p" && (marker === "" || marker === "?")) {
(Array.isArray(values) && values.length > 0 ? values : [0]).forEach((mode) => {
pushModeReport(marker, mode);
});
return true;
}
if (code === "n" && (marker === "" || marker === "?")) {
pushDeviceStatusResponse(marker, resolveCsiNumber(values, 0, 0));
return true;
}
if (code === "c" && (marker === "" || marker === ">")) {
pushDeviceAttributesResponse(marker, resolveCsiNumber(values, 0, 0));
return true;
}
return false;
}
/**
* ESC 入口目前只保留真正的 ESC 协议语义分派:
* 1. `ESC 7 / 8` 保存恢复光标
* 2. `ESC D / E / M` index / next line / reverse index
*/
function handleEscControl(final) {
const code = String(final || "");
if (handleCursorSaveRestoreControl(code)) {
return true;
}
if (code === "D") {
indexDown();
return true;
}
if (code === "E") {
nextLine();
return true;
}
if (code === "M") {
reverseIndex();
return true;
}
return false;
}
/**
* `CSI` 顶层分派只负责“协议入口 -> 已有子处理器”的路由:
* 1. 查询类、模式切换、软重置优先处理,保持和现有协议优先级一致;
* 2. 普通 ANSI 模式与无私有前缀的光标/擦除/编辑动作继续复用已有细分处理器;
* 3. `SGR` 仍留在 bufferState因为它直接作用于当前 ansiState 运行态。
*/
function handleCsiControl(privateMarker, intermediates, final, values) {
const marker = String(privateMarker || "");
const code = String(final || "");
if (handleQueryControl(marker, intermediates, code, values)) {
return true;
}
if (marker === "?") {
if (code === "h" || code === "l") {
const enabled = code === "h";
(Array.isArray(values) && values.length > 0 ? values : [0]).forEach((mode) =>
handlePrivateMode(enabled, mode)
);
return true;
}
return false;
}
if (marker === "!" && code === "p") {
softResetTerminal();
return true;
}
if (!marker && (code === "h" || code === "l")) {
values.forEach((value) => {
handleAnsiMode(code === "h", value);
});
return true;
}
if (marker) {
return false;
}
return (
handleCursorControl(code, values) ||
handleEraseControl(code, values) ||
handleEditControl(code, values) ||
handleCursorSaveRestoreControl(code)
);
}
return {
handleCsiControl,
handleCursorControl,
handleEraseControl,
handleEditControl,
handleCursorSaveRestoreControl,
handleQueryControl,
handleEscControl,
handleAnsiMode,
handleOscSequence,
handlePrivateMode,
pushDeviceAttributesResponse,
pushDeviceStatusResponse,
pushModeReport,
pushStatusStringResponse,
softResetTerminal
};
}
module.exports = {
createTerminalVtInputHandler
};

View File

@@ -0,0 +1,436 @@
import { describe, expect, it } from "vitest";
const { DEFAULT_TERMINAL_MODES } = require("./terminalBufferSet.js");
const { createTerminalVtInputHandler } = require("./vtInputHandler.js");
describe("vtInputHandler", () => {
it("OSC 10/11/12 查询会把响应写回队列", () => {
const { handler, responses } = createHandler();
handler.handleOscSequence(10, "?;?;?");
expect(responses).toEqual([
"\u001b]10;rgb:#112233\u001b\\",
"\u001b]11;rgb:#445566\u001b\\",
"\u001b]12;rgb:#778899\u001b\\"
]);
});
it("模式报告只回报当前真实维护的模式位", () => {
const { handler, responses, runtimeState, setActiveBufferAlt } = createHandler();
runtimeState.modes.applicationCursorKeys = true;
runtimeState.modes.sendFocus = true;
runtimeState.modes.insertMode = true;
setActiveBufferAlt(true);
handler.pushModeReport("?", 1);
handler.pushModeReport("?", 1049);
handler.pushModeReport("?", 1004);
handler.pushModeReport("", 4);
handler.pushModeReport("?", 9999);
expect(responses).toEqual([
"\u001b[?1;1$y",
"\u001b[?1049;1$y",
"\u001b[?1004;1$y",
"\u001b[4;1$y",
"\u001b[?9999;0$y"
]);
});
it("1049 私有模式切换会先保存光标,再切屏并在退出时恢复", () => {
const { handler, switchCalls, saveCalls, restoreCalls } = createHandler();
handler.handlePrivateMode(true, 1049);
handler.handlePrivateMode(false, 1049);
expect(saveCalls.count).toBe(1);
expect(restoreCalls.count).toBe(1);
expect(switchCalls).toEqual([
{ target: "alt", clearTarget: true },
{ target: "normal", clearTarget: false }
]);
});
it("origin mode 私有模式切换会更新模式位,并委托运行态重置光标", () => {
const { handler, runtimeState, originModeResets } = createHandler();
handler.handlePrivateMode(true, 6);
handler.handlePrivateMode(false, 6);
expect(runtimeState.modes.originMode).toBe(false);
expect(originModeResets).toEqual([true, false]);
});
it("DECSTR 软重置会收回模式位与保存光标,但不会改动当前 cursor", () => {
const { handler, runtimeState, state, activeBuffer, defaults } = createHandler();
state.cursorRow = 7;
state.cursorCol = 9;
state.scrollTop = 3;
state.scrollBottom = 8;
state.ansiState = { fg: "#abcdef", bg: "#123456", bold: true, underline: true };
activeBuffer.savedCursorRow = 5;
activeBuffer.savedCursorCol = 6;
activeBuffer.savedAnsiState = { fg: "#ffffff", bg: "#000000", bold: true, underline: false };
runtimeState.modes = {
...DEFAULT_TERMINAL_MODES,
applicationCursorKeys: true,
sendFocus: true,
insertMode: true,
cursorHidden: true
};
handler.softResetTerminal();
expect(runtimeState.modes).toEqual(defaults);
expect(state.cursorRow).toBe(7);
expect(state.cursorCol).toBe(9);
expect(state.scrollTop).toBe(0);
expect(state.scrollBottom).toBe(23);
expect(state.ansiState).toEqual({ fg: "", bg: "", bold: false, underline: false });
expect(activeBuffer.savedCursorRow).toBe(0);
expect(activeBuffer.savedCursorCol).toBe(0);
expect(activeBuffer.savedAnsiState).toEqual({ fg: "", bg: "", bold: false, underline: false });
});
it("光标移动类 CSI 会按默认参数分派到对应动作", () => {
const { handler, cursorOps } = createHandler();
expect(handler.handleCursorControl("A", [])).toBe(true);
expect(handler.handleCursorControl("B", [2])).toBe(true);
expect(handler.handleCursorControl("C", [0])).toBe(true);
expect(handler.handleCursorControl("D", [-3])).toBe(true);
expect(handler.handleCursorControl("E", [])).toBe(true);
expect(handler.handleCursorControl("F", [4])).toBe(true);
expect(cursorOps).toEqual([
{ type: "up", value: 1 },
{ type: "down", value: 2 },
{ type: "right", value: 1 },
{ type: "left", value: 1 },
{ type: "nextLine", value: 1 },
{ type: "previousLine", value: 4 }
]);
});
it("HPA/VPA/CUP 会保留 VT 的 1-based 语义交给运行态落点", () => {
const { handler, cursorOps } = createHandler();
expect(handler.handleCursorControl("G", [5])).toBe(true);
expect(handler.handleCursorControl("d", [])).toBe(true);
expect(handler.handleCursorControl("H", [3, 7])).toBe(true);
expect(handler.handleCursorControl("f", [0, -2])).toBe(true);
expect(handler.handleCursorControl("J", [2])).toBe(false);
expect(cursorOps).toEqual([
{ type: "column1", value: 5 },
{ type: "row1", value: 1 },
{ type: "position1", row: 3, column: 7 },
{ type: "position1", row: 1, column: 1 }
]);
});
it("擦除类 CSI 会按各自默认参数分派到对应擦除动作", () => {
const { handler, eraseOps } = createHandler();
expect(handler.handleEraseControl("J", [])).toBe(true);
expect(handler.handleEraseControl("K", [2])).toBe(true);
expect(handler.handleEraseControl("X", [0])).toBe(true);
expect(handler.handleEraseControl("X", [-3])).toBe(true);
expect(handler.handleEraseControl("P", [1])).toBe(false);
expect(eraseOps).toEqual([
{ type: "display", value: 0 },
{ type: "line", value: 2 },
{ type: "chars", value: 1 },
{ type: "chars", value: 1 }
]);
});
it("编辑与滚动类 CSI 会按默认参数分派到对应 buffer 动作", () => {
const { handler, editOps } = createHandler();
expect(handler.handleEditControl("@", [])).toBe(true);
expect(handler.handleEditControl("P", [2])).toBe(true);
expect(handler.handleEditControl("L", [0])).toBe(true);
expect(handler.handleEditControl("M", [-3])).toBe(true);
expect(handler.handleEditControl("S", [])).toBe(true);
expect(handler.handleEditControl("T", [4])).toBe(true);
expect(handler.handleEditControl("r", [2])).toBe(true);
expect(handler.handleEditControl("r", [3, 0])).toBe(true);
expect(handler.handleEditControl("u", [])).toBe(false);
expect(editOps).toEqual([
{ type: "insertChars", value: 1 },
{ type: "deleteChars", value: 2 },
{ type: "insertLines", value: 1 },
{ type: "deleteLines", value: 1 },
{ type: "scrollUp", value: 1 },
{ type: "scrollDown", value: 4 },
{ type: "scrollRegion", top: 2, bottom: 24 },
{ type: "scrollRegion", top: 3, bottom: 24 }
]);
});
it("CSI s/u 与 ESC 7/8 会统一分派保存与恢复光标动作", () => {
const { handler, saveCalls, restoreCalls } = createHandler();
expect(handler.handleCursorSaveRestoreControl("s")).toBe(true);
expect(handler.handleCursorSaveRestoreControl("u")).toBe(true);
expect(handler.handleCursorSaveRestoreControl("7")).toBe(true);
expect(handler.handleCursorSaveRestoreControl("8")).toBe(true);
expect(handler.handleCursorSaveRestoreControl("D")).toBe(false);
expect(saveCalls.count).toBe(2);
expect(restoreCalls.count).toBe(2);
});
it("查询类 CSI 会统一分派 mode report、DSR 和 DA", () => {
const { handler, responses, runtimeState, setActiveBufferAlt } = createHandler();
runtimeState.modes.sendFocus = true;
setActiveBufferAlt(true);
expect(handler.handleQueryControl("?", "$", "p", [1049])).toBe(true);
expect(handler.handleQueryControl("?", "", "n", [6])).toBe(true);
expect(handler.handleQueryControl("", "", "c", [0])).toBe(true);
expect(handler.handleQueryControl(">", "", "c", [0])).toBe(true);
expect(handler.handleQueryControl("!", "", "p", [0])).toBe(false);
expect(responses).toEqual([
"\u001b[?1049;1$y",
"\u001b[?2;3R",
"\u001b[?1;2c",
"\u001b[>0;276;0c"
]);
});
it("ESC D/E/M 与 ESC 7/8 会统一走 ESC 分派入口", () => {
const { handler, saveCalls, restoreCalls, escOps } = createHandler();
expect(handler.handleEscControl("7")).toBe(true);
expect(handler.handleEscControl("8")).toBe(true);
expect(handler.handleEscControl("D")).toBe(true);
expect(handler.handleEscControl("E")).toBe(true);
expect(handler.handleEscControl("M")).toBe(true);
expect(handler.handleEscControl("]")).toBe(false);
expect(saveCalls.count).toBe(1);
expect(restoreCalls.count).toBe(1);
expect(escOps).toEqual(["D", "E", "M"]);
});
it("CSI 顶层分派会统一路由查询、模式切换和普通控制动作", () => {
const { handler, responses, runtimeState, cursorOps, eraseOps, editOps, saveCalls, defaults } = createHandler();
runtimeState.modes.applicationCursorKeys = true;
runtimeState.modes.insertMode = true;
expect(handler.handleCsiControl("!", "", "p", [0])).toBe(true);
expect(runtimeState.modes).toEqual(defaults);
expect(handler.handleCsiControl("?", "", "h", [1])).toBe(true);
expect(runtimeState.modes.applicationCursorKeys).toBe(true);
expect(handler.handleCsiControl("", "", "h", [4])).toBe(true);
expect(runtimeState.modes.insertMode).toBe(true);
expect(handler.handleCsiControl("?", "$", "p", [1])).toBe(true);
expect(handler.handleCsiControl("", "", "B", [2])).toBe(true);
expect(handler.handleCsiControl("", "", "K", [])).toBe(true);
expect(handler.handleCsiControl("", "", "@", [3])).toBe(true);
expect(handler.handleCsiControl("", "", "s", [])).toBe(true);
expect(handler.handleCsiControl(">", "", "u", [1])).toBe(false);
expect(responses).toEqual(["\u001b[?1;1$y"]);
expect(cursorOps).toEqual([{ type: "down", value: 2 }]);
expect(eraseOps).toEqual([{ type: "line", value: 0 }]);
expect(editOps).toEqual([{ type: "insertChars", value: 3 }]);
expect(saveCalls.count).toBe(1);
});
});
function createHandler() {
const defaults = { ...DEFAULT_TERMINAL_MODES };
const responses: string[] = [];
const runtimeState = { modes: { ...defaults } };
const normalBuffer = {
isAlt: false,
savedCursorRow: 0,
savedCursorCol: 0,
savedAnsiState: { fg: "", bg: "", bold: false, underline: false }
};
const altBuffer = {
isAlt: true,
savedCursorRow: 0,
savedCursorCol: 0,
savedAnsiState: { fg: "", bg: "", bold: false, underline: false }
};
const state = {
cursorRow: 1,
cursorCol: 2,
scrollTop: 0,
scrollBottom: 23,
ansiState: { fg: "", bg: "", bold: false, underline: false }
};
let activeBuffer = normalBuffer;
const switchCalls: Array<{ target: string; clearTarget: boolean }> = [];
const saveCalls = { count: 0 };
const restoreCalls = { count: 0 };
const cursorOps: Array<
| { type: "up" | "down" | "right" | "left" | "nextLine" | "previousLine"; value: number }
| { type: "column1" | "row1"; value: number }
| { type: "position1"; row: number; column: number }
> = [];
const eraseOps: Array<
| { type: "display" | "line"; value: number }
| { type: "chars"; value: number }
> = [];
const editOps: Array<
| { type: "insertChars" | "deleteChars" | "insertLines" | "deleteLines" | "scrollUp" | "scrollDown"; value: number }
| { type: "scrollRegion"; top: number; bottom: number }
> = [];
const escOps: string[] = [];
const originModeResets: boolean[] = [];
const handler = createTerminalVtInputHandler({
ansiResetState: { fg: "", bg: "", bold: false, underline: false },
bufferCols: 80,
bufferRows: 24,
cloneAnsiState: (value: { fg?: string; bg?: string; bold?: boolean; underline?: boolean } | null) => ({
fg: value && value.fg ? String(value.fg) : "",
bg: value && value.bg ? String(value.bg) : "",
bold: !!(value && value.bold),
underline: !!(value && value.underline)
}),
getActiveBuffer: () => activeBuffer,
getCursorCol: () => state.cursorCol,
getScreenCursorRow: () => state.cursorRow,
getScrollBottom: () => state.scrollBottom,
getScrollTop: () => state.scrollTop,
responses,
restoreCurrentCursor: () => {
restoreCalls.count += 1;
},
runtimeColors: {
defaultForeground: "#112233",
defaultBackground: "#445566",
defaultCursor: "#778899"
},
runtimeState,
saveCurrentCursor: () => {
saveCalls.count += 1;
},
moveCursorUp: (value: number) => {
cursorOps.push({ type: "up", value });
},
moveCursorDown: (value: number) => {
cursorOps.push({ type: "down", value });
},
moveCursorRight: (value: number) => {
cursorOps.push({ type: "right", value });
},
moveCursorLeft: (value: number) => {
cursorOps.push({ type: "left", value });
},
moveCursorNextLine: (value: number) => {
cursorOps.push({ type: "nextLine", value });
},
moveCursorPreviousLine: (value: number) => {
cursorOps.push({ type: "previousLine", value });
},
setCursorColumn1: (value: number) => {
cursorOps.push({ type: "column1", value });
},
setCursorRow1: (value: number) => {
cursorOps.push({ type: "row1", value });
},
setCursorPosition1: (row: number, column: number) => {
cursorOps.push({ type: "position1", row, column });
},
clearDisplayByMode: (value: number) => {
eraseOps.push({ type: "display", value });
},
clearLineByMode: (value: number) => {
eraseOps.push({ type: "line", value });
},
eraseChars: (value: number) => {
eraseOps.push({ type: "chars", value });
},
insertChars: (value: number) => {
editOps.push({ type: "insertChars", value });
},
deleteChars: (value: number) => {
editOps.push({ type: "deleteChars", value });
},
insertLines: (value: number) => {
editOps.push({ type: "insertLines", value });
},
deleteLines: (value: number) => {
editOps.push({ type: "deleteLines", value });
},
scrollRegionUp: (value: number) => {
editOps.push({ type: "scrollUp", value });
},
scrollRegionDown: (value: number) => {
editOps.push({ type: "scrollDown", value });
},
setScrollRegion: (top: number, bottom: number) => {
editOps.push({ type: "scrollRegion", top, bottom });
},
resetCursorForOriginMode: (enabled: boolean) => {
originModeResets.push(enabled);
},
indexDown: () => {
escOps.push("D");
},
nextLine: () => {
escOps.push("E");
},
reverseIndex: () => {
escOps.push("M");
},
setAnsiState: (value: { fg: string; bg: string; bold: boolean; underline: boolean }) => {
state.ansiState = { ...value };
},
setCursorCol: (value: number) => {
state.cursorCol = value;
},
setCursorRow: (value: number) => {
state.cursorRow = value;
},
setScrollBottom: (value: number) => {
state.scrollBottom = value;
},
setScrollTop: (value: number) => {
state.scrollTop = value;
},
switchActiveBuffer: (target: string, clearTarget: boolean) => {
switchCalls.push({ target, clearTarget });
activeBuffer = target === "alt" ? altBuffer : normalBuffer;
},
toOscRgbString: (value: string) => `rgb:${value}`
});
return {
activeBuffer,
defaults,
handler,
responses,
restoreCalls,
runtimeState,
saveCalls,
originModeResets,
cursorOps,
eraseOps,
editOps,
escOps,
setActiveBufferAlt(value: boolean) {
activeBuffer = value ? altBuffer : normalBuffer;
},
state,
switchCalls
};
}

View File

@@ -0,0 +1,530 @@
/* global module */
/**
* 轻量 VT 解析层:
* 1. 只负责把原始字节流样式文本切成 `CSI / OSC / DCS / ESC / 文本`
* 2. 不直接修改 buffer也不参与页面几何/渲染;
* 3. 当前目标是先把 Codex 已经用到的 prefix / intermediates / OSC / DCS 收口到统一入口,
* 避免继续在 `terminalBufferState` 里散落正则补丁。
*/
const ESC_CHAR = "\u001b";
function shouldStripTerminalControlChar(codePoint) {
return (
(codePoint >= 0x00 && codePoint <= 0x06) ||
codePoint === 0x0b ||
codePoint === 0x0c ||
(codePoint >= 0x0e && codePoint <= 0x1a) ||
(codePoint >= 0x1c && codePoint <= 0x1f) ||
codePoint === 0x7f
);
}
/**
* 微信小程序的 eslint 开了 `no-control-regex`,因此这里不用控制字符正则,
* 改为显式扫描 `ESC ( X` / `ESC ) X` 这种 charset designator。
*/
function stripCharsetDesignators(text) {
let result = "";
let index = 0;
while (index < text.length) {
const current = text[index];
const marker = text[index + 1];
const final = text[index + 2];
if (
current === ESC_CHAR &&
(marker === "(" || marker === ")") &&
final &&
/[0-9A-Za-z]/.test(final)
) {
index += 3;
continue;
}
result += current;
index += 1;
}
return result;
}
/**
* replay 文本里会混入一批不参与终端渲染的控制字符。
* 这里逐字符过滤,既能避开 lint 规则,也更容易精确保留其余可见文本。
*/
function stripDisallowedControlChars(text) {
let result = "";
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index);
if (!Number.isFinite(codePoint)) {
continue;
}
const ch = String.fromCodePoint(codePoint);
if (!shouldStripTerminalControlChar(codePoint)) {
result += ch;
}
if (ch.length === 2) {
index += 1;
}
}
return result;
}
function normalizeTerminalReplayText(input) {
const raw = String(input || "");
if (!raw) return "";
return stripDisallowedControlChars(
stripCharsetDesignators(raw)
.replace(/\??[0-9;]*[mKJHfABCDsuhl]/g, "")
.replace(/\r\n/g, "\n")
);
}
function createTerminalSyncUpdateState() {
return {
depth: 0,
carryText: "",
bufferedText: ""
};
}
function isTerminalSyncUpdateCsi(privateMarker, final, values) {
if (String(privateMarker || "") !== "?") return false;
if (!["h", "l"].includes(String(final || ""))) return false;
return Math.round(Number(values && values[0]) || 0) === 2026;
}
/**
* web 端已经显式清洗 `DCS = 1 s / = 2 s`。
* 小程序这里保持同口径,把它们也视为同步刷新窗口边界。
*/
function resolveTerminalSyncUpdateDcsAction(header, final, data) {
if (String(final || "") !== "s") return "";
if (String(data || "")) return "";
const parsed = parseDcsHeader(header);
if (parsed.privateMarker !== "=") return "";
const mode = Math.round(Number(parsed.values && parsed.values[0]) || 0);
if (mode === 1) return "start";
if (mode === 2) return "end";
return "";
}
/**
* 将 Codex 这类 TUI 的“同步刷新窗口”从原始 stdout 中收口出来:
* 1. 窗口外文本立即可见;
* 2. 窗口内文本暂存,等结束标记到达后再一次性交给上层渲染;
* 3. 若控制序列在 chunk 边界被截断,则把尾巴 carry 到下一帧继续拼。
*
* 这里的目标不是完整实现协议,而是避免把一整批重绘中间态逐帧暴露给用户。
*/
function consumeTerminalSyncUpdateFrames(input, previousState) {
const source =
previousState && typeof previousState === "object"
? previousState
: createTerminalSyncUpdateState();
const text = `${String(source.carryText || "")}${String(input || "")}`;
let depth = Math.max(0, Math.round(Number(source.depth) || 0));
let currentText = depth > 0 ? String(source.bufferedText || "") : "";
let readyText = "";
let carryText = "";
let index = 0;
const flushCurrentText = () => {
if (!currentText) {
return;
}
readyText += currentText;
currentText = "";
};
while (index < text.length) {
if (text[index] === "\u001b") {
const next = text[index + 1];
if (next === "[") {
const csi = extractAnsiCsi(text, index);
if (!csi) {
carryText = text.slice(index);
break;
}
const parsed = parseCsiParams(csi.paramsRaw);
if (isTerminalSyncUpdateCsi(parsed.privateMarker, csi.final, parsed.values)) {
if (csi.final === "h") {
if (depth === 0) {
flushCurrentText();
}
depth += 1;
} else if (depth > 0) {
depth -= 1;
if (depth === 0) {
flushCurrentText();
}
}
index = csi.end + 1;
continue;
}
currentText += text.slice(index, csi.end + 1);
index = csi.end + 1;
continue;
}
if (next === "]") {
const osc = extractOscSequence(text, index);
if (!osc) {
carryText = text.slice(index);
break;
}
currentText += text.slice(index, osc.end + 1);
index = osc.end + 1;
continue;
}
if (next === "P") {
const dcs = extractDcsSequence(text, index);
if (!dcs) {
carryText = text.slice(index);
break;
}
const action = resolveTerminalSyncUpdateDcsAction(dcs.header, dcs.final, dcs.data);
if (action === "start") {
if (depth === 0) {
flushCurrentText();
}
depth += 1;
index = dcs.end + 1;
continue;
}
if (action === "end") {
if (depth > 0) {
depth -= 1;
if (depth === 0) {
flushCurrentText();
}
}
index = dcs.end + 1;
continue;
}
currentText += text.slice(index, dcs.end + 1);
index = dcs.end + 1;
continue;
}
if (!next) {
carryText = text.slice(index);
break;
}
currentText += text.slice(index, index + 2);
index += 2;
continue;
}
const codePoint = text.codePointAt(index);
if (!Number.isFinite(codePoint)) {
break;
}
const ch = String.fromCodePoint(codePoint);
currentText += ch;
index += ch.length;
}
let bufferedText = "";
if (depth > 0) {
bufferedText = currentText;
} else {
flushCurrentText();
}
return {
text: readyText,
state: {
depth,
carryText,
bufferedText
}
};
}
/**
* 将一段原始终端输出切成“可安全独立解析”的前缀:
* 1. 不在 CSI / OSC / DCS / 两字符 ESC 序列中间截断;
* 2. 不把 `\r\n` 从中间拆开,避免分片后被归一化成双重换行;
* 3. 默认按 code point 推进,避免把代理对字符从中间截断。
*
* 说明:
* - 如果上限恰好落在控制序列中间,且前面已经存在安全边界,则返回此前缀;
* - 如果文本开头就是一个完整但较长的控制序列,则允许这一整个序列越过上限,保证最小前进。
* - 如果文本前缀本身是不完整控制序列,则返回空 slice由调用方把这段尾巴缓存到下一轮。
*/
function takeTerminalReplaySlice(input, maxChars) {
const text = String(input || "");
if (!text) {
return { slice: "", rest: "" };
}
const limit = Math.max(1, Math.round(Number(maxChars) || 0));
let index = 0;
let safeEnd = 0;
while (index < text.length && index < limit) {
if (text[index] === "\r" && text[index + 1] === "\n") {
const nextIndex = index + 2;
if (nextIndex > limit && safeEnd > 0) {
break;
}
safeEnd = nextIndex;
index = nextIndex;
continue;
}
if (text[index] === "\u001b") {
const next = text[index + 1];
let nextIndex = 0;
if (next === "[") {
const csi = extractAnsiCsi(text, index);
if (!csi) break;
nextIndex = csi.end + 1;
} else if (next === "]") {
const osc = extractOscSequence(text, index);
if (!osc) break;
nextIndex = osc.end + 1;
} else if (next === "P") {
const dcs = extractDcsSequence(text, index);
if (!dcs) break;
nextIndex = dcs.end + 1;
} else if (next) {
nextIndex = index + 2;
} else {
break;
}
if (nextIndex > limit && safeEnd > 0) {
break;
}
safeEnd = nextIndex;
index = nextIndex;
continue;
}
const codePoint = text.codePointAt(index);
if (!Number.isFinite(codePoint)) {
break;
}
const ch = String.fromCodePoint(codePoint);
const nextIndex = index + ch.length;
if (nextIndex > limit && safeEnd > 0) {
break;
}
safeEnd = nextIndex;
index = nextIndex;
}
if (safeEnd <= 0) {
return { slice: "", rest: text };
}
return {
slice: text.slice(0, safeEnd),
rest: text.slice(safeEnd)
};
}
function extractAnsiCsi(text, startIndex) {
if (text[startIndex] !== "\u001b" || text[startIndex + 1] !== "[") return null;
let index = startIndex + 2;
let buffer = "";
while (index < text.length) {
const ch = text[index];
if (ch >= "@" && ch <= "~") {
return {
end: index,
final: ch,
paramsRaw: buffer
};
}
buffer += ch;
index += 1;
}
return null;
}
function parseCsiParams(paramsRaw) {
const raw = String(paramsRaw || "");
const privateMarker = raw && /^[?<>=!]/.test(raw) ? raw[0] : "";
const body = privateMarker ? raw.slice(1) : raw;
const intermediateMatch = /[\u0020-\u002f]+$/.exec(body);
const intermediates = intermediateMatch ? intermediateMatch[0] : "";
const paramsBody = intermediates ? body.slice(0, -intermediates.length) : body;
const values = paramsBody.length
? paramsBody.split(";").map((part) => {
if (!part) return NaN;
const parsed = Number(part);
return Number.isFinite(parsed) ? parsed : NaN;
})
: [];
return {
privateMarker,
intermediates,
values
};
}
function extractOscSequence(text, startIndex) {
if (text[startIndex] !== "\u001b" || text[startIndex + 1] !== "]") return null;
let index = startIndex + 2;
while (index < text.length) {
const ch = text[index];
if (ch === "\u0007") {
return {
content: text.slice(startIndex + 2, index),
end: index
};
}
if (ch === "\u001b" && text[index + 1] === "\\") {
return {
content: text.slice(startIndex + 2, index),
end: index + 1
};
}
index += 1;
}
return null;
}
function parseOscContent(content) {
const raw = String(content || "");
const separator = raw.indexOf(";");
if (separator < 0) {
return {
ident: Number.NaN,
data: raw
};
}
const ident = Number(raw.slice(0, separator));
return {
ident: Number.isFinite(ident) ? ident : Number.NaN,
data: raw.slice(separator + 1)
};
}
function extractDcsSequence(text, startIndex) {
if (text[startIndex] !== "\u001b" || text[startIndex + 1] !== "P") return null;
let index = startIndex + 2;
let header = "";
while (index < text.length) {
const ch = text[index];
if (ch >= "@" && ch <= "~") {
const final = ch;
const contentStart = index + 1;
let cursor = contentStart;
while (cursor < text.length) {
if (text[cursor] === "\u001b" && text[cursor + 1] === "\\") {
return {
header,
final,
data: text.slice(contentStart, cursor),
end: cursor + 1
};
}
cursor += 1;
}
return null;
}
header += ch;
index += 1;
}
return null;
}
function parseDcsHeader(header) {
const parsed = parseCsiParams(header);
return {
privateMarker: parsed.privateMarker,
intermediates: parsed.intermediates,
values: parsed.values
};
}
function isLikelySgrCode(code) {
const value = Number(code);
if (!Number.isFinite(value)) return false;
if (
value === 0 ||
value === 1 ||
value === 4 ||
value === 22 ||
value === 24 ||
value === 39 ||
value === 49
) {
return true;
}
if (value === 38 || value === 48) return true;
if (value >= 30 && value <= 37) return true;
if (value >= 40 && value <= 47) return true;
if (value >= 90 && value <= 97) return true;
if (value >= 100 && value <= 107) return true;
return false;
}
/**
* 某些录屏/replay 文本会把 `ESC[` 吃掉,只留下裸的 `31m` / `[31m` 片段。
* 这里保留一个“松散 SGR”兜底解析但仍限制在可信 SGR 编码集合内,避免把普通文本误吞成样式。
*/
function extractLooseAnsiSgr(text, startIndex) {
let index = startIndex;
let tokenCount = 0;
let sawBracket = false;
const allCodes = [];
while (index < text.length) {
const tokenStart = index;
if (text[index] === "[" || text[index] === "") {
sawBracket = true;
index += 1;
}
let body = "";
while (index < text.length) {
const ch = text[index];
if ((ch >= "0" && ch <= "9") || ch === ";") {
body += ch;
index += 1;
continue;
}
break;
}
if (body.length === 0 || text[index] !== "m") {
index = tokenStart;
break;
}
const codes = body
.split(";")
.filter((part) => part.length > 0)
.map((part) => {
const parsed = Number(part);
return Number.isFinite(parsed) ? parsed : 0;
});
if (codes.length === 0) {
codes.push(0);
}
allCodes.push(...codes);
tokenCount += 1;
index += 1;
}
if (tokenCount === 0) return null;
if (!allCodes.some((code) => isLikelySgrCode(code))) return null;
if (tokenCount === 1 && !sawBracket) {
const single = allCodes.length === 1 ? allCodes[0] : Number.NaN;
if (!Number.isFinite(single) || ![0, 22, 24, 39, 49].includes(single)) {
return null;
}
}
return {
end: index - 1,
codes: allCodes
};
}
module.exports = {
consumeTerminalSyncUpdateFrames,
createTerminalSyncUpdateState,
extractAnsiCsi,
extractDcsSequence,
extractLooseAnsiSgr,
extractOscSequence,
normalizeTerminalReplayText,
takeTerminalReplaySlice,
parseCsiParams,
parseDcsHeader,
parseOscContent
};

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
const {
consumeTerminalSyncUpdateFrames,
createTerminalSyncUpdateState,
extractLooseAnsiSgr,
takeTerminalReplaySlice
} = require("./vtParser.js");
describe("vtParser", () => {
it("不会把 CSI 控制序列从中间切开", () => {
const result = takeTerminalReplaySlice("ab\u001b[31mcd", 4);
expect(result).toEqual({
slice: "ab",
rest: "\u001b[31mcd"
});
});
it("不会把 CRLF 从中间拆开", () => {
expect(takeTerminalReplaySlice("ab\r\ncd", 3)).toEqual({
slice: "ab",
rest: "\r\ncd"
});
expect(takeTerminalReplaySlice("ab\r\ncd", 4)).toEqual({
slice: "ab\r\n",
rest: "cd"
});
});
it("文本开头是完整长控制序列时会整段前进,避免卡死在零进度", () => {
const result = takeTerminalReplaySlice("\u001b]10;?\u001b\\X", 3);
expect(result).toEqual({
slice: "\u001b]10;?\u001b\\",
rest: "X"
});
});
it("文本前缀是不完整控制序列时会返回空 slice交由上层缓存尾巴", () => {
const result = takeTerminalReplaySlice("\u001b[31", 16);
expect(result).toEqual({
slice: "",
rest: "\u001b[31"
});
});
it("会识别不带 ESC 的松散 SGR 复位片段", () => {
expect(extractLooseAnsiSgr("39mtext", 0)).toEqual({
end: 2,
codes: [39]
});
expect(extractLooseAnsiSgr("[1;31mtext", 0)).toEqual({
end: 5,
codes: [1, 31]
});
});
it("非可信 SGR 数字串不会被误识别成样式序列", () => {
expect(extractLooseAnsiSgr("123mtext", 0)).toBeNull();
});
it("会把 `CSI ? 2026 h/l` 包裹的同步刷新窗口延后到结束时一次性吐出", () => {
const first = consumeTerminalSyncUpdateFrames(
"ab\u001b[?2026hcd",
createTerminalSyncUpdateState()
);
expect(first.text).toBe("ab");
expect(first.state).toEqual({
depth: 1,
carryText: "",
bufferedText: "cd"
});
const second = consumeTerminalSyncUpdateFrames("ef\u001b[?2026lg", first.state);
expect(second.text).toBe("cdefg");
expect(second.state).toEqual({
depth: 0,
carryText: "",
bufferedText: ""
});
});
it("同步刷新起始序列若被 chunk 边界截断,会保留到下一帧继续拼", () => {
const first = consumeTerminalSyncUpdateFrames(
"ab\u001b[?2026",
createTerminalSyncUpdateState()
);
expect(first.text).toBe("ab");
expect(first.state).toEqual({
depth: 0,
carryText: "\u001b[?2026",
bufferedText: ""
});
const second = consumeTerminalSyncUpdateFrames("hcd\u001b[?2026l", first.state);
expect(second.text).toBe("cd");
expect(second.state).toEqual({
depth: 0,
carryText: "",
bufferedText: ""
});
});
it("会按和 web 端一致的口径收口 `DCS = 1 s / = 2 s` 同步刷新窗口", () => {
const first = consumeTerminalSyncUpdateFrames(
"ab\u001bP=1s\u001b\\cd",
createTerminalSyncUpdateState()
);
expect(first.text).toBe("ab");
expect(first.state).toEqual({
depth: 1,
carryText: "",
bufferedText: "cd"
});
const second = consumeTerminalSyncUpdateFrames("ef\u001bP=2s\u001b\\g", first.state);
expect(second.text).toBe("cdefg");
expect(second.state).toEqual({
depth: 0,
carryText: "",
bufferedText: ""
});
});
});