AI IDE 中的 CSS 玩法:打字机效果、主动滚动与状态提示

这一篇聚焦在“AI 加持之后,IDE 里多出来的那些 UI 形态”上:打字机式的流式输出、自动滚动到关键位置、模型状态的可视化等,主要还是从 CSS + 少量 JS 的角度来看。

在 AI IDE 场景下,一个很典型的体验是:
补全/解释不是一次性闪现,而是像聊天一样一字字滚出来。

虽然真正的流式是后端/前端 JS 在控制,但 CSS 也能帮不少忙:

  • 打字机光标效果
    • 用伪元素和动画:
 1.ai-typing::after {
 2  content: "";
 3  display: inline-block;
 4  width: 1ch;
 5  height: 1em;
 6  background-color: currentColor;
 7  margin-left: 2px;
 8  animation: caret-blink 1s steps(1) infinite;
 9}
10
11@keyframes caret-blink {
12  0%, 50% { opacity: 1; }
13  51%, 100% { opacity: 0; }
14}
  • 在“生成中”的行上加上 .ai-typing 类,让用户一眼看到“模型还在说话”。

  • 区分“生成中”和“已完成”的样式

    • 比如生成中使用略淡的颜色、斜体或背景条,结束后恢复正常样式;
    • 这样用户可以快速分辨“这段是不是还可能继续变化”。

下面是一个完整的「侧边 Chat + 打字机效果 + 主动滚动」的示例,可以直接在浏览器里跑一跑:

  1<!doctype html>
  2<html lang="zh-CN">
  3<head>
  4  <meta charset="utf-8" />
  5  <title>AI IDE Chat Demo</title>
  6  <style>
  7    :root {
  8      --bg-main: #0f172a;
  9      --bg-panel: #020617;
 10      --bg-msg-ai: #1e293b;
 11      --bg-msg-user: #0f766e;
 12      --bg-highlight: #facc15;
 13      --text-normal: #e5e7eb;
 14      --text-muted: #9ca3af;
 15      --accent: #38bdf8;
 16      --danger: #fb923c;
 17    }
 18
 19    body {
 20      margin: 0;
 21      height: 100vh;
 22      display: flex;
 23      align-items: stretch;
 24      justify-content: center;
 25      background: radial-gradient(circle at top, #1e293b 0, #020617 60%);
 26      color: var(--text-normal);
 27      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
 28    }
 29
 30    .ide-shell {
 31      display: flex;
 32      height: 90vh;
 33      width: 960px;
 34      max-width: 100%;
 35      border-radius: 12px;
 36      overflow: hidden;
 37      box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
 38      border: 1px solid rgba(148, 163, 184, 0.3);
 39      background: linear-gradient(135deg, #020617, #020617 40%, #0f172a);
 40    }
 41
 42    .sidebar {
 43      width: 56px;
 44      background: #020617;
 45      border-right: 1px solid rgba(148, 163, 184, 0.3);
 46      display: flex;
 47      flex-direction: column;
 48      align-items: center;
 49      padding-top: 12px;
 50      gap: 12px;
 51    }
 52
 53    .sidebar-icon {
 54      width: 28px;
 55      height: 28px;
 56      border-radius: 8px;
 57      display: flex;
 58      align-items: center;
 59      justify-content: center;
 60      color: var(--text-muted);
 61      cursor: pointer;
 62      transition: background 0.15s ease, color 0.15s ease, transform 0.1s;
 63      font-size: 16px;
 64    }
 65
 66    .sidebar-icon:hover {
 67      background: rgba(148, 163, 184, 0.16);
 68      color: var(--text-normal);
 69      transform: translateY(-1px);
 70    }
 71
 72    .sidebar-icon.active {
 73      background: rgba(56, 189, 248, 0.18);
 74      color: var(--accent);
 75    }
 76
 77    .main {
 78      flex: 1;
 79      display: flex;
 80      flex-direction: column;
 81      background: var(--bg-main);
 82    }
 83
 84    .topbar {
 85      height: 36px;
 86      display: flex;
 87      align-items: center;
 88      justify-content: space-between;
 89      padding: 0 12px;
 90      border-bottom: 1px solid rgba(148, 163, 184, 0.3);
 91      background: linear-gradient(to right, #020617, #020617 40%, #0f172a);
 92      font-size: 12px;
 93    }
 94
 95    .status-indicator {
 96      display: flex;
 97      align-items: center;
 98      gap: 8px;
 99    }
100
101    .status-dot {
102      width: 8px;
103      height: 8px;
104      border-radius: 50%;
105      background: #4b5563; /* idle */
106      box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.9);
107    }
108
109    .status-dot.thinking {
110      background: var(--accent);
111      box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.25);
112    }
113
114    .status-dot.risky {
115      background: var(--danger);
116      box-shadow: 0 0 0 4px rgba(251, 146, 60, 0.2);
117    }
118
119    .status-text {
120      color: var(--text-muted);
121    }
122
123    .content {
124      flex: 1;
125      display: flex;
126      overflow: hidden;
127    }
128
129    .editor {
130      flex: 1;
131      border-right: 1px solid rgba(148, 163, 184, 0.3);
132      background: #020617;
133      padding: 10px 0 10px 48px;
134      position: relative;
135      font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
136      font-size: 13px;
137      line-height: 1.5;
138      color: #e5e7eb;
139      overflow: auto;
140    }
141
142    .line {
143      position: relative;
144      padding-right: 12px;
145      padding-left: 4px;
146    }
147
148    .line-number {
149      position: absolute;
150      left: -36px;
151      width: 32px;
152      text-align: right;
153      color: #4b5563;
154      padding-right: 4px;
155      user-select: none;
156    }
157
158    .line.current {
159      background: rgba(56, 189, 248, 0.08);
160    }
161
162    .line.highlight {
163      background: rgba(250, 204, 21, 0.1);
164      transition: background 0.6s ease-out;
165    }
166
167    .line.highlight.fade-out {
168      background: transparent;
169    }
170
171    .chat {
172      width: 320px;
173      display: flex;
174      flex-direction: column;
175      background: var(--bg-panel);
176      border-left: 1px solid rgba(15, 23, 42, 1);
177    }
178
179    .chat-messages {
180      flex: 1;
181      overflow-y: auto;
182      padding: 12px;
183      display: flex;
184      flex-direction: column;
185      gap: 8px;
186      scroll-behavior: smooth;
187      padding-bottom: 40px; /* 预留空间,避免最后一条顶到边 */
188      scroll-margin-bottom: 40px;
189    }
190
191    .msg {
192      max-width: 100%;
193      border-radius: 10px;
194      padding: 8px 10px;
195      font-size: 13px;
196      line-height: 1.5;
197    }
198
199    .msg.user {
200      align-self: flex-end;
201      background: rgba(15, 118, 110, 0.9);
202    }
203
204    .msg.ai {
205      align-self: flex-start;
206      background: var(--bg-msg-ai);
207      border: 1px solid rgba(148, 163, 184, 0.35);
208    }
209
210    .msg.ai.pending {
211      color: var(--text-muted);
212      font-style: italic;
213    }
214
215    .msg.ai .code {
216      display: block;
217      margin-top: 4px;
218      padding: 4px 6px;
219      border-radius: 6px;
220      background: rgba(15, 23, 42, 0.9);
221      font-family: "JetBrains Mono", ui-monospace, monospace;
222      font-size: 12px;
223    }
224
225    .chat-input {
226      border-top: 1px solid rgba(148, 163, 184, 0.4);
227      padding: 6px 8px;
228      display: flex;
229      align-items: center;
230      gap: 6px;
231      background: linear-gradient(to top, #020617, #020617 60%, #0f172a);
232    }
233
234    .chat-input textarea {
235      flex: 1;
236      resize: none;
237      border-radius: 8px;
238      border: 1px solid rgba(148, 163, 184, 0.5);
239      background: #020617;
240      color: var(--text-normal);
241      padding: 6px 8px;
242      font-size: 13px;
243      line-height: 1.4;
244      max-height: 72px;
245    }
246
247    .chat-input button {
248      border-radius: 999px;
249      border: none;
250      padding: 6px 12px;
251      font-size: 13px;
252      cursor: pointer;
253      background: var(--accent);
254      color: #020617;
255      display: flex;
256      align-items: center;
257      gap: 4px;
258      box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.8);
259      transition: background 0.15s ease, transform 0.1s;
260    }
261
262    .chat-input button:hover {
263      background: #0ea5e9;
264      transform: translateY(-1px);
265    }
266
267    .chat-input button:active {
268      transform: translateY(0);
269    }
270
271    .ai-typing::after {
272      content: "";
273      display: inline-block;
274      width: 1ch;
275      height: 1em;
276      background-color: currentColor;
277      margin-left: 2px;
278      animation: caret-blink 1s steps(1) infinite;
279    }
280
281    @keyframes caret-blink {
282      0%, 50% { opacity: 1; }
283      51%, 100% { opacity: 0; }
284    }
285  </style>
286</head>
287<body>
288  <div class="ide-shell">
289    <div class="sidebar">
290      <div class="sidebar-icon active"></div>
291      <div class="sidebar-icon">{ }</div>
292      <div class="sidebar-icon"></div>
293    </div>
294    <div class="main">
295      <div class="topbar">
296        <div class="status-indicator">
297          <span id="status-dot" class="status-dot"></span>
298          <span id="status-text" class="status-text">Idle</span>
299        </div>
300        <div class="status-text">app.tsx — AI IDE Demo</div>
301      </div>
302      <div class="content">
303        <div class="editor" id="editor">
304          <div class="line">
305            <span class="line-number">1</span>
306            <span>function App() {</span>
307          </div>
308          <div class="line">
309            <span class="line-number">2</span>
310            <span>  const [value, setValue] = useState("&quot;");</span>
311          </div>
312          <div class="line current" id="line-insert">
313            <span class="line-number">3</span>
314            <span>// 光标在这里,等待 AI 补全...</span>
315          </div>
316          <div class="line">
317            <span class="line-number">4</span>
318            <span>  return (&lt;Editor value={value} onChange={setValue} /&gt;);</span>
319          </div>
320          <div class="line">
321            <span class="line-number">5</span>
322            <span>}</span>
323          </div>
324        </div>
325        <div class="chat">
326          <div class="chat-messages" id="messages">
327            <div class="msg ai">
328              嗨,我是内嵌在 IDE 里的 AI 助手,可以帮你补全和重构这段代码。
329            </div>
330            <div class="msg user">
331              帮我把第三行改成用 async 加载初始值,并解释一下。
332            </div>
333            <div class="msg ai pending ai-typing" id="pending-msg">
334              正在思考合适的改法...
335            </div>
336          </div>
337          <div class="chat-input">
338            <textarea rows="1" placeholder="试着问:帮我优化这段状态管理代码"></textarea>
339            <button type="button" id="send-btn">
340              发送
341            </button>
342          </div>
343        </div>
344      </div>
345    </div>
346  </div>
347
348  <script>
349    const pending = document.getElementById("pending-msg");
350    const messages = document.getElementById("messages");
351    const statusDot = document.getElementById("status-dot");
352    const statusText = document.getElementById("status-text");
353    const lineInsert = document.getElementById("line-insert");
354
355    function simulateAI() {
356      statusDot.classList.add("thinking");
357      statusText.textContent = "Thinking...";
358
359      const fullText = "可以把 state 初始化改成从异步接口加载,例如:";
360      let i = 0;
361      pending.textContent = "";
362      pending.classList.add("ai-typing");
363
364      function step() {
365        if (i <= fullText.length) {
366          pending.textContent = fullText.slice(0, i);
367          i++;
368          messages.scrollTop = messages.scrollHeight; // 主动滚动到底部
369          requestAnimationFrame(step);
370        } else {
371          // 完成后移除打字机效果,并追加代码块
372          pending.classList.remove("ai-typing", "pending");
373          pending.classList.add("msg-final");
374          const code = document.createElement("span");
375          code.className = "code";
376          code.textContent = "useEffect(() => {\n  fetchInitial().then(setValue);\n}, []);";
377          pending.appendChild(code);
378
379          // 高亮编辑器中插入位置
380          lineInsert.classList.add("highlight");
381          setTimeout(() => {
382            lineInsert.classList.add("fade-out");
383          }, 800);
384
385          statusDot.classList.remove("thinking");
386          statusText.textContent = "Idle";
387        }
388      }
389
390      step();
391    }
392
393    // 简单模拟一次“加载完成后自动开始打字”的效果
394    window.addEventListener("load", () => {
395      setTimeout(simulateAI, 600);
396    });
397  </script>
398</body>
399</html>
## 主动滚动:把焦点对准“正在发生的事”

AI 交互常见的一种行为是:
在侧边栏对话里生成大量内容,或者在代码视图里自动插入补全。

除了 JS 控制 scrollIntoView 之外,CSS 上可以做一些增强体验的处理:

  • 为“当前消息”预留可视空间

    • 在对话面板底部加上适当 padding-bottom,避免最后一行紧贴底边;
    • 配合 scroll-margin-bottomscrollIntoView 的效果更自然。
  • 针对补全的位置做局部高亮

    • 在插入/修改区域短暂加一层背景高亮(例如淡黄色),几秒后淡出;
    • transition 控制淡出,减少突兀感。

在一个“AI 不断动代码”的环境里,这些小高亮和滚动细节能让使用者不迷路。

AI IDE 还有一个和传统 IDE 不同的点:
很多时候你需要告诉用户“模型现在在干嘛、它对自己有多自信”。

CSS 在这里常见的用法包括:

  • 状态 Indicator

    • 在侧边栏头部或编辑器某个角落放一个小状态点:
      • 灰色:空闲;
      • 蓝色:思考中(请求中/生成中);
      • 橙色:低置信度建议(例如长 patch、模型自己标注“不太确定”)。
  • 置信度/风险提示样式

    • 对被标记为“可能有风险”的建议,用不同底纹或边框表现;
    • 对“安全建议”(例如只读解释)使用更中性的样式。

这些都不需要复杂逻辑,更多是统一一套 class 命名和配色方案,让状态一目了然。

AI IDE 里常见的几个 UI 容器:

  • 侧边 Chat 面板;
  • 贴在代码行旁边的内联气泡(inline hint);
  • 悬浮的解释/建议卡片。

CSS 上常见的处理:

  • 侧边 Chat 面板

    • 用 Flex/Gird 划分“消息列表区域”和“输入区域”;
    • 保持输入框固定贴底,消息区域滚动;
    • 对“代码块消息”和“纯文本消息”使用不同的 padding 和字体(代码块可以用等宽字体、略深背景)。
  • 内联气泡(Inline Hint)

    • 贴在代码行尾/旁边,使用小气泡样式:
      • 圆角、阴影、小三角形指向目标行;
    • 必须响应滚动:通常与代码行共享一个滚动容器。
  • 悬浮卡片

    • 使用绝对定位或 portal,配合 position: fixed / absolute 和 transform 修正位置;
    • 注意 max-widthmax-height,避免遮住太多代码;
    • 可以加上淡入淡出动画,让出现/消失更自然。

这些东西本质上是“聊天 UI + 代码视图”的混合体,CSS 在排版和层级感上起关键作用。

相对传统 IDE 壳,AI IDE 多出来的 CSS 关注点大致有两类:

  • 动态感:打字机效果、流式输出、插入补全时的局部高亮和自动滚动;
  • 状态感:模型状态、置信度、建议类型(解释/补全/重构)的可视化区分。

背后仍然是“合理组织 class 和变量 + 少量动画和过渡”的问题。
对于已经熟悉 IDE 布局和主题系统的前端工程师来说,把这部分加进现有 CSS 体系里,更多是“新增几个有意义的状态维度”,而不是完全重新设计一套东西。