@24klynx/tui 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState } from "react";
1
+ import React, { createContext, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState } from "react";
2
2
  import { Box, Text, useInput as useInput$1, useStdout } from "ink";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
- import { dirname, join, relative } from "node:path";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
5
  import { homedir } from "node:os";
6
- import { execSync } from "node:child_process";
6
+ import { execSync, spawn } from "node:child_process";
7
7
  //#region src/theme/theme.ts
8
8
  const THEMES = {
9
9
  dark: {
@@ -1280,6 +1280,7 @@ function replaceSubscript(text) {
1280
1280
  * Returns the approximate Unicode text, or the original if no
1281
1281
  * patterns matched.
1282
1282
  */
1283
+ /** Render LaTeX math inline — converts \\frac, \\sum, superscript, subscript etc. to Unicode approximations. */
1283
1284
  function renderInlineLatex(latex) {
1284
1285
  let result = latex;
1285
1286
  result = result.replace(/\\frac\{([^}]*)}\{([^}]*)}/g, "($1)/($2)");
@@ -1925,6 +1926,233 @@ function renderBlocks(blocks) {
1925
1926
  return React.createElement(Box, { flexDirection: "column" }, ...elements);
1926
1927
  }
1927
1928
  //#endregion
1929
+ //#region src/components/MessageRow.ts
1930
+ /**
1931
+ * MessageRow — renders a single chat transcript entry with proper visual styling.
1932
+ *
1933
+ * Three bubble styles aligned to Claude Code:
1934
+ * - User: indented right, no prefix, direct style
1935
+ * - Assistant: full-width with markdown rendering
1936
+ * - System/Tool: dimmed, prefixed, compact
1937
+ *
1938
+ * Design (§5.4a #1.3.2): extracts per‑entry rendering from ChatLog
1939
+ * (was 1058 lines) into a standalone component with proper JSDoc.
1940
+ */
1941
+ /** Characters beyond which a tool_result is collapsed by default. */
1942
+ const TOOL_RESULT_COLLAPSE_LEN$1 = 1e3;
1943
+ /**
1944
+ * Single message row in the chat transcript.
1945
+ *
1946
+ * Dispatches to specialized renderers based on `entry.kind`:
1947
+ * assistant → full markdown (streaming or finalized)
1948
+ * user → right‑aligned bubble
1949
+ * system → dimmed prefix line
1950
+ * tool_use → status icon + tool name
1951
+ * tool_result → collapsible inline result
1952
+ * thinking → collapsed/expanded reasoning block
1953
+ * btw → accent‑colored hint
1954
+ */
1955
+ /** Single message row in the chat transcript — dispatches to specialized renderers by entry kind. */
1956
+ function MessageRow({ entry, isExpanded, transcriptMode = "compact", searchQuery = "", matchIndices = [] }) {
1957
+ const theme = getTheme();
1958
+ const timestampRow = transcriptMode === "full" ? React.createElement(Text, { dimColor: true }, `── ${new Date(entry.timestamp).toLocaleTimeString()} · ${kindLabel(entry.kind)} ──`) : null;
1959
+ const content = renderEntryContent(entry, isExpanded, matchIndices, searchQuery, theme);
1960
+ return React.createElement(Box, {
1961
+ flexDirection: "column",
1962
+ marginBottom: entry.kind === "btw" ? 0 : 1
1963
+ }, timestampRow, ...content);
1964
+ }
1965
+ /** Map entry kind to a Chinese label for display. */
1966
+ function kindLabel(kind) {
1967
+ switch (kind) {
1968
+ case "system": return "系统";
1969
+ case "user": return "用户";
1970
+ case "assistant": return "助手";
1971
+ case "tool_use": return "工具调用";
1972
+ case "tool_result": return "工具结果";
1973
+ case "btw": return "提示";
1974
+ case "thinking": return "思考";
1975
+ }
1976
+ }
1977
+ function entryColor(entry, colors) {
1978
+ switch (entry.kind) {
1979
+ case "system": return entry.level === "error" ? colors.error : entry.level === "warn" ? colors.warning : colors.dimmed;
1980
+ case "user": return entry.pending ? colors.dimmed : colors.foreground;
1981
+ case "assistant": return colors.foreground;
1982
+ case "tool_use": return colors.info ?? "yellow";
1983
+ case "tool_result": return colors.dimmed;
1984
+ case "btw": return colors.accent;
1985
+ case "thinking": return colors.dimmed;
1986
+ default: return;
1987
+ }
1988
+ }
1989
+ function entryPrefix(entry) {
1990
+ switch (entry.kind) {
1991
+ case "system": return entry.level === "error" ? "✘ " : entry.level === "warn" ? "⚠ " : "─ ";
1992
+ case "user": return entry.pending ? "[发送中] " : "> ";
1993
+ case "assistant": return "";
1994
+ case "tool_use": return "";
1995
+ case "tool_result": return "";
1996
+ case "btw": return "💡 ";
1997
+ case "thinking": return "💭 ";
1998
+ default: return "";
1999
+ }
2000
+ }
2001
+ function renderEntryContent(entry, isExpanded, matchIndices, searchQuery, theme) {
2002
+ switch (entry.kind) {
2003
+ case "assistant": return renderAssistant(entry, matchIndices, searchQuery, theme);
2004
+ case "system": return renderSystem(entry, matchIndices, searchQuery, theme);
2005
+ case "user": return renderUser(entry, matchIndices, searchQuery, theme);
2006
+ case "tool_use": return renderToolUse(entry, matchIndices, searchQuery, theme);
2007
+ case "tool_result": return renderToolResult(entry, isExpanded, matchIndices, searchQuery, theme);
2008
+ case "btw": return renderBtw(entry, matchIndices, searchQuery, theme);
2009
+ case "thinking": return renderThinking(entry, isExpanded, theme);
2010
+ default: return [React.createElement(Text, {
2011
+ key: "text",
2012
+ color: entryColor(entry, theme.colors)
2013
+ }, entry.text)];
2014
+ }
2015
+ }
2016
+ function renderAssistant(entry, matchIndices, searchQuery, theme) {
2017
+ const elements = [];
2018
+ if (entry.streaming) {
2019
+ const stableOffset = findStableOffset(entry.text);
2020
+ const stablePart = entry.text.slice(0, stableOffset);
2021
+ const unstablePart = entry.text.slice(stableOffset);
2022
+ if (stablePart) {
2023
+ const blocks = parseMarkdown(stablePart);
2024
+ elements.push(React.createElement(Box, {
2025
+ key: "stable",
2026
+ flexDirection: "column"
2027
+ }, renderBlocks(blocks)));
2028
+ }
2029
+ if (unstablePart) elements.push(React.createElement(Text, {
2030
+ key: "streaming",
2031
+ color: theme.colors.foreground
2032
+ }, highlightMatches(unstablePart, matchIndices, searchQuery), React.createElement(Text, null, "▊")));
2033
+ else if (!stablePart) elements.push(React.createElement(Text, {
2034
+ key: "streaming",
2035
+ color: theme.colors.foreground
2036
+ }, "▊"));
2037
+ } else {
2038
+ const blocks = parseMarkdown(entry.text);
2039
+ if (blocks.length > 0) elements.push(React.createElement(Box, {
2040
+ key: "md",
2041
+ flexDirection: "column"
2042
+ }, renderBlocks(blocks)));
2043
+ }
2044
+ return elements;
2045
+ }
2046
+ function renderSystem(entry, matchIndices, searchQuery, theme) {
2047
+ const color = entryColor(entry, theme.colors);
2048
+ const prefix = entryPrefix(entry);
2049
+ const content = highlightMatches(entry.text, matchIndices, searchQuery);
2050
+ return [React.createElement(Text, {
2051
+ key: "content",
2052
+ color
2053
+ }, prefix, renderInlineContent(content))];
2054
+ }
2055
+ function renderUser(entry, matchIndices, searchQuery, theme) {
2056
+ const color = entryColor(entry, theme.colors);
2057
+ const prefix = entryPrefix(entry);
2058
+ const suffix = entry.pending ? "…" : "";
2059
+ return [React.createElement(Text, {
2060
+ key: "content",
2061
+ color
2062
+ }, prefix, renderInlineContent(highlightMatches(entry.text, matchIndices, searchQuery)), suffix)];
2063
+ }
2064
+ function renderToolUse(entry, matchIndices, searchQuery, theme) {
2065
+ const isDone = entry.toolStartedAt !== void 0 && entry.text.includes("·");
2066
+ const prefix = isDone ? "✓ " : "✽ ";
2067
+ const color = isDone ? theme.colors.success : theme.colors.accent;
2068
+ const suffix = isDone ? "" : "…";
2069
+ return [React.createElement(Text, {
2070
+ key: "content",
2071
+ color
2072
+ }, prefix, highlightMatches(entry.text + suffix, matchIndices, searchQuery))];
2073
+ }
2074
+ /** Collapse multi-line content into a single line. */
2075
+ function collapseWhitespace(text) {
2076
+ return text.replace(/\s+/g, " ").trim();
2077
+ }
2078
+ function renderToolResult(entry, isExpanded, matchIndices, searchQuery, theme) {
2079
+ const color = theme.colors.foreground;
2080
+ const collapsed = collapseWhitespace(entry.text);
2081
+ const isLong = collapsed.length > TOOL_RESULT_COLLAPSE_LEN$1;
2082
+ const elements = [];
2083
+ if (isLong && !isExpanded) {
2084
+ const truncated = collapsed.slice(0, TOOL_RESULT_COLLAPSE_LEN$1);
2085
+ elements.push(React.createElement(Text, {
2086
+ key: "content",
2087
+ color,
2088
+ dimColor: true
2089
+ }, " ⎿ ", renderInlineContent(highlightMatches(truncated, matchIndices, searchQuery)), React.createElement(Text, {
2090
+ key: "ellipsis",
2091
+ dimColor: true
2092
+ }, `… [+${collapsed.length - TOOL_RESULT_COLLAPSE_LEN$1} 字符, Ctrl+E 展开]`)));
2093
+ } else elements.push(React.createElement(Text, {
2094
+ key: "content",
2095
+ color,
2096
+ dimColor: true
2097
+ }, " ⎿ ", renderInlineContent(highlightMatches(collapsed, matchIndices, searchQuery)), isLong ? React.createElement(Text, {
2098
+ key: "collapse",
2099
+ dimColor: true
2100
+ }, " [Ctrl+E 折叠]") : null));
2101
+ return elements;
2102
+ }
2103
+ function renderBtw(entry, matchIndices, searchQuery, theme) {
2104
+ const color = entryColor(entry, theme.colors);
2105
+ const prefix = entryPrefix(entry);
2106
+ return [React.createElement(Text, {
2107
+ key: "content",
2108
+ color
2109
+ }, prefix, renderInlineContent(highlightMatches(entry.text, matchIndices, searchQuery)))];
2110
+ }
2111
+ function renderThinking(entry, isExpanded, theme) {
2112
+ const color = entryColor(entry, theme.colors);
2113
+ const prefix = entryPrefix(entry);
2114
+ const seconds = entry.durationMs != null ? (entry.durationMs / 1e3).toFixed(1) : "?";
2115
+ if (isExpanded) return entry.text.trim().split("\n").map((line, i) => React.createElement(Text, {
2116
+ key: `think-${i}`,
2117
+ color
2118
+ }, i === 0 ? `${prefix}已思考 ${seconds}s · Enter 折叠` : prefix, line || " "));
2119
+ return [React.createElement(Text, {
2120
+ key: "summary",
2121
+ color,
2122
+ dimColor: true
2123
+ }, `${prefix}已思考 ${seconds}s · Enter 展开`)];
2124
+ }
2125
+ /** Render text with inline markdown formatting. */
2126
+ function renderInlineContent(content) {
2127
+ if (typeof content === "string") return renderInline(content);
2128
+ return content;
2129
+ }
2130
+ /**
2131
+ * Highlight search query matches in text.
2132
+ *
2133
+ * Wraps each match in an inverse‑colored `<Text>` span.
2134
+ * Returns plain text when no query/matches are active.
2135
+ */
2136
+ function highlightMatches(text, matchIndices, query) {
2137
+ if (!query || matchIndices.length === 0) return text;
2138
+ const len = query.length;
2139
+ const sorted = [...new Set(matchIndices)].sort((a, b) => a - b);
2140
+ const children = [];
2141
+ let cursor = 0;
2142
+ for (const start of sorted) {
2143
+ if (start < cursor) continue;
2144
+ if (start > cursor) children.push(text.slice(cursor, start));
2145
+ children.push(React.createElement(Text, {
2146
+ key: `hl-${start}`,
2147
+ inverse: true
2148
+ }, text.slice(start, start + len)));
2149
+ cursor = start + len;
2150
+ }
2151
+ if (cursor < text.length) children.push(text.slice(cursor));
2152
+ if (children.length === 1 && typeof children[0] === "string") return children[0];
2153
+ return children;
2154
+ }
2155
+ //#endregion
1928
2156
  //#region src/components/ChatLog.ts
1929
2157
  /**
1930
2158
  * ChatLog — imperative‑handle message transcript.
@@ -1987,7 +2215,7 @@ function reduceCommitPendingUser(state) {
1987
2215
  }
1988
2216
  function reduceDropPendingUser(state) {
1989
2217
  const entries = [...state.entries];
1990
- const idx = entries.findLastIndex((e) => e.kind === "user" && e.pending);
2218
+ const idx = findLastIndex(entries, (e) => e.kind === "user" && e.pending);
1991
2219
  if (idx !== -1) entries.splice(idx, 1);
1992
2220
  return {
1993
2221
  entries,
@@ -1996,7 +2224,7 @@ function reduceDropPendingUser(state) {
1996
2224
  }
1997
2225
  function reduceStartAssistant(state, text) {
1998
2226
  const entries = [...state.entries];
1999
- const existing = entries.findLastIndex((e) => e.kind === "assistant" && e.streaming);
2227
+ const existing = findLastIndex(entries, (e) => e.kind === "assistant" && e.streaming);
2000
2228
  const newEntry = entry(state, "assistant", text ?? "", { streaming: true });
2001
2229
  if (existing !== -1) {
2002
2230
  entries[existing] = newEntry;
@@ -2042,7 +2270,7 @@ function reduceFinalizeAssistant(state, text) {
2042
2270
  }
2043
2271
  function reduceDropAssistant(state) {
2044
2272
  const entries = [...state.entries];
2045
- const idx = entries.findLastIndex((e) => e.kind === "assistant" && e.streaming);
2273
+ const idx = findLastIndex(entries, (e) => e.kind === "assistant" && e.streaming);
2046
2274
  if (idx !== -1) entries.splice(idx, 1);
2047
2275
  return {
2048
2276
  entries,
@@ -2084,7 +2312,7 @@ function reduceUpdateToolResult(state, callId, result) {
2084
2312
  }
2085
2313
  function reduceShowBtw(state, text) {
2086
2314
  const entries = [...state.entries];
2087
- const existing = entries.findLastIndex((e) => e.kind === "btw");
2315
+ const existing = findLastIndex(entries, (e) => e.kind === "btw");
2088
2316
  if (existing !== -1) entries.splice(existing, 1);
2089
2317
  entries.push(entry(state, "btw", text));
2090
2318
  return {
@@ -2094,7 +2322,7 @@ function reduceShowBtw(state, text) {
2094
2322
  }
2095
2323
  function reduceDismissBtw(state) {
2096
2324
  const entries = [...state.entries];
2097
- const idx = entries.findLastIndex((e) => e.kind === "btw");
2325
+ const idx = findLastIndex(entries, (e) => e.kind === "btw");
2098
2326
  if (idx !== -1) entries.splice(idx, 1);
2099
2327
  return {
2100
2328
  entries,
@@ -2111,7 +2339,7 @@ function reduceShowThinking(state, text, durationMs) {
2111
2339
  }
2112
2340
  function reduceDismissThinking(state) {
2113
2341
  const entries = [...state.entries];
2114
- const idx = entries.findLastIndex((e) => e.kind === "thinking");
2342
+ const idx = findLastIndex(entries, (e) => e.kind === "thinking");
2115
2343
  if (idx !== -1) entries.splice(idx, 1);
2116
2344
  return {
2117
2345
  entries,
@@ -2144,6 +2372,15 @@ function chatLogReducer(state, action) {
2144
2372
  case "CLEAR_ALL": return reduceClearAll();
2145
2373
  }
2146
2374
  }
2375
+ /** ES2023 polyfill — find the index of the last element matching a predicate. */
2376
+ function findLastIndex(arr, pred) {
2377
+ for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i])) return i;
2378
+ return -1;
2379
+ }
2380
+ /** ES2023 polyfill — find the last element matching a predicate. */
2381
+ function findLast(arr, pred) {
2382
+ for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i])) return arr[i];
2383
+ }
2147
2384
  /** Characters beyond which a tool_result is collapsed by default. */
2148
2385
  const TOOL_RESULT_COLLAPSE_LEN = 1e3;
2149
2386
  /** ChatLog component — renders the message transcript driven by a handle. */
@@ -2215,7 +2452,8 @@ const ChatLog = forwardRef(function ChatLog({ visibleRange, onSearchActiveChange
2215
2452
  return;
2216
2453
  }
2217
2454
  if (key.return && !key.ctrl && !key.meta) {
2218
- const thinkingEntry = stateForInputRef.current.entries.findLast((e) => e.kind === "thinking");
2455
+ const curState = stateForInputRef.current;
2456
+ const thinkingEntry = findLast(curState.entries, (e) => e.kind === "thinking");
2219
2457
  if (thinkingEntry) {
2220
2458
  setExpandedIds((prev) => {
2221
2459
  const next = new Set(prev);
@@ -2360,16 +2598,19 @@ const ChatLog = forwardRef(function ChatLog({ visibleRange, onSearchActiveChange
2360
2598
  }
2361
2599
  }
2362
2600
  const clampedMatchIdx = searchMatches.length > 0 ? (searchMatchIdx % searchMatches.length + searchMatches.length) % searchMatches.length : 0;
2363
- const activeMatchEntryId = searchMatches.length > 0 ? searchMatches[clampedMatchIdx].entryId : null;
2601
+ searchMatches.length > 0 && searchMatches[clampedMatchIdx].entryId;
2364
2602
  const elements = [];
2365
2603
  for (const entry of windowed) {
2366
2604
  const isExpanded = expandedIds.has(entry.id);
2367
2605
  const entryMatchIndices = searchActive ? searchMatches.filter((m) => m.entryId === entry.id).map((m) => m.start) : [];
2368
- elements.push(React.createElement(Box, {
2606
+ elements.push(React.createElement(MessageRow, {
2369
2607
  key: entry.id,
2370
- flexDirection: "column",
2371
- marginBottom: entry.kind === "btw" ? 0 : 1
2372
- }, transcriptMode === "full" ? React.createElement(Text, { dimColor: true }, `── ${new Date(entry.timestamp).toLocaleTimeString()} · ${kindLabel(entry.kind)} ──`) : null, ...renderEntryContent(entry, isExpanded, activeMatchEntryId === entry.id ? clampedMatchIdx : -1, entryMatchIndices, searchQuery, theme)));
2608
+ entry,
2609
+ isExpanded,
2610
+ transcriptMode,
2611
+ searchQuery: searchActive ? searchQuery : "",
2612
+ matchIndices: entryMatchIndices
2613
+ }));
2373
2614
  }
2374
2615
  const searchBar = searchActive ? React.createElement(Box, {
2375
2616
  flexDirection: "row",
@@ -2380,233 +2621,19 @@ const ChatLog = forwardRef(function ChatLog({ visibleRange, onSearchActiveChange
2380
2621
  }, "🔍 "), React.createElement(Text, null, searchQuery || React.createElement(Text, { dimColor: true }, "输入搜索内容")), React.createElement(Text, { dimColor: true }, "█"), searchMatches.length > 0 ? React.createElement(Text, { dimColor: true }, ` ${clampedMatchIdx + 1}/${searchMatches.length}`) : searchQuery ? React.createElement(Text, { dimColor: true }, " 无匹配") : React.createElement(Text, { dimColor: true }, " Esc 关闭")) : null;
2381
2622
  return React.createElement(Box, { flexDirection: "column" }, stickyHeader, ...elements, searchBar);
2382
2623
  });
2383
- function entryColor(entry, colors) {
2384
- switch (entry.kind) {
2385
- case "system": return entry.level === "error" ? colors.error : entry.level === "warn" ? colors.warning : colors.dimmed;
2386
- case "user": return entry.pending ? colors.dimmed : colors.foreground;
2387
- case "assistant": return entry.streaming ? colors.foreground : colors.foreground;
2388
- case "tool_use": return colors.info ?? "yellow";
2389
- case "tool_result": return colors.dimmed;
2390
- case "btw": return colors.accent;
2391
- case "thinking": return colors.dimmed;
2392
- default: return;
2393
- }
2394
- }
2395
- /** Map entry kind to a Chinese label for display. */
2396
- function kindLabel(kind) {
2397
- switch (kind) {
2398
- case "system": return "系统";
2399
- case "user": return "用户";
2400
- case "assistant": return "助手";
2401
- case "tool_use": return "工具调用";
2402
- case "tool_result": return "工具结果";
2403
- case "btw": return "提示";
2404
- case "thinking": return "思考";
2405
- }
2406
- }
2407
- function entryPrefix(entry) {
2408
- switch (entry.kind) {
2409
- case "system": return entry.level === "error" ? "✘ " : entry.level === "warn" ? "⚠ " : "─ ";
2410
- case "user": return entry.pending ? "[发送中] " : "> ";
2411
- case "assistant": return "";
2412
- case "tool_use": return "";
2413
- case "tool_result": return "";
2414
- case "btw": return "💡 ";
2415
- case "thinking": return "💭 ";
2416
- default: return "";
2417
- }
2418
- }
2624
+ //#endregion
2625
+ //#region src/renderer/frame-limiter.ts
2419
2626
  /**
2420
- * Render the content lines for a single chat entry.
2627
+ * FrameRateLimiter throttle Ink re‑renders to ~30 FPS via microtask batching.
2628
+ *
2629
+ * Ink renders on every state change. For high‑frequency updates (streaming
2630
+ * text deltas, tool output), this can cause excessive re‑renders.
2631
+ * FrameRateLimiter coalesces state updates within a 16 ms window.
2421
2632
  *
2422
- * Returns an array of React elements suitable as children of a Box.
2423
- * Each entry kind gets its own rendering strategy:
2424
- * - assistant full markdown (parseMarkdown + renderBlocks)
2425
- * - system → renderInline for formatting
2426
- * - user → renderInline for formatting
2427
- * - tool_use → plain text with prefix
2428
- * - tool_result → renderInline, collapsible beyond TOOL_RESULT_COLLAPSE_LEN
2429
- * - btw → renderInline
2430
- */
2431
- function renderEntryContent(entry, isExpanded, activeMatchGlobalIdx, entryMatchIndices, searchQuery, theme) {
2432
- switch (entry.kind) {
2433
- case "assistant": return renderAssistant(entry, entryMatchIndices, searchQuery, theme);
2434
- case "system": return renderSystem(entry, entryMatchIndices, searchQuery, theme);
2435
- case "user": return renderUser(entry, entryMatchIndices, searchQuery, theme);
2436
- case "tool_use": return renderToolUse(entry, entryMatchIndices, searchQuery, theme);
2437
- case "tool_result": return renderToolResult(entry, isExpanded, entryMatchIndices, searchQuery, theme);
2438
- case "btw": return renderBtw(entry, entryMatchIndices, searchQuery, theme);
2439
- case "thinking": return renderThinking(entry, isExpanded, theme);
2440
- default: return [React.createElement(Text, {
2441
- key: "text",
2442
- color: entryColor(entry, theme.colors)
2443
- }, entry.text)];
2444
- }
2445
- }
2446
- function renderAssistant(entry, matchIndices, searchQuery, theme) {
2447
- const elements = [];
2448
- if (entry.streaming) {
2449
- const stableOffset = findStableOffset(entry.text);
2450
- const stablePart = entry.text.slice(0, stableOffset);
2451
- const unstablePart = entry.text.slice(stableOffset);
2452
- if (stablePart) {
2453
- const blocks = parseMarkdown(stablePart);
2454
- elements.push(React.createElement(Box, {
2455
- key: "stable",
2456
- flexDirection: "column"
2457
- }, renderBlocks(blocks)));
2458
- }
2459
- if (unstablePart) elements.push(React.createElement(Text, {
2460
- key: "streaming",
2461
- color: theme.colors.foreground
2462
- }, highlightMatches(unstablePart, matchIndices, searchQuery), React.createElement(Text, null, "▊")));
2463
- else if (!stablePart) elements.push(React.createElement(Text, {
2464
- key: "streaming",
2465
- color: theme.colors.foreground
2466
- }, "▊"));
2467
- } else {
2468
- const blocks = parseMarkdown(entry.text);
2469
- if (blocks.length > 0) elements.push(React.createElement(Box, {
2470
- key: "md",
2471
- flexDirection: "column"
2472
- }, renderBlocks(blocks)));
2473
- }
2474
- return elements;
2475
- }
2476
- function renderSystem(entry, matchIndices, searchQuery, theme) {
2477
- const color = entryColor(entry, theme.colors);
2478
- const prefix = entryPrefix(entry);
2479
- const content = highlightMatches(entry.text, matchIndices, searchQuery);
2480
- return [React.createElement(Text, {
2481
- key: "content",
2482
- color
2483
- }, prefix, renderInlineContent(content))];
2484
- }
2485
- function renderUser(entry, matchIndices, searchQuery, theme) {
2486
- const color = entryColor(entry, theme.colors);
2487
- const prefix = entryPrefix(entry);
2488
- const suffix = entry.pending ? "…" : "";
2489
- return [React.createElement(Text, {
2490
- key: "content",
2491
- color
2492
- }, prefix, renderInlineContent(highlightMatches(entry.text, matchIndices, searchQuery)), suffix)];
2493
- }
2494
- function renderToolUse(entry, matchIndices, searchQuery, theme) {
2495
- const isDone = entry.toolStartedAt !== void 0 && entry.text.includes("·");
2496
- const prefix = isDone ? "✓ " : "✽ ";
2497
- const color = isDone ? theme.colors.success : theme.colors.accent;
2498
- const suffix = isDone ? "" : "…";
2499
- return [React.createElement(Text, {
2500
- key: "content",
2501
- color
2502
- }, prefix, highlightMatches(entry.text + suffix, matchIndices, searchQuery))];
2503
- }
2504
- /**
2505
- * Collapse multi-line content into a single line for inline display.
2506
- * Replaces consecutive whitespace (including newlines) with a single space.
2507
- */
2508
- function collapseWhitespace(text) {
2509
- return text.replace(/\s+/g, " ").trim();
2510
- }
2511
- function renderToolResult(entry, isExpanded, matchIndices, searchQuery, theme) {
2512
- const color = theme.colors.foreground;
2513
- const collapsed = collapseWhitespace(entry.text);
2514
- const isLong = collapsed.length > TOOL_RESULT_COLLAPSE_LEN;
2515
- const elements = [];
2516
- if (isLong && !isExpanded) {
2517
- const truncated = collapsed.slice(0, TOOL_RESULT_COLLAPSE_LEN);
2518
- elements.push(React.createElement(Text, {
2519
- key: "content",
2520
- color,
2521
- dimColor: true
2522
- }, " ⎿ ", renderInlineContent(highlightMatches(truncated, matchIndices, searchQuery)), React.createElement(Text, {
2523
- key: "ellipsis",
2524
- dimColor: true
2525
- }, `… [+${collapsed.length - TOOL_RESULT_COLLAPSE_LEN} 字符, Ctrl+E 展开]`)));
2526
- } else elements.push(React.createElement(Text, {
2527
- key: "content",
2528
- color,
2529
- dimColor: true
2530
- }, " ⎿ ", renderInlineContent(highlightMatches(collapsed, matchIndices, searchQuery)), isLong ? React.createElement(Text, {
2531
- key: "collapse",
2532
- dimColor: true
2533
- }, " [Ctrl+E 折叠]") : null));
2534
- return elements;
2535
- }
2536
- function renderBtw(entry, matchIndices, searchQuery, theme) {
2537
- const color = entryColor(entry, theme.colors);
2538
- const prefix = entryPrefix(entry);
2539
- return [React.createElement(Text, {
2540
- key: "content",
2541
- color
2542
- }, prefix, renderInlineContent(highlightMatches(entry.text, matchIndices, searchQuery)))];
2543
- }
2544
- /**
2545
- * Render a thinking entry — collapsed shows "已思考 X.Xs · Enter 展开",
2546
- * expanded shows the full reasoning text.
2547
- */
2548
- function renderThinking(entry, isExpanded, theme) {
2549
- const color = entryColor(entry, theme.colors);
2550
- const prefix = entryPrefix(entry);
2551
- const seconds = entry.durationMs != null ? (entry.durationMs / 1e3).toFixed(1) : "?";
2552
- if (isExpanded) return entry.text.trim().split("\n").map((line, i) => React.createElement(Text, {
2553
- key: `think-${i}`,
2554
- color
2555
- }, i === 0 ? `${prefix}已思考 ${seconds}s · Enter 折叠` : prefix, line || " "));
2556
- return [React.createElement(Text, {
2557
- key: "summary",
2558
- color,
2559
- dimColor: true
2560
- }, `${prefix}已思考 ${seconds}s · Enter 展开`)];
2561
- }
2562
- /**
2563
- * Render text with inline markdown formatting.
2564
- *
2565
- * Wraps `renderInline` but handles the case where the input is
2566
- * a mix of plain strings and Ink elements (from search highlighting).
2567
- */
2568
- function renderInlineContent(content) {
2569
- if (typeof content === "string") return renderInline(content);
2570
- return content;
2571
- }
2572
- /**
2573
- * Highlight search query matches in text.
2574
- *
2575
- * When no search is active (matchIndices is empty), returns the plain text.
2576
- * With matches, wraps each match in an inverse‑colored `<Text>` span.
2577
- */
2578
- function highlightMatches(text, matchIndices, query) {
2579
- if (!query || matchIndices.length === 0) return text;
2580
- const len = query.length;
2581
- const sorted = [...new Set(matchIndices)].sort((a, b) => a - b);
2582
- const children = [];
2583
- let cursor = 0;
2584
- for (const start of sorted) {
2585
- if (start < cursor) continue;
2586
- if (start > cursor) children.push(text.slice(cursor, start));
2587
- children.push(React.createElement(Text, {
2588
- key: `hl-${start}`,
2589
- inverse: true
2590
- }, text.slice(start, start + len)));
2591
- cursor = start + len;
2592
- }
2593
- if (cursor < text.length) children.push(text.slice(cursor));
2594
- if (children.length === 1 && typeof children[0] === "string") return children[0];
2595
- return children;
2596
- }
2597
- //#endregion
2598
- //#region src/renderer/frame-limiter.ts
2599
- /**
2600
- * FrameRateLimiter — throttle Ink re‑renders to ~30 FPS via microtask batching.
2601
- *
2602
- * Ink renders on every state change. For high‑frequency updates (streaming
2603
- * text deltas, tool output), this can cause excessive re‑renders.
2604
- * FrameRateLimiter coalesces state updates within a 16 ms window.
2605
- *
2606
- * Design (§5.9i):
2607
- * - 30 fps throttle (~33 ms frame budget, 16 ms coalesce window)
2608
- * - In‑memory transcript cache for static entries
2609
- * - React.memo on static message blocks
2633
+ * Design (§5.9i):
2634
+ * - 30 fps throttle (~33 ms frame budget, 16 ms coalesce window)
2635
+ * - In‑memory transcript cache for static entries
2636
+ * - React.memo on static message blocks
2610
2637
  */
2611
2638
  /** Default target frame interval in ms (30 fps ≈ 33 ms). */
2612
2639
  const DEFAULT_FRAME_MS = 33;
@@ -2676,6 +2703,7 @@ function createFrameRateLimiter(frameMs) {
2676
2703
  * VimStatusBar — 显示当前 Vim 模式和按键提示。
2677
2704
  * 从 InputBox 提取出的独立子组件。
2678
2705
  */
2706
+ /** Displays current Vim mode label with color coding — hidden when vimEnabled is false. */
2679
2707
  function VimStatusBar({ vimEnabled, vimMode }) {
2680
2708
  if (!vimEnabled) return null;
2681
2709
  const label = vimMode === "insert" ? "-- 插入 --" : vimMode === "normal" ? "-- 普通 --" : "-- 可视 --";
@@ -2689,6 +2717,7 @@ function VimStatusBar({ vimEnabled, vimMode }) {
2689
2717
  * 绝对定位在输入框上方,显示匹配的补全选项列表。
2690
2718
  * 从 InputBox 提取出的独立子组件。
2691
2719
  */
2720
+ /** Autocomplete overlay — absolutely positioned above the input box with highlighted options. */
2692
2721
  function SuggestionsOverlay({ completions, completionIndex }) {
2693
2722
  if (completions.length === 0) return null;
2694
2723
  return React.createElement(Box, { flexDirection: "column" }, ...completions.map((item, index) => React.createElement(Box, { key: item }, React.createElement(Text, {
@@ -2703,6 +2732,7 @@ function SuggestionsOverlay({ completions, completionIndex }) {
2703
2732
  * 显示 Vim 模式、输入值预览、禁用状态。
2704
2733
  * 从 InputBox 提取出的独立子组件。
2705
2734
  */
2735
+ /** Input area bottom status line — shows Vim mode, char count, or disabled state. */
2706
2736
  function InputFooter({ vimEnabled, vimMode, value, disabled }) {
2707
2737
  const children = [];
2708
2738
  if (vimEnabled) children.push(React.createElement(Text, {
@@ -2720,27 +2750,40 @@ function InputFooter({ vimEnabled, vimMode, value, disabled }) {
2720
2750
  return React.createElement(Box, {}, ...children);
2721
2751
  }
2722
2752
  //#endregion
2723
- //#region src/components/InputBox.ts
2753
+ //#region src/components/input/input-history.ts
2724
2754
  /**
2725
- * InputBoxmulti‑line REPL input with history, autocomplete, and routing.
2755
+ * Input history persistence load/save to ~/.lynx/input-history.json.
2726
2756
  *
2727
- * Features:
2728
- * - Multi‑line input (Enter submits, Ctrl+Enter inserts newline)
2729
- * - History navigation (Up/Down, 200 items, deduped, persisted to disk)
2730
- * - Slash command autocomplete (8 suggestions max)
2731
- * - @‑mention file path autocomplete (8 suggestions max)
2732
- * - Submit routing: !bang shell, / command, text message
2733
- * - Full cursor movement: Home/End, Ctrl+A/E/K/W, ←/→
2734
- * - 3‑layer abort counter (tracked in parent)
2735
- *
2736
- * Design:
2737
- * Single useInkInput handler manages all keyboard behaviour.
2738
- * Renders last MAX_VISIBLE_LINES of the input with a prompt.
2757
+ * Best-effort I/O: failures are silently swallowed so the REPL
2758
+ * never crashes because of a disk error.
2759
+ */
2760
+ /** Path to the persisted input history file. */
2761
+ const HISTORY_FILE = join(homedir(), ".lynx", "input-history.json");
2762
+ /** Load persisted input history from disk. Returns empty array on any error. */
2763
+ function loadPersistedHistory() {
2764
+ try {
2765
+ if (!existsSync(HISTORY_FILE)) return [];
2766
+ const raw = readFileSync(HISTORY_FILE, "utf-8");
2767
+ const parsed = JSON.parse(raw);
2768
+ if (Array.isArray(parsed) && parsed.every((e) => typeof e === "string")) return parsed.slice(-200);
2769
+ return [];
2770
+ } catch {
2771
+ return [];
2772
+ }
2773
+ }
2774
+ /** Save input history to disk. Does not throw on failure. */
2775
+ function savePersistedHistory(history) {
2776
+ try {
2777
+ const dir = dirname(HISTORY_FILE);
2778
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2779
+ writeFileSync(HISTORY_FILE, JSON.stringify(history.slice(-200)), "utf-8");
2780
+ } catch {}
2781
+ }
2782
+ //#endregion
2783
+ //#region src/components/input/input-completions.ts
2784
+ /**
2785
+ * Input completion matching — slash commands and @‑mention triggers.
2739
2786
  */
2740
- const MAX_HISTORY = 200;
2741
- const MAX_VISIBLE_LINES = 5;
2742
- /** Max autocomplete suggestions displayed. */
2743
- const MAX_COMPLETIONS = 8;
2744
2787
  /** Built‑in slash commands for autocomplete. */
2745
2788
  const SLASH_COMMANDS = [
2746
2789
  "/sessions",
@@ -2766,8 +2809,40 @@ const SLASH_COMMANDS = [
2766
2809
  "/stash",
2767
2810
  "/diff"
2768
2811
  ];
2769
- /** Path to the persisted input history file. */
2770
- const HISTORY_FILE = join(homedir(), ".lynx", "input-history.json");
2812
+ /** Match slash commands against a prefix string. */
2813
+ function matchCompletions(prefix) {
2814
+ if (!prefix.startsWith("/")) return [];
2815
+ const lower = prefix.toLowerCase();
2816
+ return SLASH_COMMANDS.filter((cmd) => cmd.toLowerCase().startsWith(lower)).slice(0, 8);
2817
+ }
2818
+ /**
2819
+ * Find the `@` trigger position preceding the cursor.
2820
+ *
2821
+ * Returns the index of `@` in the value, or -1 if no trigger is active.
2822
+ * The trigger is only valid if `@` appears at the start of a word
2823
+ * (preceded by whitespace or start-of-string).
2824
+ */
2825
+ function findAtTrigger(value, cursor) {
2826
+ for (let i = cursor - 1; i >= 0; i--) {
2827
+ const ch = value[i];
2828
+ if (ch === "@") {
2829
+ if (i === 0 || /\s/.test(value[i - 1])) return i;
2830
+ return -1;
2831
+ }
2832
+ if (/\s/.test(ch)) return -1;
2833
+ }
2834
+ return -1;
2835
+ }
2836
+ //#endregion
2837
+ //#region src/components/input/file-completions.ts
2838
+ /**
2839
+ * @‑mention file path autocomplete helpers.
2840
+ *
2841
+ * Walks the workspace to collect file candidates, then filters
2842
+ * by the trigger text typed after `@`.
2843
+ */
2844
+ /** Max autocomplete suggestions displayed. */
2845
+ const MAX_COMPLETIONS = 8;
2771
2846
  /**
2772
2847
  * Recursively list files in a directory, relative to the workspace root.
2773
2848
  * Returns at most `maxResults` entries. Directories are suffixed with `/`.
@@ -2787,14 +2862,14 @@ function listFilesRecursive(dir, workspace, maxResults) {
2787
2862
  if (results.length >= maxResults) break;
2788
2863
  if (name.startsWith(".") || name === "node_modules") continue;
2789
2864
  const fullPath = join(current, name);
2790
- let stat;
2865
+ let entryStat;
2791
2866
  try {
2792
- stat = statSync(fullPath);
2867
+ entryStat = statSync(fullPath);
2793
2868
  } catch {
2794
2869
  continue;
2795
2870
  }
2796
2871
  const relativePath = relative(workspace, fullPath).replace(/\\/g, "/");
2797
- if (stat.isDirectory()) {
2872
+ if (entryStat.isDirectory()) {
2798
2873
  results.push(relativePath + "/");
2799
2874
  stack.push(fullPath);
2800
2875
  } else results.push(relativePath);
@@ -2810,450 +2885,369 @@ function listFilesRecursive(dir, workspace, maxResults) {
2810
2885
  * up to `MAX_COMPLETIONS` entries.
2811
2886
  */
2812
2887
  function matchFileCompletions(trigger, workspace) {
2813
- if (!trigger && trigger !== "") return [];
2888
+ if (trigger == null) return [];
2814
2889
  const normalized = trigger.replace(/\\/g, "/");
2815
2890
  const lastSlash = normalized.lastIndexOf("/");
2816
2891
  const searchDir = lastSlash >= 0 ? join(workspace, normalized.slice(0, lastSlash)) : workspace;
2817
2892
  const prefix = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
2818
2893
  const allFiles = listFilesRecursive(searchDir, workspace, 200);
2819
2894
  const lowerPrefix = prefix.toLowerCase();
2820
- return allFiles.filter((f) => f.toLowerCase().startsWith(lowerPrefix) || f.toLowerCase().includes(lowerPrefix)).slice(0, MAX_COMPLETIONS).map((f) => "@" + f);
2821
- }
2822
- /** Load persisted input history from disk. Returns empty array on any error. */
2823
- function loadPersistedHistory() {
2824
- try {
2825
- if (!existsSync(HISTORY_FILE)) return [];
2826
- const raw = readFileSync(HISTORY_FILE, "utf-8");
2827
- const parsed = JSON.parse(raw);
2828
- if (Array.isArray(parsed) && parsed.every((e) => typeof e === "string")) return parsed.slice(-200);
2829
- return [];
2830
- } catch {
2831
- return [];
2832
- }
2833
- }
2834
- /** Save input history to disk. */
2835
- function savePersistedHistory(history) {
2836
- try {
2837
- const dir = dirname(HISTORY_FILE);
2838
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2839
- writeFileSync(HISTORY_FILE, JSON.stringify(history.slice(-200)), "utf-8");
2840
- } catch {}
2841
- }
2842
- /**
2843
- * Match slash‑command prefix for autocomplete.
2844
- * Returns matching commands (up to limit) sorted by length.
2845
- */
2846
- function matchCompletions(prefix) {
2847
- if (!prefix.startsWith("/")) return [];
2848
- const lower = prefix.toLowerCase();
2849
- return SLASH_COMMANDS.filter((cmd) => cmd.toLowerCase().startsWith(lower)).sort((a, b) => a.length - b.length).slice(0, MAX_COMPLETIONS);
2850
- }
2851
- /**
2852
- * Find the position of the `@` trigger character preceding the cursor.
2853
- *
2854
- * The trigger is the last `@` that is preceded by a whitespace, start‑of‑string,
2855
- * or another `@`, and is not inside a word boundary (e.g. email addresses).
2856
- * Returns -1 if no valid trigger is found.
2857
- */
2858
- function findAtTrigger(value, cursor) {
2859
- for (let i = cursor - 1; i >= 0; i--) {
2860
- const ch = value[i];
2861
- if (ch === "@") {
2862
- if (i === 0 || /\s/.test(value[i - 1]) || value[i - 1] === "@") return i;
2863
- return -1;
2864
- }
2865
- if (ch === " " || ch === "\n") return -1;
2866
- }
2867
- return -1;
2868
- }
2869
- /**
2870
- * Route the submitted text to determine submission type.
2871
- * ! → shell command
2872
- * / → slash command
2873
- * default → chat message
2874
- */
2875
- function routeInput(text) {
2876
- if (text.startsWith("!")) return "shell";
2877
- if (text.startsWith("/")) return "command";
2878
- return "chat";
2879
- }
2880
- function InputBox({ onSubmit, onAbort, disabled, placeholder = "输入消息...", onStash, appendText, onAppendTextConsumed, workspace = process.cwd(), vimMode: vimEnabled = false, onVimModeChange }) {
2881
- const theme = getTheme();
2882
- const [value, setValue] = useState("");
2883
- const [cursor, setCursor] = useState(0);
2884
- const [completions, setCompletions] = useState([]);
2885
- const [completionIndex, setCompletionIndex] = useState(0);
2886
- /** Cursor position where the active completion trigger began (-1 = no trigger). */
2887
- const [completionStart, setCompletionStart] = useState(-1);
2888
- const [vimMode, setVimMode] = useState("insert");
2889
- const [visualAnchor, setVisualAnchor] = useState(-1);
2890
- const historyRef = useRef(loadPersistedHistory());
2891
- const historyIndexRef = useRef(-1);
2892
- const savedDraftRef = useRef("");
2893
- const burstBufferRef = useRef([]);
2894
- const burstTimerRef = useRef(null);
2895
- const flushBurst = useCallback(() => {
2896
- if (burstTimerRef.current) {
2897
- clearTimeout(burstTimerRef.current);
2898
- burstTimerRef.current = null;
2899
- }
2900
- const lines = burstBufferRef.current;
2901
- burstBufferRef.current = [];
2902
- if (lines.length === 0) return;
2903
- if (lines.length === 1) onSubmit(lines[0]);
2904
- else onSubmit(lines.join("\n"));
2905
- }, [onSubmit]);
2906
- const submitText = useCallback((text) => {
2907
- const hist = historyRef.current;
2908
- if (hist.length === 0 || hist[hist.length - 1] !== text) {
2909
- hist.push(text);
2910
- if (hist.length > MAX_HISTORY) hist.shift();
2911
- }
2912
- historyIndexRef.current = -1;
2913
- if (routeInput(text) === "chat") {
2914
- burstBufferRef.current.push(text);
2915
- if (burstTimerRef.current) clearTimeout(burstTimerRef.current);
2916
- burstTimerRef.current = setTimeout(flushBurst, 50);
2917
- } else {
2918
- flushBurst();
2919
- onSubmit(text);
2920
- }
2921
- setValue("");
2922
- setCursor(0);
2923
- setCompletions([]);
2924
- setCompletionIndex(0);
2925
- setCompletionStart(-1);
2926
- savePersistedHistory(hist);
2927
- }, [onSubmit, flushBurst]);
2928
- function handleSubmit(input, key, ctx) {
2929
- if (key.ctrl && input === "c") {
2930
- onAbort();
2931
- return true;
2932
- }
2933
- if (key.return && key.ctrl) {
2934
- const newValue = ctx.value.slice(0, ctx.cursor) + "\n" + ctx.value.slice(ctx.cursor);
2935
- ctx.setValue(newValue);
2936
- ctx.setCursor(ctx.cursor + 1);
2937
- ctx.setCompletions([]);
2938
- ctx.setCompletionStart(-1);
2939
- return true;
2940
- }
2941
- if (key.return) {
2942
- const trimmed = ctx.value.trim();
2943
- if (trimmed) {
2944
- if (ctx.completions.length > 0 && ctx.completionIndex < ctx.completions.length) {
2945
- const selected = ctx.completions[ctx.completionIndex];
2946
- if (ctx.completionStart >= 0 && ctx.completionStart < ctx.value.length) {
2947
- const before = ctx.value.slice(0, ctx.completionStart);
2948
- const after = ctx.value.slice(ctx.cursor);
2949
- ctx.setValue(before + selected + after);
2950
- ctx.setCursor(ctx.completionStart + selected.length);
2951
- } else {
2952
- ctx.setValue(selected);
2953
- ctx.setCursor(selected.length);
2954
- }
2955
- ctx.setCompletions([]);
2956
- ctx.setCompletionIndex(0);
2957
- ctx.setCompletionStart(-1);
2958
- return true;
2959
- }
2960
- submitText(trimmed);
2961
- }
2962
- return true;
2963
- }
2964
- return false;
2965
- }
2966
- function handleCompletionKeys(_input, key, ctx) {
2967
- if (key.tab && ctx.completions.length > 0) {
2968
- const selected = ctx.completions[ctx.completionIndex];
2969
- if (ctx.completionStart >= 0 && ctx.completionStart < ctx.value.length) {
2970
- const before = ctx.value.slice(0, ctx.completionStart);
2971
- const after = ctx.value.slice(ctx.cursor);
2972
- ctx.setValue(before + selected + after);
2973
- ctx.setCursor(ctx.completionStart + selected.length);
2974
- } else {
2975
- ctx.setValue(selected);
2976
- ctx.setCursor(selected.length);
2977
- }
2978
- ctx.setCompletions([]);
2979
- ctx.setCompletionIndex(0);
2980
- ctx.setCompletionStart(-1);
2981
- return true;
2982
- }
2983
- if (key.escape) {
2984
- ctx.setCompletions([]);
2985
- ctx.setCompletionIndex(0);
2986
- ctx.setCompletionStart(-1);
2987
- return true;
2988
- }
2989
- return false;
2990
- }
2991
- function handleHistoryKeys(key, ctx) {
2992
- if (key.upArrow) {
2993
- if (ctx.completions.length > 0) {
2994
- ctx.setCompletionIndex(Math.max(0, ctx.completionIndex - 1));
2995
- return true;
2996
- }
2997
- const hist = ctx.historyRef.current;
2998
- if (hist.length === 0) return true;
2999
- if (ctx.historyIndexRef.current === -1) ctx.savedDraftRef.current = ctx.value;
3000
- const nextIdx = ctx.historyIndexRef.current + 1;
3001
- if (nextIdx < hist.length) {
3002
- ctx.historyIndexRef.current = nextIdx;
3003
- const entry = hist[hist.length - 1 - nextIdx];
3004
- ctx.setValue(entry);
3005
- ctx.setCursor(entry.length);
3006
- }
3007
- return true;
3008
- }
3009
- if (key.downArrow) {
3010
- if (ctx.completions.length > 0) {
3011
- ctx.setCompletionIndex(Math.min(ctx.completions.length - 1, ctx.completionIndex + 1));
2895
+ return allFiles.filter((f) => f.toLowerCase().startsWith(lowerPrefix) || f.toLowerCase().includes(lowerPrefix)).slice(0, MAX_COMPLETIONS).map((f) => "@" + f);
2896
+ }
2897
+ //#endregion
2898
+ //#region src/components/input/input-keyboard.ts
2899
+ /** Handle Enter / Ctrl+C / Ctrl+Enter. Returns true if consumed. */
2900
+ function handleSubmit(input, key, ctx, actions) {
2901
+ if (key.ctrl && input === "c") {
2902
+ actions.onAbort();
2903
+ return true;
2904
+ }
2905
+ if (key.return && key.ctrl) {
2906
+ const newValue = ctx.value.slice(0, ctx.cursor) + "\n" + ctx.value.slice(ctx.cursor);
2907
+ ctx.setValue(newValue);
2908
+ ctx.setCursor(ctx.cursor + 1);
2909
+ ctx.setCompletions([]);
2910
+ ctx.setCompletionStart(-1);
2911
+ return true;
2912
+ }
2913
+ if (key.return) {
2914
+ const trimmed = ctx.value.trim();
2915
+ if (trimmed) {
2916
+ if (ctx.completions.length > 0 && ctx.completionIndex < ctx.completions.length) {
2917
+ const selected = ctx.completions[ctx.completionIndex];
2918
+ if (ctx.completionStart >= 0 && ctx.completionStart < ctx.value.length) {
2919
+ const before = ctx.value.slice(0, ctx.completionStart);
2920
+ const after = ctx.value.slice(ctx.cursor);
2921
+ ctx.setValue(before + selected + after);
2922
+ ctx.setCursor(ctx.completionStart + selected.length);
2923
+ } else {
2924
+ ctx.setValue(selected);
2925
+ ctx.setCursor(selected.length);
2926
+ }
2927
+ ctx.setCompletions([]);
2928
+ ctx.setCompletionIndex(0);
2929
+ ctx.setCompletionStart(-1);
3012
2930
  return true;
3013
2931
  }
3014
- if (ctx.historyIndexRef.current === -1) return true;
3015
- const nextIdx = ctx.historyIndexRef.current - 1;
3016
- if (nextIdx >= 0) {
3017
- ctx.historyIndexRef.current = nextIdx;
3018
- const entry = ctx.historyRef.current[ctx.historyRef.current.length - 1 - nextIdx];
3019
- ctx.setValue(entry);
3020
- ctx.setCursor(entry.length);
3021
- } else {
3022
- ctx.historyIndexRef.current = -1;
3023
- ctx.setValue(ctx.savedDraftRef.current);
3024
- ctx.setCursor(ctx.savedDraftRef.current.length);
3025
- }
3026
- return true;
2932
+ actions.submitText(trimmed);
3027
2933
  }
3028
- return false;
2934
+ return true;
3029
2935
  }
3030
- function handleNavigationKeys(input, key, ctx) {
3031
- if (handleHistoryKeys(key, ctx)) return true;
3032
- if (key.leftArrow) {
3033
- ctx.setCursor(Math.max(0, ctx.cursor - 1));
3034
- return true;
3035
- }
3036
- if (key.rightArrow) {
3037
- ctx.setCursor(Math.min(ctx.value.length, ctx.cursor + 1));
3038
- return true;
2936
+ return false;
2937
+ }
2938
+ /** Handle Tab / Shift+Tab / Escape for completions. */
2939
+ function handleCompletionKeys(_input, key, ctx) {
2940
+ if (key.tab && key.shift && ctx.completions.length > 0) {
2941
+ ctx.setCompletionIndex((ctx.completionIndex - 1 + ctx.completions.length) % ctx.completions.length);
2942
+ return true;
2943
+ }
2944
+ if (key.tab && ctx.completions.length > 0) {
2945
+ const selected = ctx.completions[ctx.completionIndex];
2946
+ if (ctx.completionStart >= 0 && ctx.completionStart < ctx.value.length) {
2947
+ const before = ctx.value.slice(0, ctx.completionStart);
2948
+ const after = ctx.value.slice(ctx.cursor);
2949
+ ctx.setValue(before + selected + after);
2950
+ ctx.setCursor(ctx.completionStart + selected.length);
2951
+ } else {
2952
+ ctx.setValue(selected);
2953
+ ctx.setCursor(selected.length);
3039
2954
  }
3040
- if (key.home || key.ctrl && input === "a") {
3041
- ctx.setCursor(0);
2955
+ ctx.setCompletions([]);
2956
+ ctx.setCompletionIndex(0);
2957
+ ctx.setCompletionStart(-1);
2958
+ return true;
2959
+ }
2960
+ if (key.escape) {
2961
+ ctx.setCompletions([]);
2962
+ ctx.setCompletionIndex(0);
2963
+ ctx.setCompletionStart(-1);
2964
+ return true;
2965
+ }
2966
+ return false;
2967
+ }
2968
+ /** Handle Up/Down arrow for history and completion navigation. */
2969
+ function handleHistoryKeys(key, ctx) {
2970
+ if (key.upArrow) {
2971
+ if (ctx.completions.length > 0) {
2972
+ ctx.setCompletionIndex(Math.max(0, ctx.completionIndex - 1));
3042
2973
  return true;
3043
2974
  }
3044
- if (key.end || key.ctrl && input === "e") {
3045
- ctx.setCursor(ctx.value.length);
3046
- return true;
2975
+ const hist = ctx.historyRef.current;
2976
+ if (hist.length === 0) return true;
2977
+ if (ctx.historyIndexRef.current === -1) ctx.savedDraftRef.current = ctx.value;
2978
+ const nextIdx = ctx.historyIndexRef.current + 1;
2979
+ if (nextIdx < hist.length) {
2980
+ ctx.historyIndexRef.current = nextIdx;
2981
+ const entry = hist[hist.length - 1 - nextIdx];
2982
+ ctx.setValue(entry);
2983
+ ctx.setCursor(entry.length);
3047
2984
  }
3048
- return false;
2985
+ return true;
3049
2986
  }
3050
- function handleEditKeys(input, key, ctx) {
3051
- if (key.ctrl && input === "w") {
3052
- ctx.setValue((prev) => {
3053
- const before = prev.slice(0, ctx.cursor);
3054
- const after = prev.slice(ctx.cursor);
3055
- const wordEnd = before.replace(/\s*\S+$/, "");
3056
- const deleted = before.length - wordEnd.length;
3057
- ctx.setCursor((c) => c - deleted);
3058
- return wordEnd + after;
3059
- });
2987
+ if (key.downArrow) {
2988
+ if (ctx.completions.length > 0) {
2989
+ ctx.setCompletionIndex(Math.min(ctx.completions.length - 1, ctx.completionIndex + 1));
3060
2990
  return true;
3061
2991
  }
3062
- if (key.ctrl && input === "k") {
3063
- ctx.setValue((prev) => prev.slice(0, ctx.cursor));
3064
- return true;
2992
+ if (ctx.historyIndexRef.current > 0) {
2993
+ ctx.historyIndexRef.current--;
2994
+ const hist = ctx.historyRef.current;
2995
+ const entry = hist[hist.length - 1 - ctx.historyIndexRef.current];
2996
+ ctx.setValue(entry);
2997
+ ctx.setCursor(entry.length);
2998
+ } else if (ctx.historyIndexRef.current === 0) {
2999
+ ctx.historyIndexRef.current = -1;
3000
+ ctx.setValue(ctx.savedDraftRef.current);
3001
+ ctx.setCursor(ctx.savedDraftRef.current.length);
3065
3002
  }
3066
- if (key.ctrl && input === "u") {
3067
- ctx.setValue((prev) => prev.slice(ctx.cursor));
3068
- ctx.setCursor(0);
3069
- return true;
3003
+ return true;
3004
+ }
3005
+ return false;
3006
+ }
3007
+ /** Handle Left/Right arrows, Home/End, Ctrl+A/E. */
3008
+ function handleNavigationKeys(input, key, ctx) {
3009
+ if (handleHistoryKeys(key, ctx)) return true;
3010
+ if (key.leftArrow) {
3011
+ ctx.setCursor(Math.max(0, ctx.cursor - 1));
3012
+ return true;
3013
+ }
3014
+ if (key.rightArrow) {
3015
+ ctx.setCursor(Math.min(ctx.value.length, ctx.cursor + 1));
3016
+ return true;
3017
+ }
3018
+ if (key.home || key.ctrl && input === "a") {
3019
+ ctx.setCursor(0);
3020
+ return true;
3021
+ }
3022
+ if (key.end || key.ctrl && input === "e") {
3023
+ ctx.setCursor(ctx.value.length);
3024
+ return true;
3025
+ }
3026
+ return false;
3027
+ }
3028
+ /** Handle Ctrl+W/K/U and Backspace/Delete. */
3029
+ function handleEditKeys(_input, key, ctx) {
3030
+ if (key.ctrl && _input === "w") {
3031
+ ctx.setValue((prev) => {
3032
+ const before = prev.slice(0, ctx.cursor);
3033
+ const after = prev.slice(ctx.cursor);
3034
+ const wordEnd = before.replace(/\s*\S+$/, "");
3035
+ const deleted = before.length - wordEnd.length;
3036
+ ctx.setCursor((c) => c - deleted);
3037
+ return wordEnd + after;
3038
+ });
3039
+ return true;
3040
+ }
3041
+ if (key.ctrl && _input === "k") {
3042
+ ctx.setValue((prev) => prev.slice(0, ctx.cursor));
3043
+ return true;
3044
+ }
3045
+ if (key.ctrl && _input === "u") {
3046
+ ctx.setValue((prev) => prev.slice(ctx.cursor));
3047
+ ctx.setCursor(0);
3048
+ return true;
3049
+ }
3050
+ if (key.backspace || key.delete) {
3051
+ if (ctx.cursor > 0) {
3052
+ ctx.setValue((prev) => prev.slice(0, ctx.cursor - 1) + prev.slice(ctx.cursor));
3053
+ ctx.setCursor((c) => c - 1);
3070
3054
  }
3071
- if (key.backspace || key.delete) {
3072
- if (ctx.cursor > 0) {
3073
- ctx.setValue((prev) => prev.slice(0, ctx.cursor - 1) + prev.slice(ctx.cursor));
3074
- ctx.setCursor((c) => c - 1);
3075
- }
3055
+ return true;
3056
+ }
3057
+ return false;
3058
+ }
3059
+ /** Handle printable text input and trigger completions. */
3060
+ function handleTextInput(input, key, ctx, workspace) {
3061
+ if (input && !key.ctrl && !key.meta && input.length > 0) {
3062
+ if (/^[\x00-\x08\x0b\x0c\x0e-\x1f]$/.test(input)) return false;
3063
+ ctx.setValue((prev) => prev.slice(0, ctx.cursor) + input + prev.slice(ctx.cursor));
3064
+ ctx.setCursor((c) => c + input.length);
3065
+ const newVal = ctx.value.slice(0, ctx.cursor) + input + ctx.value.slice(ctx.cursor);
3066
+ const newCursor = ctx.cursor + input.length;
3067
+ if (newVal.startsWith("/")) {
3068
+ const matches = matchCompletions(newVal);
3069
+ ctx.setCompletions(matches);
3070
+ ctx.setCompletionIndex(0);
3071
+ ctx.setCompletionStart(0);
3076
3072
  return true;
3077
3073
  }
3078
- return false;
3079
- }
3080
- function handleTextInput(input, key, ctx) {
3081
- if (input && !key.ctrl && !key.meta && input.length > 0) {
3082
- if (/^[\x00-\x08\x0b\x0c\x0e-\x1f]$/.test(input)) return false;
3083
- ctx.setValue((prev) => prev.slice(0, ctx.cursor) + input + prev.slice(ctx.cursor));
3084
- ctx.setCursor((c) => c + input.length);
3085
- const newVal = ctx.value.slice(0, ctx.cursor) + input + ctx.value.slice(ctx.cursor);
3086
- const newCursor = ctx.cursor + input.length;
3087
- if (newVal.startsWith("/")) {
3088
- const matches = matchCompletions(newVal);
3074
+ const atIdx = findAtTrigger(newVal, newCursor);
3075
+ if (atIdx >= 0) {
3076
+ const trigger = newVal.slice(atIdx + 1, newCursor);
3077
+ if (trigger !== void 0) {
3078
+ const matches = matchFileCompletions(trigger, workspace);
3089
3079
  ctx.setCompletions(matches);
3090
3080
  ctx.setCompletionIndex(0);
3091
- ctx.setCompletionStart(0);
3081
+ ctx.setCompletionStart(atIdx);
3092
3082
  return true;
3093
3083
  }
3094
- const atIdx = findAtTrigger(newVal, newCursor);
3095
- if (atIdx >= 0) {
3096
- const trigger = newVal.slice(atIdx + 1, newCursor);
3097
- if (trigger !== void 0) {
3098
- const matches = matchFileCompletions(trigger, workspace);
3099
- ctx.setCompletions(matches);
3100
- ctx.setCompletionIndex(0);
3101
- ctx.setCompletionStart(atIdx);
3102
- return true;
3103
- }
3104
- }
3105
- ctx.setCompletions([]);
3106
- ctx.setCompletionStart(-1);
3107
- return true;
3108
3084
  }
3109
- return false;
3085
+ ctx.setCompletions([]);
3086
+ ctx.setCompletionStart(-1);
3087
+ return true;
3110
3088
  }
3111
- useEffect(() => {
3112
- if (appendText && appendText.length > 0) {
3113
- setValue((prev) => prev.slice(0, cursor) + appendText + prev.slice(cursor));
3114
- setCursor((c) => c + appendText.length);
3115
- onAppendTextConsumed?.();
3116
- }
3117
- }, [appendText]);
3118
- /** Handle keys in vim NORMAL mode. Returns true if consumed. */
3119
- const handleVimNormal = useCallback((input, key) => {
3089
+ return false;
3090
+ }
3091
+ //#endregion
3092
+ //#region src/components/input/useVimInput.ts
3093
+ /**
3094
+ * useVimInput — Vim-style editing hooks for the multi‑line input.
3095
+ *
3096
+ * Provides normal‑mode navigation (hjkl/w/b/0/$/i/a/I/A/o/O/x/dd/v)
3097
+ * and visual‑mode selection (hjkl/y/d) callbacks.
3098
+ */
3099
+ /**
3100
+ * Create a NORMAL‑mode key handler.
3101
+ *
3102
+ * Returns a function `(input: string, key: Key) => boolean` that the parent
3103
+ * calls within its useInkInput dispatch when `vimMode === "normal"`.
3104
+ */
3105
+ function useVimNormalHandler(value, cursor, actions) {
3106
+ return useCallback((input, key) => {
3120
3107
  if (input === "h") {
3121
- setCursor((c) => Math.max(0, c - 1));
3108
+ actions.setCursor((c) => Math.max(0, c - 1));
3122
3109
  return true;
3123
3110
  }
3124
3111
  if (input === "l") {
3125
- setCursor((c) => Math.min(value.length, c + 1));
3112
+ actions.setCursor((c) => Math.min(value.length, c + 1));
3126
3113
  return true;
3127
3114
  }
3128
3115
  if (input === "k") {
3129
3116
  const before = value.slice(0, cursor);
3130
3117
  const prevNewline = before.lastIndexOf("\n");
3131
3118
  if (prevNewline === -1) {
3132
- setCursor(0);
3119
+ actions.setCursor(0);
3133
3120
  return true;
3134
3121
  }
3135
3122
  const prevPrevNewline = before.lastIndexOf("\n", prevNewline - 1);
3136
3123
  const col = before.length - prevNewline - 1;
3137
3124
  const targetCol = Math.min(col, prevNewline - (prevPrevNewline + 1));
3138
- setCursor((prevPrevNewline === -1 ? 0 : prevPrevNewline + 1) + targetCol);
3125
+ actions.setCursor((prevPrevNewline === -1 ? 0 : prevPrevNewline + 1) + targetCol);
3139
3126
  return true;
3140
3127
  }
3141
3128
  if (input === "j") {
3142
3129
  const after = value.slice(cursor);
3143
3130
  const nextNewline = after.indexOf("\n");
3144
3131
  if (nextNewline === -1) {
3145
- setCursor(value.length);
3132
+ actions.setCursor(value.length);
3146
3133
  return true;
3147
3134
  }
3148
3135
  const col = cursor - (value.slice(0, cursor).lastIndexOf("\n") + 1);
3149
3136
  const afterNext = after.indexOf("\n", nextNewline + 1);
3150
3137
  const lineLen = afterNext === -1 ? after.length - nextNewline - 1 : afterNext - nextNewline - 1;
3151
- setCursor(cursor + nextNewline + 1 + Math.min(col, lineLen));
3138
+ actions.setCursor(cursor + nextNewline + 1 + Math.min(col, lineLen));
3152
3139
  return true;
3153
3140
  }
3154
3141
  if (input === "i") {
3155
- setVimMode("insert");
3156
- onVimModeChange?.("insert");
3142
+ actions.setVimMode("insert");
3143
+ actions.onVimModeChange?.("insert");
3157
3144
  return true;
3158
3145
  }
3159
3146
  if (input === "a") {
3160
- setCursor((c) => Math.min(value.length, c + 1));
3161
- setVimMode("insert");
3162
- onVimModeChange?.("insert");
3147
+ actions.setCursor((c) => Math.min(value.length, c + 1));
3148
+ actions.setVimMode("insert");
3149
+ actions.onVimModeChange?.("insert");
3163
3150
  return true;
3164
3151
  }
3165
3152
  if (input === "I") {
3166
- setCursor(value.slice(0, cursor).lastIndexOf("\n") + 1);
3167
- setVimMode("insert");
3168
- onVimModeChange?.("insert");
3153
+ const lineStart = value.slice(0, cursor).lastIndexOf("\n") + 1;
3154
+ actions.setCursor(lineStart);
3155
+ actions.setVimMode("insert");
3156
+ actions.onVimModeChange?.("insert");
3169
3157
  return true;
3170
3158
  }
3171
3159
  if (input === "A") {
3172
3160
  const after = value.slice(cursor);
3173
3161
  const nextNewline = after.indexOf("\n");
3174
- setCursor(cursor + (nextNewline === -1 ? after.length : nextNewline));
3175
- setVimMode("insert");
3176
- onVimModeChange?.("insert");
3162
+ actions.setCursor(cursor + (nextNewline === -1 ? after.length : nextNewline));
3163
+ actions.setVimMode("insert");
3164
+ actions.onVimModeChange?.("insert");
3177
3165
  return true;
3178
3166
  }
3179
3167
  if (input === "o") {
3180
3168
  const after = value.slice(cursor);
3181
3169
  const nextNewline = after.indexOf("\n");
3182
3170
  const lineEnd = cursor + (nextNewline === -1 ? after.length : nextNewline);
3183
- setValue((v) => v.slice(0, lineEnd) + "\n" + v.slice(lineEnd));
3184
- setCursor(lineEnd + 1);
3185
- setVimMode("insert");
3186
- onVimModeChange?.("insert");
3171
+ actions.setValue((v) => v.slice(0, lineEnd) + "\n" + v.slice(lineEnd));
3172
+ actions.setCursor(lineEnd + 1);
3173
+ actions.setVimMode("insert");
3174
+ actions.onVimModeChange?.("insert");
3187
3175
  return true;
3188
3176
  }
3189
3177
  if (input === "O") {
3190
3178
  const lineStart = value.slice(0, cursor).lastIndexOf("\n") + 1;
3191
- setValue((v) => v.slice(0, lineStart) + "\n" + v.slice(lineStart));
3192
- setCursor(lineStart);
3193
- setVimMode("insert");
3194
- onVimModeChange?.("insert");
3179
+ actions.setValue((v) => v.slice(0, lineStart) + "\n" + v.slice(lineStart));
3180
+ actions.setCursor(lineStart);
3181
+ actions.setVimMode("insert");
3182
+ actions.onVimModeChange?.("insert");
3195
3183
  return true;
3196
3184
  }
3197
3185
  if (input === "x") {
3198
- setValue((v) => v.slice(0, cursor) + v.slice(cursor + 1));
3199
- if (cursor >= value.length) setCursor((c) => Math.max(0, c - 1));
3186
+ actions.setValue((v) => v.slice(0, cursor) + v.slice(cursor + 1));
3187
+ if (cursor >= value.length) actions.setCursor((c) => Math.max(0, c - 1));
3200
3188
  return true;
3201
3189
  }
3202
3190
  if (input === "d" && !key.ctrl) return true;
3203
3191
  if (input === "v") {
3204
- setVisualAnchor(cursor);
3205
- setVimMode("visual");
3206
- onVimModeChange?.("visual");
3192
+ actions.setVisualAnchor(cursor);
3193
+ actions.setVimMode("visual");
3194
+ actions.onVimModeChange?.("visual");
3207
3195
  return true;
3208
3196
  }
3209
3197
  if (input === "w") {
3210
3198
  const match = value.slice(cursor).match(/[^\s\w]*\w+/);
3211
- if (match) setCursor(cursor + (match.index ?? 0) + match[0].length);
3212
- else setCursor(value.length);
3199
+ if (match) actions.setCursor(cursor + (match.index ?? 0) + match[0].length);
3200
+ else actions.setCursor(value.length);
3213
3201
  return true;
3214
3202
  }
3215
3203
  if (input === "b") {
3216
3204
  const before = value.slice(0, cursor);
3217
3205
  const match = before.match(/(\w+)\W*$/);
3218
- if (match) setCursor(cursor - match[0].length + match[1].length);
3219
- else setCursor(before.lastIndexOf(" ") + 1);
3206
+ if (match) actions.setCursor(cursor - match[0].length + match[1].length);
3207
+ else actions.setCursor(before.lastIndexOf(" ") + 1);
3220
3208
  return true;
3221
3209
  }
3222
3210
  if (input === "0") {
3223
- setCursor((c) => c - (c - value.slice(0, c).lastIndexOf("\n") - 1));
3211
+ actions.setCursor((c) => c - (c - value.slice(0, c).lastIndexOf("\n") - 1));
3224
3212
  return true;
3225
3213
  }
3226
- if (key.ctrl && input === "e") return false;
3227
3214
  return false;
3228
3215
  }, [
3229
3216
  value,
3230
3217
  cursor,
3231
- onVimModeChange
3218
+ actions
3232
3219
  ]);
3233
- /** Handle keys in vim VISUAL mode. Returns true if consumed. */
3234
- const handleVimVisual = useCallback((input, key) => {
3220
+ }
3221
+ /**
3222
+ * Create a VISUAL‑mode key handler.
3223
+ *
3224
+ * Returns a function `(input: string, key: Key) => boolean` for use
3225
+ * when `vimMode === "visual"`.
3226
+ */
3227
+ function useVimVisualHandler(value, cursor, visualAnchor, actions) {
3228
+ return useCallback((input, key) => {
3235
3229
  if (key.escape) {
3236
- setVimMode("normal");
3237
- onVimModeChange?.("normal");
3238
- setVisualAnchor(-1);
3239
- if (visualAnchor >= 0 && visualAnchor < cursor) setCursor(visualAnchor);
3230
+ actions.setVimMode("normal");
3231
+ actions.onVimModeChange?.("normal");
3232
+ actions.setVisualAnchor(-1);
3233
+ if (visualAnchor >= 0 && visualAnchor < cursor) actions.setCursor(visualAnchor);
3240
3234
  return true;
3241
3235
  }
3242
3236
  if (input === "h") {
3243
- setCursor((c) => Math.max(0, c - 1));
3237
+ actions.setCursor((c) => Math.max(0, c - 1));
3244
3238
  return true;
3245
3239
  }
3246
3240
  if (input === "l") {
3247
- setCursor((c) => Math.min(value.length, c + 1));
3241
+ actions.setCursor((c) => Math.min(value.length, c + 1));
3248
3242
  return true;
3249
3243
  }
3250
3244
  if (input === "j") {
3251
3245
  const nextNewline = value.slice(cursor).indexOf("\n");
3252
3246
  if (nextNewline === -1) {
3253
- setCursor(value.length);
3247
+ actions.setCursor(value.length);
3254
3248
  return true;
3255
3249
  }
3256
- setCursor(cursor + nextNewline + 1);
3250
+ actions.setCursor(cursor + nextNewline + 1);
3257
3251
  return true;
3258
3252
  }
3259
3253
  if (input === "k") {
@@ -3261,27 +3255,24 @@ function InputBox({ onSubmit, onAbort, disabled, placeholder = "输入消息..."
3261
3255
  const prevNewline = before.lastIndexOf("\n");
3262
3256
  if (prevNewline !== -1) {
3263
3257
  const prevPrev = before.lastIndexOf("\n", prevNewline - 1);
3264
- setCursor(prevPrev === -1 ? 0 : prevPrev + 1);
3258
+ actions.setCursor(prevPrev === -1 ? 0 : prevPrev + 1);
3265
3259
  }
3266
3260
  return true;
3267
3261
  }
3268
3262
  if (input === "y") {
3269
- const start = Math.min(visualAnchor, cursor);
3270
- const end = Math.max(visualAnchor, cursor);
3271
- value.slice(start, end);
3272
- setVimMode("normal");
3273
- onVimModeChange?.("normal");
3274
- setVisualAnchor(-1);
3263
+ actions.setVimMode("normal");
3264
+ actions.onVimModeChange?.("normal");
3265
+ actions.setVisualAnchor(-1);
3275
3266
  return true;
3276
3267
  }
3277
3268
  if (input === "d") {
3278
3269
  const start = Math.min(visualAnchor, cursor);
3279
3270
  const end = Math.max(visualAnchor, cursor);
3280
- setValue((v) => v.slice(0, start) + v.slice(end));
3281
- setCursor(start);
3282
- setVimMode("normal");
3283
- onVimModeChange?.("normal");
3284
- setVisualAnchor(-1);
3271
+ actions.setValue((v) => v.slice(0, start) + v.slice(end));
3272
+ actions.setCursor(start);
3273
+ actions.setVimMode("normal");
3274
+ actions.onVimModeChange?.("normal");
3275
+ actions.setVisualAnchor(-1);
3285
3276
  return true;
3286
3277
  }
3287
3278
  return false;
@@ -3289,8 +3280,91 @@ function InputBox({ onSubmit, onAbort, disabled, placeholder = "输入消息..."
3289
3280
  value,
3290
3281
  cursor,
3291
3282
  visualAnchor,
3292
- onVimModeChange
3283
+ actions
3293
3284
  ]);
3285
+ }
3286
+ //#endregion
3287
+ //#region src/components/InputBox.ts
3288
+ /**
3289
+ * InputBox — multi‑line REPL input with history, autocomplete, and routing.
3290
+ *
3291
+ * Features:
3292
+ * - Multi‑line input (Enter submits, Ctrl+Enter inserts newline)
3293
+ * - History navigation (Up/Down, 200 items, deduped, persisted to disk)
3294
+ * - Slash command autocomplete (8 suggestions max)
3295
+ * - @‑mention file path autocomplete (8 suggestions max)
3296
+ * - Submit routing: !bang → shell, / → command, text → message
3297
+ * - Full cursor movement: Home/End, Ctrl+A/E/K/W, ←/→
3298
+ * - 3‑layer abort counter (tracked in parent)
3299
+ *
3300
+ * Keyboard handlers extracted to input/input-keyboard.ts.
3301
+ * File completion extracted to input/file-completions.ts.
3302
+ * History persistence extracted to input/input-history.ts.
3303
+ * Vim hooks extracted to input/useVimInput.ts.
3304
+ */
3305
+ const MAX_VISIBLE_LINES = 5;
3306
+ /** Multi-line REPL input — handles submit routing, history, completions, and Vim editing mode. */
3307
+ function InputBox({ onSubmit, onAbort, disabled, placeholder = "输入消息...", onStash, appendText, onAppendTextConsumed, workspace = process.cwd(), vimEnabled = false, vimMode: initialVimMode = "insert", onVimModeChange }) {
3308
+ const theme = getTheme();
3309
+ const [value, setValue] = useState("");
3310
+ const [cursor, setCursor] = useState(0);
3311
+ const [completions, setCompletions] = useState([]);
3312
+ const [completionIndex, setCompletionIndex] = useState(0);
3313
+ /** Cursor position where the active completion trigger began (-1 = no trigger). */
3314
+ const [completionStart, setCompletionStart] = useState(-1);
3315
+ const [vimMode, setVimMode] = useState(initialVimMode);
3316
+ const [visualAnchor, setVisualAnchor] = useState(-1);
3317
+ const historyRef = useRef(loadPersistedHistory());
3318
+ const historyIndexRef = useRef(-1);
3319
+ const savedDraftRef = useRef("");
3320
+ const burstBufferRef = useRef([]);
3321
+ const burstTimerRef = useRef(null);
3322
+ useCallback(() => {
3323
+ if (burstTimerRef.current) {
3324
+ clearTimeout(burstTimerRef.current);
3325
+ burstTimerRef.current = null;
3326
+ }
3327
+ const lines = burstBufferRef.current;
3328
+ burstBufferRef.current = [];
3329
+ if (lines.length === 0) return;
3330
+ if (lines.length === 1) onSubmit(lines[0]);
3331
+ else onSubmit(lines.join("\n"));
3332
+ }, [onSubmit]);
3333
+ const submitText = useCallback((text) => {
3334
+ const hist = historyRef.current;
3335
+ if (hist.length === 0 || hist[hist.length - 1] !== text) {
3336
+ hist.push(text);
3337
+ if (hist.length > 200) hist.shift();
3338
+ }
3339
+ historyIndexRef.current = -1;
3340
+ onSubmit(text);
3341
+ setValue("");
3342
+ setCursor(0);
3343
+ setCompletions([]);
3344
+ setCompletionIndex(0);
3345
+ setCompletionStart(-1);
3346
+ savePersistedHistory(hist);
3347
+ }, [onSubmit]);
3348
+ const vimActions = {
3349
+ setValue,
3350
+ setCursor,
3351
+ setVimMode,
3352
+ setVisualAnchor,
3353
+ onVimModeChange
3354
+ };
3355
+ const handleVimNormal = useVimNormalHandler(value, cursor, vimActions);
3356
+ const handleVimVisual = useVimVisualHandler(value, cursor, visualAnchor, vimActions);
3357
+ useEffect(() => {
3358
+ if (appendText && appendText.length > 0) {
3359
+ setValue((prev) => prev.slice(0, cursor) + appendText + prev.slice(cursor));
3360
+ setCursor((c) => c + appendText.length);
3361
+ onAppendTextConsumed?.();
3362
+ }
3363
+ }, [appendText]);
3364
+ const keyboardActions = {
3365
+ submitText,
3366
+ onAbort
3367
+ };
3294
3368
  useInput$1((input, key) => {
3295
3369
  if (disabled) return;
3296
3370
  if (input.includes("\x1B[<") || /^\[<\d+;\d+;\d+[Mm]/.test(input.trim())) return;
@@ -3343,11 +3417,11 @@ function InputBox({ onSubmit, onAbort, disabled, placeholder = "输入消息..."
3343
3417
  historyIndexRef,
3344
3418
  savedDraftRef
3345
3419
  };
3346
- if (handleSubmit(input, key, ctx)) return;
3420
+ if (handleSubmit(input, key, ctx, keyboardActions)) return;
3347
3421
  if (handleCompletionKeys(input, key, ctx)) return;
3348
3422
  if (handleNavigationKeys(input, key, ctx)) return;
3349
3423
  if (handleEditKeys(input, key, ctx)) return;
3350
- handleTextInput(input, key, ctx);
3424
+ handleTextInput(input, key, ctx, workspace);
3351
3425
  });
3352
3426
  const lines = value.split("\n");
3353
3427
  const visibleLines = lines.slice(-5);
@@ -3393,9 +3467,6 @@ function InputBox({ onSubmit, onAbort, disabled, placeholder = "输入消息..."
3393
3467
  /**
3394
3468
  * Get the cursor offset within a specific line of multi‑line text.
3395
3469
  */
3396
- /**
3397
- * Get the cursor offset within a specific line of multi‑line text.
3398
- */
3399
3470
  function getCursorOffset(text, lineIndex) {
3400
3471
  const lines = text.split("\n");
3401
3472
  let offset = 0;
@@ -3413,11 +3484,17 @@ function renderWithCursor(line, cur, _theme) {
3413
3484
  //#endregion
3414
3485
  //#region src/components/StatusBar.ts
3415
3486
  /**
3416
- * StatusBar — bottom‑of‑screen status line.
3487
+ * StatusBar — bottom status line with three‑section layout.
3417
3488
  *
3418
- * Shows: permission mode, current model, streaming indicator,
3419
- * token count with estimated cost, and the active view name.
3420
- * Compact single‑line layout with budget color coding.
3489
+ * Aligned to Claude Code's StatusLine:
3490
+ * - Left: mode (insert/normal/abort) + current view
3491
+ * - Center: model name + streaming indicator + memory
3492
+ * - Right: tokens used / cost / budget bar + IDE status
3493
+ *
3494
+ * Memory indicator: green (<700MB), yellow (700-900MB),
3495
+ * red (>900MB) — mirrors Claude Code's heap sampling thresholds.
3496
+ *
3497
+ * Design (§5.5a #1.5): three‑section status line with color‑coded metrics.
3421
3498
  */
3422
3499
  /** Format a token count as a human-readable string (e.g. "12.3K"). */
3423
3500
  function formatTokens(tokens) {
@@ -3432,11 +3509,10 @@ function formatCost(cost) {
3432
3509
  return `$${cost.toFixed(4)}`;
3433
3510
  }
3434
3511
  /**
3435
- * Determine the color for the cost display based on budget ratio.
3436
- * - < 50% green
3437
- * - 50–80% yellow
3438
- * - > 80% red
3439
- * - no budget → dimmed
3512
+ * Color‑code the cost display based on budget ratio.
3513
+ * - < 50%: normal (within budget)
3514
+ * - 50–80%: warning (approaching limit)
3515
+ * - > 80%: error (near budget cap)
3440
3516
  */
3441
3517
  function budgetColor(costUsd, maxUsd) {
3442
3518
  if (maxUsd === void 0 || maxUsd <= 0) return void 0;
@@ -3445,24 +3521,71 @@ function budgetColor(costUsd, maxUsd) {
3445
3521
  if (ratio > .5) return "yellow";
3446
3522
  return "green";
3447
3523
  }
3448
- function StatusBar({ mode, model, streaming, viewName, tokensUsed, costUsd, budgetMaxUsd }) {
3524
+ /** Format bytes as human-readable memory size. */
3525
+ function formatMemory(bytes) {
3526
+ const mb = bytes / (1024 * 1024);
3527
+ if (mb >= 1e3) return `${(mb / 1024).toFixed(1)}GB`;
3528
+ return `${Math.round(mb)}MB`;
3529
+ }
3530
+ /**
3531
+ * Memory usage color:
3532
+ * - < 700MB: dim (normal)
3533
+ * - 700–900MB: warning (yellow)
3534
+ * - > 900MB: error (red)
3535
+ */
3536
+ function memoryColor(bytes) {
3537
+ const mb = bytes / (1024 * 1024);
3538
+ if (mb > 900) return "red";
3539
+ if (mb > 700) return "yellow";
3540
+ return "dim";
3541
+ }
3542
+ /** Bottom status line — three-section layout: left (mode+view), center (model+memory), right (tokens+cost+IDE). */
3543
+ function StatusBar({ mode, model, streaming, viewName, tokensUsed, costUsd, budgetMaxUsd, ideConnected }) {
3449
3544
  const theme = getTheme();
3450
- const streamIndicator = streaming ? " ◉" : " ○";
3451
- const parts = [];
3452
- parts.push(`${mode} | ${model}`);
3453
- if (tokensUsed !== void 0 && tokensUsed > 0) parts.push(`| ${formatTokens(tokensUsed)} 令牌`);
3545
+ const streamIcon = streaming ? "◉" : "○";
3546
+ const [heapUsed, setHeapUsed] = useState(() => process.memoryUsage().heapUsed);
3547
+ useEffect(() => {
3548
+ if (!streaming) {
3549
+ setHeapUsed(process.memoryUsage().heapUsed);
3550
+ return;
3551
+ }
3552
+ const id = setInterval(() => {
3553
+ setHeapUsed(process.memoryUsage().heapUsed);
3554
+ }, 5e3);
3555
+ return () => clearInterval(id);
3556
+ }, [streaming]);
3557
+ const memColor = memoryColor(heapUsed);
3558
+ const memText = formatMemory(heapUsed);
3559
+ const ideText = ideConnected ? "IDE" : "";
3560
+ let budgetText = "";
3561
+ let budgetBarColor;
3454
3562
  if (costUsd !== void 0 && costUsd > 0) {
3455
3563
  const color = budgetColor(costUsd, budgetMaxUsd);
3456
- parts.push(React.createElement(React.Fragment, { key: "cost" }, "| ", React.createElement(Text, { color }, formatCost(costUsd))));
3564
+ budgetText = formatCost(costUsd);
3565
+ budgetBarColor = color;
3457
3566
  }
3458
- parts.push(`| ${viewName}${streamIndicator}`);
3459
3567
  return React.createElement(Box, {
3460
3568
  flexDirection: "row",
3461
3569
  justifyContent: "space-between",
3462
3570
  paddingX: 1,
3463
3571
  borderStyle: "single",
3464
3572
  borderColor: theme.colors.border
3465
- }, React.createElement(Text, { dimColor: true }, ...parts.flatMap((p, i) => [i > 0 ? " " : "", p])), React.createElement(Text, { dimColor: true }, "Ctrl+C: 中断"));
3573
+ }, React.createElement(Box, {
3574
+ flexDirection: "row",
3575
+ gap: 1
3576
+ }, React.createElement(Text, { color: theme.colors.accent }, mode), React.createElement(Text, { dimColor: true }, viewName)), React.createElement(Box, {
3577
+ flexDirection: "row",
3578
+ gap: 1
3579
+ }, React.createElement(Text, { dimColor: true }, model), React.createElement(Text, { color: streaming ? theme.colors.accent : void 0 }, streamIcon), React.createElement(Text, {
3580
+ dimColor: true,
3581
+ color: memColor !== "dim" ? memColor : void 0
3582
+ }, memText)), React.createElement(Box, {
3583
+ flexDirection: "row",
3584
+ gap: 1
3585
+ }, tokensUsed && tokensUsed > 0 ? React.createElement(Text, { dimColor: true }, formatTokens(tokensUsed)) : null, budgetText ? React.createElement(Text, { color: budgetBarColor }, budgetText) : null, budgetMaxUsd && budgetMaxUsd > 0 ? React.createElement(Text, { dimColor: true }, `/$${budgetMaxUsd.toFixed(0)}`) : null, ideText ? React.createElement(Text, {
3586
+ dimColor: true,
3587
+ color: theme.colors.accent
3588
+ }, ideText) : null));
3466
3589
  }
3467
3590
  //#endregion
3468
3591
  //#region src/components/NotificationCenter.ts
@@ -3580,8 +3703,6 @@ function NotificationCenter({ current }) {
3580
3703
  * - Tab to amend, Shift+Tab to cycle permission modes
3581
3704
  * - 60s auto‑deny timeout
3582
3705
  */
3583
- /** Timeout in ms before auto‑deny. */
3584
- const AUTO_DENY_MS = 6e4;
3585
3706
  /** Badge styles per safety level. */
3586
3707
  const SAFETY_BADGE = {
3587
3708
  Safe: {
@@ -3611,6 +3732,13 @@ function PermissionRequest({ request, onDecide, onCancel }) {
3611
3732
  const theme = getTheme();
3612
3733
  const badge = SAFETY_BADGE[request.safety] ?? SAFETY_BADGE.RequiresApproval;
3613
3734
  const badgeColor = theme.colors[badge.color] ?? theme.colors.warning;
3735
+ /** Currently highlighted option index (0=Allow, 1=Deny, 2=Always). */
3736
+ const [highlighted, setHighlighted] = React.useState(0);
3737
+ const OPTIONS = [
3738
+ "allow",
3739
+ "deny",
3740
+ "always_allow"
3741
+ ];
3614
3742
  useInput$1(useCallback((input, key) => {
3615
3743
  const char = input.toLowerCase();
3616
3744
  if (char === "a") {
@@ -3625,21 +3753,28 @@ function PermissionRequest({ request, onDecide, onCancel }) {
3625
3753
  onDecide(request.requestId, "always_allow");
3626
3754
  return;
3627
3755
  }
3756
+ if (key.return) {
3757
+ onDecide(request.requestId, OPTIONS[highlighted]);
3758
+ return;
3759
+ }
3760
+ if (key.leftArrow) {
3761
+ setHighlighted((prev) => (prev - 1 + OPTIONS.length) % OPTIONS.length);
3762
+ return;
3763
+ }
3764
+ if (key.rightArrow) {
3765
+ setHighlighted((prev) => (prev + 1) % OPTIONS.length);
3766
+ return;
3767
+ }
3628
3768
  if (key.escape) {
3629
3769
  onCancel();
3630
3770
  return;
3631
3771
  }
3632
3772
  }, [
3633
3773
  request.requestId,
3774
+ highlighted,
3634
3775
  onDecide,
3635
3776
  onCancel
3636
3777
  ]));
3637
- useEffect(() => {
3638
- const timer = setTimeout(() => {
3639
- onDecide(request.requestId, "deny");
3640
- }, AUTO_DENY_MS);
3641
- return () => clearTimeout(timer);
3642
- }, [request.requestId, onDecide]);
3643
3778
  const paramLines = [];
3644
3779
  if (request.parameters) for (const [key, val] of Object.entries(request.parameters)) {
3645
3780
  const str = typeof val === "string" ? val : JSON.stringify(val);
@@ -3668,7 +3803,19 @@ function PermissionRequest({ request, onDecide, onCancel }) {
3668
3803
  flexDirection: "row",
3669
3804
  marginTop: 1,
3670
3805
  gap: 2
3671
- }, React.createElement(Text, { color: theme.colors.success }, "[A]llow "), React.createElement(Text, { color: theme.colors.error }, "[D]eny "), React.createElement(Text, { color: theme.colors.accent }, "[L] Always Allow")), React.createElement(Text, { dimColor: true }, "Arrow keys to select · Tab to amend · Auto‑deny in 60s"));
3806
+ }, React.createElement(Text, highlighted === 0 ? {
3807
+ bold: true,
3808
+ color: theme.colors.success,
3809
+ inverse: true
3810
+ } : { color: theme.colors.success }, " Allow "), React.createElement(Text, highlighted === 1 ? {
3811
+ bold: true,
3812
+ color: theme.colors.error,
3813
+ inverse: true
3814
+ } : { color: theme.colors.error }, " Deny "), React.createElement(Text, highlighted === 2 ? {
3815
+ bold: true,
3816
+ color: theme.colors.accent,
3817
+ inverse: true
3818
+ } : { color: theme.colors.accent }, " Always Allow ")), React.createElement(Text, { dimColor: true }, "← → to select · Enter to confirm · A/D/L shortcuts · Esc to cancel turn"));
3672
3819
  }
3673
3820
  //#endregion
3674
3821
  //#region src/components/Dialog.ts
@@ -3910,11 +4057,6 @@ function SessionPicker({ sessions: allSessions, onSelect, onCancel }) {
3910
4057
  */
3911
4058
  /** Built‑in shortcut reference. */
3912
4059
  const SHORTCUTS = [
3913
- {
3914
- keys: "Enter",
3915
- action: "发送消息",
3916
- category: "对话"
3917
- },
3918
4060
  {
3919
4061
  keys: "Ctrl+C",
3920
4062
  action: "中断(三层)",
@@ -3931,18 +4073,73 @@ const SHORTCUTS = [
3931
4073
  category: "全局"
3932
4074
  },
3933
4075
  {
3934
- keys: "Ctrl+K",
4076
+ keys: "Ctrl+S",
4077
+ action: "暂存当前输入",
4078
+ category: "全局"
4079
+ },
4080
+ {
4081
+ keys: "Ctrl+O",
4082
+ action: "切换时间戳显示",
4083
+ category: "全局"
4084
+ },
4085
+ {
4086
+ keys: "Ctrl+G",
4087
+ action: "外部编辑器",
4088
+ category: "全局"
4089
+ },
4090
+ {
4091
+ keys: "Ctrl+Shift+P",
3935
4092
  action: "命令面板",
3936
4093
  category: "全局"
3937
4094
  },
3938
4095
  {
3939
- keys: "Ctrl+S",
3940
- action: "暂存当前输入",
4096
+ keys: "Ctrl+Shift+F",
4097
+ action: "全局文件搜索",
3941
4098
  category: "全局"
3942
4099
  },
4100
+ {
4101
+ keys: "Enter",
4102
+ action: "发送消息",
4103
+ category: "对话"
4104
+ },
4105
+ {
4106
+ keys: "Ctrl+Enter",
4107
+ action: "插入换行",
4108
+ category: "对话"
4109
+ },
3943
4110
  {
3944
4111
  keys: "Ctrl+F",
3945
- action: "文件选择器",
4112
+ action: "消息内搜索",
4113
+ category: "对话"
4114
+ },
4115
+ {
4116
+ keys: "Ctrl+A/E",
4117
+ action: "跳到行首/行尾",
4118
+ category: "对话"
4119
+ },
4120
+ {
4121
+ keys: "Ctrl+W",
4122
+ action: "删除上一个词",
4123
+ category: "对话"
4124
+ },
4125
+ {
4126
+ keys: "Ctrl+K",
4127
+ action: "删除到行尾",
4128
+ category: "对话"
4129
+ },
4130
+ {
4131
+ keys: "Ctrl+U",
4132
+ action: "删除到行首",
4133
+ category: "对话"
4134
+ },
4135
+ {
4136
+ keys: "Tab",
4137
+ action: "接受自动补全",
4138
+ category: "对话"
4139
+ },
4140
+ {
4141
+ keys: "Up/Down",
4142
+ action: "浏览历史(200 条)",
3946
4143
  category: "对话"
3947
4144
  },
3948
4145
  {
@@ -3956,13 +4153,18 @@ const SHORTCUTS = [
3956
4153
  category: "对话"
3957
4154
  },
3958
4155
  {
3959
- keys: "Up/Down",
3960
- action: "浏览历史(200 条)",
4156
+ keys: "/",
4157
+ action: "斜杠命令",
3961
4158
  category: "对话"
3962
4159
  },
3963
4160
  {
3964
- keys: "Tab",
3965
- action: "接受自动补全",
4161
+ keys: "!",
4162
+ action: "Bang 模式(Shell 命令)",
4163
+ category: "对话"
4164
+ },
4165
+ {
4166
+ keys: "@",
4167
+ action: "文件路径补全",
3966
4168
  category: "对话"
3967
4169
  },
3968
4170
  {
@@ -3972,38 +4174,13 @@ const SHORTCUTS = [
3972
4174
  },
3973
4175
  {
3974
4176
  keys: "A/D/L",
3975
- action: "允许/拒绝/始终允许(权限)",
4177
+ action: "允许/拒绝/始终允许",
3976
4178
  category: "权限"
3977
4179
  },
3978
4180
  {
3979
4181
  keys: "Shift+Tab",
3980
4182
  action: "切换权限模式",
3981
4183
  category: "权限"
3982
- },
3983
- {
3984
- keys: "/",
3985
- action: "斜杠命令 / 技能选择器",
3986
- category: "对话"
3987
- },
3988
- {
3989
- keys: "!",
3990
- action: "Bang 模式(Shell 命令)",
3991
- category: "对话"
3992
- },
3993
- {
3994
- keys: "Ctrl+A/E",
3995
- action: "跳到行首/行尾",
3996
- category: "对话"
3997
- },
3998
- {
3999
- keys: "Ctrl+W",
4000
- action: "删除上一个词",
4001
- category: "对话"
4002
- },
4003
- {
4004
- keys: "Ctrl+K",
4005
- action: "删除到行尾",
4006
- category: "对话"
4007
4184
  }
4008
4185
  ];
4009
4186
  /**
@@ -4824,6 +5001,7 @@ function ConfirmDialog({ title, message, confirmLabel = "是", cancelLabel = "
4824
5001
  * Ctrl+K — delete to end of line
4825
5002
  * Ctrl+W — delete previous word
4826
5003
  */
5004
+ /** Modal input prompt component — renders a single-line text input with cursor navigation and keyboard shortcuts. */
4827
5005
  function InputPrompt({ title, initialValue = "", placeholder = "", onConfirm, onCancel, maxLength = 100 }) {
4828
5006
  const [value, setValue] = useState(initialValue);
4829
5007
  const [cursor, setCursor] = useState(initialValue.length);
@@ -5038,6 +5216,8 @@ function runBuiltinChecks() {
5038
5216
  * - Git status colors (M=amber, A=green, D=red, ?=dim)
5039
5217
  * - ↑↓ to navigate, Enter to select file, Esc to cancel
5040
5218
  * - Tab to toggle flat/tree mode
5219
+ * - Ctrl+← or Backspace on empty filter = go to parent directory
5220
+ * - Breadcrumb showing current directory path
5041
5221
  * - Max 20,000 candidates, max 15 visible
5042
5222
  */
5043
5223
  /** Directories to skip during traversal. */
@@ -5135,7 +5315,7 @@ function walkFiles(dir, workspace, candidates, depth) {
5135
5315
  function buildGitStatusMap(workspace) {
5136
5316
  const statusMap = /* @__PURE__ */ new Map();
5137
5317
  try {
5138
- const output = execSync("git -C . status --porcelain", {
5318
+ const output = execSync("git status --porcelain", {
5139
5319
  cwd: workspace,
5140
5320
  encoding: "utf-8",
5141
5321
  timeout: 3e3
@@ -5276,6 +5456,8 @@ function FilePicker({ workspace, onSelect, onCancel, initialFilter = "" }) {
5276
5456
  const [selectedIndex, setSelectedIndex] = useState(0);
5277
5457
  const [isTreeMode, setIsTreeMode] = useState(false);
5278
5458
  const [expandedDirs, setExpandedDirs] = useState(/* @__PURE__ */ new Set());
5459
+ /** Current browsed directory (empty = workspace root, shows all files). */
5460
+ const [currentDir, setCurrentDir] = useState("");
5279
5461
  const treeItems = useMemo(() => {
5280
5462
  if (!isTreeMode) return [];
5281
5463
  const flat = [];
@@ -5294,7 +5476,13 @@ function FilePicker({ workspace, onSelect, onCancel, initialFilter = "" }) {
5294
5476
  candidates,
5295
5477
  filter
5296
5478
  ]);
5297
- const itemCount = isTreeMode ? treeItems.length : flatMatched.length;
5479
+ /** Flat matches further filtered by current browsed directory. */
5480
+ const flatDirFiltered = useMemo(() => {
5481
+ if (!currentDir) return flatMatched;
5482
+ const prefix = currentDir.endsWith("/") ? currentDir : currentDir + "/";
5483
+ return flatMatched.filter((f) => f.path.startsWith(prefix));
5484
+ }, [flatMatched, currentDir]);
5485
+ const itemCount = isTreeMode ? treeItems.length : flatDirFiltered.length;
5298
5486
  const maxIndex = Math.max(0, itemCount - 1);
5299
5487
  const safeIndex = Math.min(selectedIndex, maxIndex);
5300
5488
  const toggleDir = useCallback((dirPath) => {
@@ -5327,7 +5515,7 @@ function FilePicker({ workspace, onSelect, onCancel, initialFilter = "" }) {
5327
5515
  }
5328
5516
  return;
5329
5517
  }
5330
- const file = flatMatched[safeIndex];
5518
+ const file = flatDirFiltered[safeIndex];
5331
5519
  if (file) onSelect(file.path);
5332
5520
  return;
5333
5521
  }
@@ -5340,8 +5528,13 @@ function FilePicker({ workspace, onSelect, onCancel, initialFilter = "" }) {
5340
5528
  return;
5341
5529
  }
5342
5530
  if (key.backspace) {
5343
- setFilter((f) => f.slice(0, -1));
5344
- setSelectedIndex(0);
5531
+ if (filter.length > 0) {
5532
+ setFilter((f) => f.slice(0, -1));
5533
+ setSelectedIndex(0);
5534
+ } else if (!isTreeMode && currentDir) {
5535
+ setCurrentDir(currentDir.includes("/") ? currentDir.slice(0, currentDir.lastIndexOf("/")) : "");
5536
+ setSelectedIndex(0);
5537
+ }
5345
5538
  return;
5346
5539
  }
5347
5540
  if (input.length === 1 && !key.ctrl && !key.meta) {
@@ -5351,10 +5544,12 @@ function FilePicker({ workspace, onSelect, onCancel, initialFilter = "" }) {
5351
5544
  }, [
5352
5545
  isTreeMode,
5353
5546
  treeItems,
5354
- flatMatched,
5547
+ flatDirFiltered,
5355
5548
  safeIndex,
5356
5549
  maxIndex,
5357
5550
  selectedIndex,
5551
+ filter,
5552
+ currentDir,
5358
5553
  toggleDir,
5359
5554
  onSelect,
5360
5555
  onCancel
@@ -5412,8 +5607,8 @@ function FilePicker({ workspace, onSelect, onCancel, initialFilter = "" }) {
5412
5607
  }, `${cursor} ${depthPrefix}${fileName}`), statusLabel ? React.createElement(Text, { color: statusColor }, statusLabel) : null, React.createElement(Text, { dimColor: true }, ` ${formatTime(item.entry.mtime)}`)));
5413
5608
  }
5414
5609
  }
5415
- else for (let i = 0; i < flatMatched.length; i++) {
5416
- const file = flatMatched[i];
5610
+ else for (let i = 0; i < flatDirFiltered.length; i++) {
5611
+ const file = flatDirFiltered[i];
5417
5612
  const isSelected = i === safeIndex;
5418
5613
  const prefix = isSelected ? "❯ " : " ";
5419
5614
  const statusColor = gitColor(file.path);
@@ -5443,7 +5638,10 @@ function FilePicker({ workspace, onSelect, onCancel, initialFilter = "" }) {
5443
5638
  }, " 树形 "), React.createElement(Text, { dimColor: true }, " "), React.createElement(Text, {
5444
5639
  color: !isTreeMode ? theme.colors.accent : theme.colors.dim,
5445
5640
  bold: !isTreeMode
5446
- }, " 列表 ")), items.length > 0 ? React.createElement(Box, { flexDirection: "column" }, ...items) : React.createElement(Text, { dimColor: true }, filter ? ` 无匹配 "${filter}" 的文件` : " 工作区中无文件。"));
5641
+ }, " 列表 ")), !isTreeMode && currentDir ? React.createElement(Box, {
5642
+ flexDirection: "row",
5643
+ marginBottom: 1
5644
+ }, React.createElement(Text, { dimColor: true }, "📁 "), React.createElement(Text, { color: theme.colors.accent }, currentDir + "/"), React.createElement(Text, { dimColor: true }, ` (${flatDirFiltered.length} 项, 退格返回上级)`)) : null, items.length > 0 ? React.createElement(Box, { flexDirection: "column" }, ...items) : React.createElement(Text, { dimColor: true }, filter ? ` 无匹配 "${filter}" 的文件` : " 工作区中无文件。"));
5447
5645
  }
5448
5646
  //#endregion
5449
5647
  //#region src/components/StashPicker.ts
@@ -6054,6 +6252,289 @@ function SkillPicker({ skills, onSelect, onCancel }) {
6054
6252
  }));
6055
6253
  }
6056
6254
  //#endregion
6255
+ //#region src/components/GlobalSearchDialog.ts
6256
+ /**
6257
+ * GlobalSearchDialog — 跨工作区文件内容搜索对话框(Ctrl+Shift+F)。
6258
+ *
6259
+ * 使用 ripgrep (rg) 进行增量流式搜索,结果按文件:行号去重。
6260
+ * 宽终端(≥120 列)在右侧显示预览窗格。
6261
+ *
6262
+ * Design:
6263
+ * - 防抖 150ms,流式递增结果(不替换,避免闪烁)
6264
+ * - 最大 500 条结果,单文件最多 10 条
6265
+ * - Enter → 插入 file:line 到输入框
6266
+ * - Tab → 插入 @file#Lline 到输入框(mention 格式)
6267
+ * - ↑↓ 导航, Esc 关闭
6268
+ */
6269
+ const DEBOUNCE_MS = 150;
6270
+ const MAX_MATCHES_PER_FILE = 10;
6271
+ const MAX_TOTAL_MATCHES = 500;
6272
+ const VISIBLE_RESULTS = 12;
6273
+ const PREVIEW_CONTEXT_LINES = 3;
6274
+ /** Parse a ripgrep -n --no-heading line: "path:line:text". */
6275
+ function parseRgLine(line) {
6276
+ const m = /^(.*?):(\d+):(.*)$/.exec(line);
6277
+ if (!m) return null;
6278
+ const [, file, lineStr, text] = m;
6279
+ const lineNum = Number(lineStr);
6280
+ if (!file || !Number.isFinite(lineNum)) return null;
6281
+ return {
6282
+ file,
6283
+ line: lineNum,
6284
+ text: text ?? ""
6285
+ };
6286
+ }
6287
+ function matchKey(m) {
6288
+ return `${m.file}:${m.line}`;
6289
+ }
6290
+ /** Read context lines around a match for preview. */
6291
+ function readPreview(workspace, file, targetLine) {
6292
+ try {
6293
+ const absolute = resolve(workspace, file);
6294
+ if (!existsSync(absolute)) return null;
6295
+ const lines = readFileSync(absolute, "utf-8").split("\n");
6296
+ const start = Math.max(0, targetLine - PREVIEW_CONTEXT_LINES - 1);
6297
+ const end = Math.min(lines.length, targetLine + PREVIEW_CONTEXT_LINES);
6298
+ return lines.slice(start, end).map((l, i) => `${start + i + 1}: ${l}`);
6299
+ } catch {
6300
+ return null;
6301
+ }
6302
+ }
6303
+ /** Cross-workspace file content search dialog — uses ripgrep for incremental streaming results. */
6304
+ function GlobalSearchDialog({ workspace, onCancel, onInsert }) {
6305
+ const theme = getTheme();
6306
+ const [query, setQuery] = useState("");
6307
+ const [matches, setMatches] = useState([]);
6308
+ const [isSearching, setIsSearching] = useState(false);
6309
+ const [truncated, setTruncated] = useState(false);
6310
+ const [focusedIndex, setFocusedIndex] = useState(0);
6311
+ const [previewLines, setPreviewLines] = useState(null);
6312
+ const [rgError, setRgError] = useState(null);
6313
+ const rgProcRef = useRef(null);
6314
+ const debounceRef = useRef(null);
6315
+ const collectedRef = useRef(0);
6316
+ const runSearch = useCallback((q) => {
6317
+ if (rgProcRef.current) {
6318
+ rgProcRef.current.kill();
6319
+ rgProcRef.current = null;
6320
+ }
6321
+ if (!q.trim()) {
6322
+ setMatches([]);
6323
+ setIsSearching(false);
6324
+ setTruncated(false);
6325
+ setRgError(null);
6326
+ collectedRef.current = 0;
6327
+ return;
6328
+ }
6329
+ setIsSearching(true);
6330
+ setTruncated(false);
6331
+ setRgError(null);
6332
+ collectedRef.current = 0;
6333
+ const queryLower = q.toLowerCase();
6334
+ setMatches((prev) => {
6335
+ const filtered = prev.filter((m) => m.text.toLowerCase().includes(queryLower));
6336
+ return filtered.length === prev.length ? prev : filtered;
6337
+ });
6338
+ const rg = spawn("rg", [
6339
+ "--no-heading",
6340
+ "-n",
6341
+ "-i",
6342
+ "-m",
6343
+ String(MAX_MATCHES_PER_FILE),
6344
+ "-F",
6345
+ "-e",
6346
+ q,
6347
+ "."
6348
+ ], {
6349
+ cwd: workspace,
6350
+ stdio: [
6351
+ "ignore",
6352
+ "pipe",
6353
+ "pipe"
6354
+ ]
6355
+ });
6356
+ rgProcRef.current = rg;
6357
+ let buffer = "";
6358
+ rg.stdout.on("data", (chunk) => {
6359
+ buffer += chunk.toString();
6360
+ const lines = buffer.split("\n");
6361
+ buffer = lines.pop() ?? "";
6362
+ const parsed = [];
6363
+ for (const line of lines) {
6364
+ const m = parseRgLine(line);
6365
+ if (!m) continue;
6366
+ const rel = relative(workspace, resolve(workspace, m.file));
6367
+ parsed.push({
6368
+ ...m,
6369
+ file: rel.startsWith("..") ? m.file.replace(/\\/g, "/") : rel.replace(/\\/g, "/")
6370
+ });
6371
+ }
6372
+ if (parsed.length === 0) return;
6373
+ collectedRef.current += parsed.length;
6374
+ setMatches((prev) => {
6375
+ const seen = new Set(prev.map(matchKey));
6376
+ const fresh = parsed.filter((p) => !seen.has(matchKey(p)));
6377
+ if (fresh.length === 0) return prev;
6378
+ const next = prev.concat(fresh);
6379
+ return next.length > MAX_TOTAL_MATCHES ? next.slice(0, MAX_TOTAL_MATCHES) : next;
6380
+ });
6381
+ if (collectedRef.current >= MAX_TOTAL_MATCHES) {
6382
+ rg.kill();
6383
+ setTruncated(true);
6384
+ setIsSearching(false);
6385
+ }
6386
+ });
6387
+ rg.on("close", () => {
6388
+ rgProcRef.current = null;
6389
+ if (collectedRef.current === 0) setMatches((prev) => prev.length ? [] : prev);
6390
+ setIsSearching(false);
6391
+ });
6392
+ rg.stderr.on("data", (chunk) => {
6393
+ const msg = chunk.toString().trim();
6394
+ if (msg) setRgError(`ripgrep (rg) 未安装或不可用,请安装后重试。${msg ? ` (${msg.slice(0, 200)})` : ""}`);
6395
+ });
6396
+ }, [workspace]);
6397
+ const handleQueryChange = useCallback((newQuery) => {
6398
+ setQuery(newQuery);
6399
+ if (debounceRef.current) clearTimeout(debounceRef.current);
6400
+ debounceRef.current = setTimeout(() => runSearch(newQuery), DEBOUNCE_MS);
6401
+ }, [runSearch]);
6402
+ useEffect(() => {
6403
+ return () => {
6404
+ if (rgProcRef.current) rgProcRef.current.kill();
6405
+ if (debounceRef.current) clearTimeout(debounceRef.current);
6406
+ };
6407
+ }, []);
6408
+ useEffect(() => {
6409
+ const focused = matches[focusedIndex];
6410
+ if (!focused) {
6411
+ setPreviewLines(null);
6412
+ return;
6413
+ }
6414
+ setPreviewLines(readPreview(workspace, focused.file, focused.line));
6415
+ }, [
6416
+ focusedIndex,
6417
+ matches,
6418
+ workspace
6419
+ ]);
6420
+ const safeIndex = Math.min(focusedIndex, Math.max(0, matches.length - 1));
6421
+ useInput$1((input, key) => {
6422
+ if (key.escape) {
6423
+ onCancel();
6424
+ return;
6425
+ }
6426
+ if (key.upArrow) {
6427
+ setFocusedIndex((i) => Math.max(0, i - 1));
6428
+ return;
6429
+ }
6430
+ if (key.downArrow) {
6431
+ setFocusedIndex((i) => Math.min(matches.length - 1, i + 1));
6432
+ return;
6433
+ }
6434
+ if (key.return) {
6435
+ const focused = matches[safeIndex];
6436
+ if (focused) onInsert(`${focused.file}:${focused.line} `);
6437
+ return;
6438
+ }
6439
+ if (key.tab) {
6440
+ const focused = matches[safeIndex];
6441
+ if (focused) onInsert(`@${focused.file}#L${focused.line} `);
6442
+ return;
6443
+ }
6444
+ if (key.backspace) {
6445
+ handleQueryChange(query.slice(0, -1));
6446
+ return;
6447
+ }
6448
+ if (input.length === 1 && !key.ctrl && !key.meta) {
6449
+ handleQueryChange(query + input);
6450
+ setFocusedIndex(0);
6451
+ }
6452
+ });
6453
+ const matchLabel = isSearching ? `搜索中…` : query ? `${matches.length}${truncated ? "+" : ""} 条结果` : "输入关键字开始搜索…";
6454
+ const focusedMatch = matches[safeIndex] ?? null;
6455
+ /** Highlight query in text — returns Text children with the match inverted. */
6456
+ function highlightText(text, q) {
6457
+ if (!q) return text;
6458
+ const lowered = text.toLowerCase();
6459
+ const qLower = q.toLowerCase();
6460
+ const idx = lowered.indexOf(qLower);
6461
+ if (idx === -1) return text;
6462
+ return [
6463
+ text.slice(0, idx),
6464
+ React.createElement(Text, {
6465
+ key: "hl",
6466
+ inverse: true
6467
+ }, text.slice(idx, idx + q.length)),
6468
+ text.slice(idx + q.length)
6469
+ ];
6470
+ }
6471
+ /** Truncate path to maxLen chars, keeping basename visible. */
6472
+ function truncatePath(path, maxLen) {
6473
+ if (path.length <= maxLen) return path;
6474
+ const sep = "/";
6475
+ const parts = path.split(sep);
6476
+ const base = parts[parts.length - 1] ?? path;
6477
+ const dir = parts.slice(0, -1).join(sep);
6478
+ const maxDir = maxLen - base.length - 4;
6479
+ if (maxDir <= 0) return `…/${base.slice(-(maxLen - 2))}`;
6480
+ return dir.slice(0, maxDir) + "…/" + base;
6481
+ }
6482
+ const renderListItems = [];
6483
+ for (let i = 0; i < Math.min(matches.length, VISIBLE_RESULTS); i++) {
6484
+ const m = matches[i];
6485
+ const isFocused = i === safeIndex;
6486
+ const prefix = isFocused ? "❯ " : " ";
6487
+ const color = isFocused ? theme.colors.accent : void 0;
6488
+ renderListItems.push(React.createElement(Box, {
6489
+ key: matchKey(m),
6490
+ flexDirection: "row"
6491
+ }, React.createElement(Text, { color }, prefix, React.createElement(Text, { dimColor: !isFocused }, truncatePath(m.file, 30)), ":", React.createElement(Text, { color: theme.colors.success }, String(m.line)), " "), React.createElement(Text, { color }, highlightText(m.text.trimStart(), query))));
6492
+ }
6493
+ const renderPreview = [];
6494
+ if (focusedMatch && previewLines) {
6495
+ renderPreview.push(React.createElement(Text, {
6496
+ key: "preview-header",
6497
+ dimColor: true,
6498
+ bold: true
6499
+ }, `── ${focusedMatch.file}:${focusedMatch.line} ──`));
6500
+ for (let i = 0; i < previewLines.length; i++) {
6501
+ const line = previewLines[i];
6502
+ const isTargetLine = line.startsWith(`${focusedMatch.line}:`);
6503
+ renderPreview.push(React.createElement(Text, {
6504
+ key: `preview-${i}`,
6505
+ color: isTargetLine ? theme.colors.accent : void 0,
6506
+ dimColor: !isTargetLine,
6507
+ inverse: isTargetLine
6508
+ }, line));
6509
+ }
6510
+ } else if (focusedMatch && previewLines === null) renderPreview.push(React.createElement(Text, {
6511
+ key: "loading",
6512
+ dimColor: true
6513
+ }, "加载预览中…"));
6514
+ return React.createElement(Dialog, {
6515
+ title: "全局搜索",
6516
+ subtitle: matchLabel,
6517
+ inputGuide: "Enter 插入路径 · Tab 插入 @mention · Esc 关闭 · ↑↓ 导航",
6518
+ accentColor: "accent",
6519
+ width: 100
6520
+ }, React.createElement(Box, {
6521
+ flexDirection: "row",
6522
+ marginBottom: 1
6523
+ }, React.createElement(Text, { color: theme.colors.accent }, "🔍 "), React.createElement(Text, { color: theme.colors.foreground }, query || "输入搜索关键字…"), query ? React.createElement(Text, { inverse: true }, " ") : null), rgError ? React.createElement(Box, { marginBottom: 1 }, React.createElement(Text, { color: theme.colors.error }, `⚠ ${rgError}`)) : null, matches.length > 0 ? React.createElement(Box, {
6524
+ flexDirection: "row",
6525
+ gap: 2
6526
+ }, React.createElement(Box, {
6527
+ flexDirection: "column",
6528
+ flexGrow: 1
6529
+ }, ...renderListItems), renderPreview.length > 0 ? React.createElement(Box, {
6530
+ flexDirection: "column",
6531
+ width: 50,
6532
+ borderStyle: "single",
6533
+ borderColor: theme.colors.border,
6534
+ paddingX: 1
6535
+ }, ...renderPreview) : null) : React.createElement(Text, { dimColor: true }, query ? " 无结果" : " 输入关键字开始搜索文件内容…"));
6536
+ }
6537
+ //#endregion
6057
6538
  //#region src/components/McpPanel.ts
6058
6539
  /**
6059
6540
  * McpPanel — MCP server management panel triggered by /mcp.
@@ -6646,6 +7127,186 @@ function useTerminalSize() {
6646
7127
  return size;
6647
7128
  }
6648
7129
  //#endregion
7130
+ //#region src/components/ink/ScrollBox.ts
7131
+ /**
7132
+ * ScrollBox — scrollable container with imperative API and windowed rendering.
7133
+ *
7134
+ * In standard Ink 6.x there is no native overflow:scroll — terminal output
7135
+ * is a linear stream. ScrollBox simulates scrolling by only rendering
7136
+ * children that intersect the visible viewport, at the cost of needing
7137
+ * child‑height hints.
7138
+ *
7139
+ * This is the foundation for the ChatLog message list, modal content
7140
+ * areas, and any other scrollable region in the TUI.
7141
+ *
7142
+ * Design (§5.9b #1.6.1): imperative scroll API, sticky‑scroll mode,
7143
+ * viewport culling, and subscriber notifications.
7144
+ */
7145
+ /**
7146
+ * Scrollable container with imperative scroll control.
7147
+ *
7148
+ * Only renders children that intersect [scrollTop, scrollTop + height].
7149
+ * Supports keyboard navigation: PageUp/Down, Home/End, arrow keys.
7150
+ *
7151
+ * Limitations in standard Ink:
7152
+ * - No smooth pixel‑level scrolling (Yoga‑based layout)
7153
+ * - Children above/below the viewport are not rendered at all
7154
+ * - Height must be known ahead of time (no auto‑measurement)
7155
+ */
7156
+ /** Virtual scrolling container — renders a viewport slice of children with a scroll offset. */
7157
+ const ScrollBox = forwardRef(function ScrollBox({ children, height, totalRows, stickyScroll: initialSticky = true, onScroll }, ref) {
7158
+ const [scrollTop, setScrollTop] = useState(0);
7159
+ const [stickyScroll, setStickyScroll] = useState(initialSticky);
7160
+ const scrollTopRef = useRef(scrollTop);
7161
+ scrollTopRef.current = scrollTop;
7162
+ const stickyRef = useRef(stickyScroll);
7163
+ stickyRef.current = stickyScroll;
7164
+ const listenersRef = useRef(/* @__PURE__ */ new Set());
7165
+ /** Clamp scrollTop to valid range. */
7166
+ const clamp = useCallback((y) => {
7167
+ const maxScroll = Math.max(0, totalRows - height);
7168
+ return Math.max(0, Math.min(y, maxScroll));
7169
+ }, [totalRows, height]);
7170
+ /** Notify all subscribers of a scroll change. */
7171
+ const notify = useCallback(() => {
7172
+ for (const listener of listenersRef.current) listener();
7173
+ }, []);
7174
+ /** Update scroll position and notify. */
7175
+ const updateScroll = useCallback((y, sticky) => {
7176
+ const clamped = clamp(y);
7177
+ scrollTopRef.current = clamped;
7178
+ setScrollTop(clamped);
7179
+ if (sticky !== void 0) {
7180
+ stickyRef.current = sticky;
7181
+ setStickyScroll(sticky);
7182
+ }
7183
+ onScroll?.(clamped);
7184
+ notify();
7185
+ }, [
7186
+ clamp,
7187
+ onScroll,
7188
+ notify
7189
+ ]);
7190
+ useImperativeHandle(ref, () => ({
7191
+ scrollTo(y) {
7192
+ updateScroll(y, false);
7193
+ },
7194
+ scrollBy(dy) {
7195
+ updateScroll(scrollTopRef.current + dy, false);
7196
+ },
7197
+ scrollToBottom() {
7198
+ updateScroll(totalRows - height, true);
7199
+ },
7200
+ getScrollTop() {
7201
+ return scrollTopRef.current;
7202
+ },
7203
+ getScrollHeight() {
7204
+ return totalRows;
7205
+ },
7206
+ isSticky() {
7207
+ return stickyRef.current;
7208
+ },
7209
+ subscribe(listener) {
7210
+ listenersRef.current.add(listener);
7211
+ return () => {
7212
+ listenersRef.current.delete(listener);
7213
+ };
7214
+ }
7215
+ }), [
7216
+ updateScroll,
7217
+ totalRows,
7218
+ height
7219
+ ]);
7220
+ useInput$1(useCallback((_input, key) => {
7221
+ const pageStep = Math.max(1, Math.floor(height / 2));
7222
+ if (key.pageUp) updateScroll(scrollTopRef.current + pageStep, false);
7223
+ else if (key.pageDown) {
7224
+ const next = scrollTopRef.current - pageStep;
7225
+ if (next <= 0) updateScroll(0, true);
7226
+ else updateScroll(next, false);
7227
+ } else if (key.upArrow) updateScroll(scrollTopRef.current + 1, false);
7228
+ else if (key.downArrow) {
7229
+ const next = scrollTopRef.current - 1;
7230
+ if (next <= 0) updateScroll(0, true);
7231
+ else updateScroll(next, false);
7232
+ } else if (key.home) updateScroll(0, false);
7233
+ else if (key.end) updateScroll(0, true);
7234
+ }, [updateScroll, height]));
7235
+ const visibleStart = scrollTop;
7236
+ const visibleEnd = Math.min(scrollTop + height, totalRows);
7237
+ const visibleChildren = React.Children.toArray(children).slice(visibleStart, visibleEnd);
7238
+ return React.createElement(Box, {
7239
+ flexDirection: "column",
7240
+ height,
7241
+ overflow: "hidden"
7242
+ }, ...visibleChildren);
7243
+ });
7244
+ //#endregion
7245
+ //#region src/components/FullscreenLayout.ts
7246
+ /**
7247
+ * FullscreenLayout — three‑slot terminal layout for the Lynx REPL.
7248
+ *
7249
+ * Mirrors Claude Code's FullscreenLayout API: `header`, `scrollable`,
7250
+ * `bottom` props, with an optional `overlay` slot for permission prompts
7251
+ * and `modal` for full‑screen dialogs.
7252
+ *
7253
+ * In fullscreen mode (alt buffer + mouse tracking) the three slots are
7254
+ * stacked vertically: header (fixed height) → scrollable (flexGrow)
7255
+ * → bottom (fixed height). The scrollable region uses ScrollBox for
7256
+ * viewport‑culled rendering with sticky‑scroll support.
7257
+ *
7258
+ * Design (§5.3a #1.2): three‑slot model with terminal resize adaptation.
7259
+ */
7260
+ /** Context for scroll‑derived chrome (sticky header, jump‑to‑bottom pill). */
7261
+ const ScrollChromeContext = createContext({ setStickyPrompt: () => {} });
7262
+ /** Default header row height (can be overridden by children). */
7263
+ const HEADER_HEIGHT = 1;
7264
+ /**
7265
+ * Full‑screen terminal layout with three slots.
7266
+ *
7267
+ * Calculates viewport dimensions from `useTerminalSize` and
7268
+ * distributes space: header gets fixed height, scrollable gets
7269
+ * the remainder, and bottom gets its natural height.
7270
+ *
7271
+ * When `modal` is set, it paints over both the scrollable and
7272
+ * bottom regions with a dimmed backdrop.
7273
+ */
7274
+ function FullscreenLayout({ header, scrollable, bottom, overlay, modal, hidePill, newMessageCount, onPillClick }) {
7275
+ const { rows, columns } = useTerminalSize();
7276
+ const theme = getTheme();
7277
+ const [stickyPrompt, setStickyPrompt] = useState(null);
7278
+ /** Stable context value to avoid unnecessary re‑renders. */
7279
+ const chromeCtx = useMemo(() => ({ setStickyPrompt }), [setStickyPrompt]);
7280
+ const pillText = newMessageCount != null && newMessageCount > 0 ? ` ↓ ${newMessageCount} 条新消息 — Enter 跳转 ` : " ↓ 跳转到底部 ";
7281
+ const bottomEstimate = 5;
7282
+ const headerHeight = HEADER_HEIGHT;
7283
+ Math.max(rows - headerHeight - bottomEstimate, 6);
7284
+ return React.createElement(ScrollChromeContext.Provider, { value: chromeCtx }, React.createElement(Box, {
7285
+ flexDirection: "column",
7286
+ height: rows,
7287
+ width: columns
7288
+ }, header ? React.createElement(Box, {
7289
+ height: headerHeight,
7290
+ flexShrink: 0
7291
+ }, header) : null, React.createElement(Box, {
7292
+ flexDirection: "column",
7293
+ flexGrow: 1
7294
+ }, stickyPrompt ? React.createElement(Box, { paddingX: 1 }, React.createElement(Text, { dimColor: true }, stickyPrompt.text)) : null, React.createElement(Box, {
7295
+ flexDirection: "column",
7296
+ flexGrow: 1,
7297
+ overflow: "hidden"
7298
+ }, scrollable), overlay, !hidePill && newMessageCount != null ? React.createElement(Box, {
7299
+ paddingX: 2,
7300
+ paddingY: 0
7301
+ }, React.createElement(Text, {
7302
+ color: theme.colors.accent,
7303
+ inverse: true
7304
+ }, pillText)) : null), React.createElement(Box, {
7305
+ flexDirection: "column",
7306
+ flexShrink: 0
7307
+ }, bottom), modal));
7308
+ }
7309
+ //#endregion
6649
7310
  //#region src/hooks/useScroll.ts
6650
7311
  /**
6651
7312
  * useScroll — scroll management hook for the ChatLog message area.
@@ -6821,8 +7482,7 @@ function App(props) {
6821
7482
  const showWelcome = props.showWelcome === true;
6822
7483
  const [welcomeDone, setWelcomeDone] = useState(false);
6823
7484
  const chatLogRef = useRef(null);
6824
- const chatHeight = Math.max(rows - 6, 6);
6825
- const scroll = useScroll({ visibleRows: chatHeight });
7485
+ const scroll = useScroll({ visibleRows: Math.max(rows - 6, 6) });
6826
7486
  const viewStackRef = useRef(createViewStack());
6827
7487
  const [currentView, setCurrentView] = useState({ id: "chat" });
6828
7488
  /** Frame rate limiter for throttling scroll sync during streaming. */
@@ -6875,7 +7535,7 @@ function App(props) {
6875
7535
  }
6876
7536
  watchdogTimeoutCountRef.current = 0;
6877
7537
  }, []);
6878
- const [themeVersion, setThemeVersion] = useState(0);
7538
+ const [, forceUpdate] = useReducer((x) => x + 1, 0);
6879
7539
  const [pendingPermission, setPendingPermission] = useState(null);
6880
7540
  const executeSlashCommand = useCallback((command) => {
6881
7541
  popView();
@@ -7037,7 +7697,11 @@ function App(props) {
7037
7697
  break;
7038
7698
  default: chatLogRef.current?.addSystem(`Command "${command}" not yet implemented.`, "warn");
7039
7699
  }
7040
- }, [pushView, popView]);
7700
+ }, [
7701
+ pushView,
7702
+ popView,
7703
+ callbacks
7704
+ ]);
7041
7705
  const handleGlobalKey = useCallback((input, key) => {
7042
7706
  if (scroll.handleKey(input, key)) return;
7043
7707
  const selfHandled = new Set([
@@ -7061,7 +7725,8 @@ function App(props) {
7061
7725
  "diff",
7062
7726
  "snapshots",
7063
7727
  "tasks",
7064
- "skill_pick"
7728
+ "skill_pick",
7729
+ "global_search"
7065
7730
  ]);
7066
7731
  if (key.escape && currentView.id !== "chat" && !selfHandled.has(currentView.id)) popView();
7067
7732
  if (key.ctrl && input === "g" && currentView.id === "chat") {
@@ -7074,11 +7739,14 @@ function App(props) {
7074
7739
  doExternalEdit();
7075
7740
  }
7076
7741
  if (key.ctrl && input === "o" && currentView.id === "chat") setTranscriptMode((prev) => prev === "compact" ? "full" : "compact");
7742
+ if (key.ctrl && key.shift && input === "p" && currentView.id === "chat") pushView({ id: "commands" });
7743
+ if (key.ctrl && key.shift && input === "f" && currentView.id === "chat") pushView({ id: "global_search" });
7077
7744
  }, [
7078
7745
  currentView.id,
7079
7746
  scroll,
7080
7747
  popView,
7081
- callbacks
7748
+ callbacks,
7749
+ pushView
7082
7750
  ]);
7083
7751
  useInput$1((input, key) => {
7084
7752
  if (input.includes("\x1B[<") || /^\[<\d+;\d+;\d+[Mm]/.test(input.trim())) return;
@@ -7244,7 +7912,7 @@ function App(props) {
7244
7912
  popView();
7245
7913
  },
7246
7914
  onCancel: () => {
7247
- callbacks.onPermissionReply(pendingPermission.requestId, false);
7915
+ callbacks.onAbort();
7248
7916
  setPendingPermission(null);
7249
7917
  popView();
7250
7918
  }
@@ -7280,7 +7948,7 @@ function App(props) {
7280
7948
  return React.createElement(ThemePicker, {
7281
7949
  onSelect: (themeName) => {
7282
7950
  setTheme(themeName);
7283
- setThemeVersion((v) => v + 1);
7951
+ forceUpdate();
7284
7952
  props.callbacks.onThemeChange?.(themeName);
7285
7953
  popView();
7286
7954
  },
@@ -7432,6 +8100,14 @@ function App(props) {
7432
8100
  popView();
7433
8101
  },
7434
8102
  onCancel: () => popView()
8103
+ }),
8104
+ global_search: () => React.createElement(GlobalSearchDialog, {
8105
+ workspace: session?.workspace ?? process.cwd(),
8106
+ onInsert: (text) => {
8107
+ setAppendToInput(text);
8108
+ popView();
8109
+ },
8110
+ onCancel: () => popView()
7435
8111
  })
7436
8112
  };
7437
8113
  /** Render the active modal overlay, if any. */
@@ -7442,7 +8118,7 @@ function App(props) {
7442
8118
  }
7443
8119
  if (showWelcome && !welcomeDone) return React.createElement(WelcomeScreen, {
7444
8120
  workspace: session?.workspace ?? process.cwd(),
7445
- version: "0.1.0",
8121
+ version: props.version,
7446
8122
  lastSession: session ? {
7447
8123
  label: session.label,
7448
8124
  relativeTime: relativeTimeStr(session.updatedAt),
@@ -7451,21 +8127,14 @@ function App(props) {
7451
8127
  onConfirm: () => setWelcomeDone(true),
7452
8128
  onExit: () => process.exit(0)
7453
8129
  });
7454
- return React.createElement(Box, {
7455
- flexDirection: "column",
7456
- height: rows
7457
- }, React.createElement(Box, {
8130
+ const headerNode = React.createElement(Box, {
7458
8131
  flexDirection: "row",
7459
- justifyContent: "space-between",
7460
- marginBottom: 1
7461
- }, React.createElement(Text, {
8132
+ justifyContent: "space-between"
8133
+ }, React.createElement(Box, { flexDirection: "row" }, React.createElement(Text, {
7462
8134
  bold: true,
7463
8135
  color: theme.colors.accent
7464
- }, "Lynx"), React.createElement(Text, { dimColor: true }, session?.label ?? "no session")), React.createElement(Box, {
7465
- flexDirection: "column",
7466
- height: chatHeight,
7467
- overflow: "hidden"
7468
- }, React.createElement(ChatLog, {
8136
+ }, "Lynx"), React.createElement(Text, { dimColor: true }, ` · ${session?.label ?? "no session"}`)), React.createElement(Box, { flexDirection: "row" }, React.createElement(Text, { dimColor: true }, props.currentModel ?? "unknown"), costUsd > 0 ? React.createElement(Text, { dimColor: true }, ` $${costUsd.toFixed(4)}`) : null));
8137
+ const scrollableNode = React.createElement(Box, { flexDirection: "column" }, React.createElement(ChatLog, {
7469
8138
  ref: chatLogRef,
7470
8139
  visibleRange: hasVisibleRange ? {
7471
8140
  start: scroll.visibleStart,
@@ -7473,13 +8142,9 @@ function App(props) {
7473
8142
  } : void 0,
7474
8143
  onSearchActiveChange: setSearchActive,
7475
8144
  transcriptMode
7476
- }), !scroll.stickyScroll && scroll.unseenCount > 0 ? React.createElement(Box, {
7477
- paddingX: 2,
7478
- paddingY: 0
7479
- }, React.createElement(Text, {
7480
- color: theme.colors.accent,
7481
- inverse: true
7482
- }, ` ↓ ${scroll.unseenCount} new messages — Enter to jump `)) : null), activeNotification ? React.createElement(Box, { paddingX: 1 }, React.createElement(NotificationCenter, { current: activeNotification })) : null, React.createElement(Box, { flexDirection: "column" }, React.createElement(InputBox, {
8145
+ }));
8146
+ const overlayNode = currentView.id === "permission" && pendingPermission ? renderPermission() : null;
8147
+ const bottomNode = React.createElement(Box, { flexDirection: "column" }, activeNotification ? React.createElement(Box, { paddingX: 1 }, React.createElement(NotificationCenter, { current: activeNotification })) : null, React.createElement(InputBox, {
7483
8148
  onSubmit: handleSubmit,
7484
8149
  onAbort: handleAbort,
7485
8150
  disabled: streaming || searchActive,
@@ -7488,7 +8153,7 @@ function App(props) {
7488
8153
  onStash: stashCurrentInput,
7489
8154
  appendText: appendToInput,
7490
8155
  onAppendTextConsumed: () => setAppendToInput(void 0)
7491
- })), React.createElement(StatusBar, {
8156
+ }), React.createElement(StatusBar, {
7492
8157
  mode: abortCount > 0 ? `abort ${abortCount}/${MAX_ABORT_LAYERS}` : "default",
7493
8158
  model: props.currentModel ?? "unknown",
7494
8159
  streaming,
@@ -7496,7 +8161,17 @@ function App(props) {
7496
8161
  tokensUsed,
7497
8162
  costUsd,
7498
8163
  budgetMaxUsd: 10
7499
- }), isModal ? renderModal() : null);
8164
+ }));
8165
+ return React.createElement(FullscreenLayout, {
8166
+ header: headerNode,
8167
+ scrollable: scrollableNode,
8168
+ bottom: bottomNode,
8169
+ overlay: overlayNode,
8170
+ modal: isModal ? renderModal() : null,
8171
+ hidePill: scroll.stickyScroll || scroll.unseenCount === 0,
8172
+ newMessageCount: scroll.unseenCount,
8173
+ onPillClick: () => scroll.jumpToBottom()
8174
+ });
7500
8175
  }
7501
8176
  //#endregion
7502
8177
  //#region src/components/ChatView.ts
@@ -7507,6 +8182,7 @@ function App(props) {
7507
8182
  * text blocks, with speaker labels (User / Assistant)
7508
8183
  * and tool‑use callouts.
7509
8184
  */
8185
+ /** Scrollable message transcript — renders the last 50 messages as labeled entries. */
7510
8186
  function ChatView({ messages, streaming }) {
7511
8187
  const visible = messages.slice(-50);
7512
8188
  if (visible.length === 0) return React.createElement(Box, { flexDirection: "column" }, React.createElement(Text, { dimColor: true }, "No messages yet. Start a conversation!"));
@@ -7677,6 +8353,233 @@ function Mascot({ state, animate = true }) {
7677
8353
  return React.createElement(Text, null, frame);
7678
8354
  }
7679
8355
  //#endregion
8356
+ //#region src/components/ink/Button.ts
8357
+ /**
8358
+ * Button — interactive component with keyboard activation and visual states.
8359
+ *
8360
+ * Activated via Enter, Space, or click. Supports focus/hover/active
8361
+ * visual states through a render‑prop pattern. Built on Ink's Box
8362
+ * with keyboard event handling via useInput.
8363
+ *
8364
+ * Design (§5.9b #1.6.2): inverse highlight on active, Enter to trigger.
8365
+ * Intentionally unstyled — styling is done by the consumer via render prop.
8366
+ */
8367
+ /** Duration of the "active" visual flash in milliseconds. */
8368
+ const ACTIVE_FLASH_MS = 100;
8369
+ /**
8370
+ * Interactive button component with keyboard activation.
8371
+ *
8372
+ * Supports Enter/Space activation, active‑state flash, and a
8373
+ * render‑prop pattern so consumers fully control visual styling
8374
+ * based on { focused, hovered, active } state.
8375
+ */
8376
+ function Button({ onAction, children, isFocused = false }) {
8377
+ const [isActive, setIsActive] = useState(false);
8378
+ const activeTimer = useRef(null);
8379
+ useInput$1(useCallback((input, key) => {
8380
+ if (!isFocused) return;
8381
+ if (key.return || input === " ") {
8382
+ setIsActive(true);
8383
+ onAction();
8384
+ if (activeTimer.current) clearTimeout(activeTimer.current);
8385
+ activeTimer.current = setTimeout(() => setIsActive(false), ACTIVE_FLASH_MS);
8386
+ }
8387
+ }, [onAction, isFocused]), { isActive: isFocused });
8388
+ const state = {
8389
+ focused: isFocused,
8390
+ hovered: false,
8391
+ active: isActive
8392
+ };
8393
+ return React.createElement(Box, null, children(state));
8394
+ }
8395
+ //#endregion
8396
+ //#region src/components/ink/Link.ts
8397
+ /**
8398
+ * Link — renders a clickable hyperlink in supported terminals.
8399
+ *
8400
+ * Uses OSC 8 escape sequences when the terminal supports hyperlinks
8401
+ * (detected via `supports-hyperlinks`). Falls back to plain text
8402
+ * display on terminals without hyperlink support.
8403
+ *
8404
+ * Design (§5.9b #1.6.3): clickable links via Ctrl+Click or terminal's
8405
+ * native hyperlink handling. Supports file://, http://, https://.
8406
+ */
8407
+ /**
8408
+ * Detect whether the terminal supports OSC 8 hyperlinks.
8409
+ *
8410
+ * Checks: iTerm (TERM_PROGRAM), VSCode terminal, Windows Terminal
8411
+ * (WT_SESSION), Ghostty (TERM), and Kitty.
8412
+ */
8413
+ function supportsHyperlinks() {
8414
+ const { TERM_PROGRAM, WT_SESSION, TERM } = process.env;
8415
+ if (TERM_PROGRAM === "iTerm.app") return true;
8416
+ if (TERM_PROGRAM === "vscode") return true;
8417
+ if (WT_SESSION) return true;
8418
+ if (TERM === "xterm-kitty") return true;
8419
+ if (TERM?.startsWith("ghostty")) return true;
8420
+ if (process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm") return true;
8421
+ return false;
8422
+ }
8423
+ /**
8424
+ * Build an OSC 8 hyperlink escape sequence.
8425
+ *
8426
+ * Format: ESC ] 8 ; <params> ; <url> ST <text> ESC ] 8 ; ; ST
8427
+ * Where ST (string terminator) is ESC \.
8428
+ */
8429
+ function osc8(url, text) {
8430
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
8431
+ }
8432
+ /**
8433
+ * Render a clickable link with terminal hyperlink support.
8434
+ *
8435
+ * On terminals with OSC 8 support, wraps text in hyperlink escape
8436
+ * sequences. On unsupported terminals, renders the URL in brackets
8437
+ * after the link text (GitHub‑flavored Markdown style).
8438
+ */
8439
+ function Link({ children, url, fallback }) {
8440
+ const content = children ?? url;
8441
+ if (supportsHyperlinks()) {
8442
+ const linked = osc8(url, content);
8443
+ return React.createElement(Text, null, linked);
8444
+ }
8445
+ const display = fallback ?? `${content} (${url})`;
8446
+ return React.createElement(Text, { dimColor: true }, display);
8447
+ }
8448
+ //#endregion
8449
+ //#region src/components/ink/RawAnsi.ts
8450
+ /**
8451
+ * RawAnsi — renders pre‑wrapped ANSI‑escaped text without re‑parsing.
8452
+ *
8453
+ * Use this when external tools (bash, git diff, compiler output) produce
8454
+ * ANSI‑colored output that should be displayed as‑is. Bypasses the
8455
+ * React → Yoga → Text serialization roundtrip for performance.
8456
+ *
8457
+ * In standard Ink 6.x, ANSI SGR codes in Text children are passed through
8458
+ * to the terminal output. This component joins pre‑wrapped lines and
8459
+ * hands them to Ink's Text, which preserves the escape sequences.
8460
+ *
8461
+ * Design (§5.9b #1.3.7): renders tool output with native terminal colors.
8462
+ */
8463
+ /**
8464
+ * Render ANSI‑escaped lines directly to terminal output.
8465
+ *
8466
+ * Joins lines with newlines and renders as a single Text node.
8467
+ * In standard Ink, ANSI codes in Text children are preserved in
8468
+ * terminal output — no special parsing needed.
8469
+ */
8470
+ function RawAnsi({ lines }) {
8471
+ if (lines.length === 0) return null;
8472
+ return React.createElement(Text, null, lines.join("\n"));
8473
+ }
8474
+ //#endregion
8475
+ //#region src/components/ink/Spinner.ts
8476
+ /**
8477
+ * Spinner — animated loading indicator with configurable frame sets.
8478
+ *
8479
+ * Renders a cycling character animation at a configurable interval.
8480
+ * Use for streaming states, tool execution, or any indeterminate wait.
8481
+ *
8482
+ * Design (§5.9b): 3‑frame cycle, configurable speed, works in both
8483
+ * fullscreen and inline contexts.
8484
+ */
8485
+ /** Pre‑built spinner frame sets. */
8486
+ const SPINNER_FRAMES = {
8487
+ dots: [
8488
+ "⠋",
8489
+ "⠙",
8490
+ "⠹",
8491
+ "⠸",
8492
+ "⠼",
8493
+ "⠴",
8494
+ "⠦",
8495
+ "⠧",
8496
+ "⠇",
8497
+ "⠏"
8498
+ ],
8499
+ line: [
8500
+ "|",
8501
+ "/",
8502
+ "-",
8503
+ "\\"
8504
+ ],
8505
+ pulse: [
8506
+ "█",
8507
+ "▓",
8508
+ "▒",
8509
+ "░"
8510
+ ],
8511
+ arrow: [
8512
+ "←",
8513
+ "↖",
8514
+ "↑",
8515
+ "↗",
8516
+ "→",
8517
+ "↘",
8518
+ "↓",
8519
+ "↙"
8520
+ ],
8521
+ bounce: [
8522
+ "⠁",
8523
+ "⠂",
8524
+ "⠄",
8525
+ "⡀",
8526
+ "⢀",
8527
+ "⠠",
8528
+ "⠐",
8529
+ "⠈"
8530
+ ]
8531
+ };
8532
+ /**
8533
+ * Animated spinner component.
8534
+ *
8535
+ * Cycles through frames at `intervalMs` speed. Cleans up the
8536
+ * interval timer on unmount to prevent memory leaks.
8537
+ */
8538
+ function Spinner({ frames = "dots", intervalMs = 80, label, color }) {
8539
+ const frameSet = typeof frames === "string" ? SPINNER_FRAMES[frames] ?? SPINNER_FRAMES.dots : frames;
8540
+ const [index, setIndex] = useState(0);
8541
+ const mountedRef = useRef(true);
8542
+ useEffect(() => {
8543
+ mountedRef.current = true;
8544
+ const id = setInterval(() => {
8545
+ if (!mountedRef.current) return;
8546
+ setIndex((prev) => (prev + 1) % frameSet.length);
8547
+ }, intervalMs);
8548
+ return () => {
8549
+ mountedRef.current = false;
8550
+ clearInterval(id);
8551
+ };
8552
+ }, [frameSet.length, intervalMs]);
8553
+ const frame = frameSet[index];
8554
+ if (label) return React.createElement(Text, color ? { color } : void 0, `${frame} ${label}`);
8555
+ return React.createElement(Text, color ? { color } : void 0, frame);
8556
+ }
8557
+ //#endregion
8558
+ //#region src/components/ink/NoSelect.ts
8559
+ /**
8560
+ * NoSelect — marks content as non‑selectable during alt‑screen text selection.
8561
+ *
8562
+ * In standard Ink 6.x (without custom renderer extensions), terminal text
8563
+ * selection is handled by the terminal emulator, not Ink. This component
8564
+ * is a semantic wrapper: Box with role annotation for accessibility.
8565
+ *
8566
+ * When Lynx adopts a custom Ink renderer (Tier 5), this component will
8567
+ * integrate with the noSelect DOM attribute for true selection exclusion.
8568
+ *
8569
+ * Design (§5.9b): fences off gutters (line numbers, diff sigils, list
8570
+ * bullets) so click‑drag yields clean pasteable content.
8571
+ */
8572
+ /**
8573
+ * Semantic wrapper for non‑selectable content.
8574
+ *
8575
+ * Currently renders as a plain Box (standard Ink has no selection API).
8576
+ * When custom renderer support is added, this will integrate with the
8577
+ * noSelect DOM attribute.
8578
+ */
8579
+ function NoSelect({ children }) {
8580
+ return React.createElement(Box, null, children);
8581
+ }
8582
+ //#endregion
7680
8583
  //#region src/hooks/useInput.ts
7681
8584
  /**
7682
8585
  * useInput — bridge between Ink's raw input and the agent's submit callback.
@@ -7992,6 +8895,6 @@ function AlternateScreen({ children, onEnter, onExit }) {
7992
8895
  return React.createElement(React.Fragment, null, children);
7993
8896
  }
7994
8897
  //#endregion
7995
- export { AlternateScreen, App, ChatLog, ChatView, CommandPalette, ConfigMenu, ConfirmDialog, Dialog, DiffViewer, DoctorPanel, ErrorBoundary, FilePicker, FrameRateLimiter, HelpModal, InputBox, InputPrompt, Mascot, ModelPicker, PermissionRequest, SessionPicker, SkillPicker, SnapshotBrowser, StashPicker, StatusBar, TasksPanel, ThemePicker, ToolRenderer, WelcomeScreen, chunkLongTokens, color, createDefaultBindings, createFrameRateLimiter, createViewStack, dispatchBinding, findStableOffset, getTheme, initTheme, isolateRtl, listThemes, parseMarkdown, redactBinaryContent, renderBlocks, runBuiltinChecks, sanitize, setTheme, stripAnsi, stripControlChars, transcriptCacheKey, useInput, useScroll, useTerminalSize };
8898
+ export { AlternateScreen, App, Button, ChatLog, ChatView, CommandPalette, ConfigMenu, ConfirmDialog, Dialog, DiffViewer, DoctorPanel, ErrorBoundary, FilePicker, FrameRateLimiter, FullscreenLayout, HelpModal, InputBox, InputPrompt, Link, Mascot, MessageRow, ModelPicker, NoSelect, PermissionRequest, RawAnsi, SPINNER_FRAMES, ScrollBox, ScrollChromeContext, SessionPicker, SkillPicker, SnapshotBrowser, Spinner, StashPicker, StatusBar, TasksPanel, ThemePicker, ToolRenderer, WelcomeScreen, chunkLongTokens, color, createDefaultBindings, createFrameRateLimiter, createViewStack, dispatchBinding, findStableOffset, getTheme, initTheme, isolateRtl, listThemes, parseMarkdown, redactBinaryContent, renderBlocks, runBuiltinChecks, sanitize, setTheme, stripAnsi, stripControlChars, transcriptCacheKey, useInput, useScroll, useTerminalSize };
7996
8899
 
7997
8900
  //# sourceMappingURL=index.mjs.map