@24klynx/tui 0.1.4 → 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/LICENSE +21 -0
- package/dist/index.d.mts +315 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1616 -713
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
2606
|
+
elements.push(React.createElement(MessageRow, {
|
|
2369
2607
|
key: entry.id,
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
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
|
-
|
|
2384
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2423
|
-
*
|
|
2424
|
-
* -
|
|
2425
|
-
* -
|
|
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/
|
|
2753
|
+
//#region src/components/input/input-history.ts
|
|
2724
2754
|
/**
|
|
2725
|
-
*
|
|
2755
|
+
* Input history persistence — load/save to ~/.lynx/input-history.json.
|
|
2726
2756
|
*
|
|
2727
|
-
*
|
|
2728
|
-
*
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
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
|
-
/**
|
|
2770
|
-
|
|
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
|
|
2865
|
+
let entryStat;
|
|
2791
2866
|
try {
|
|
2792
|
-
|
|
2867
|
+
entryStat = statSync(fullPath);
|
|
2793
2868
|
} catch {
|
|
2794
2869
|
continue;
|
|
2795
2870
|
}
|
|
2796
2871
|
const relativePath = relative(workspace, fullPath).replace(/\\/g, "/");
|
|
2797
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
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
|
-
|
|
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
|
|
2934
|
+
return true;
|
|
3029
2935
|
}
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
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
|
-
|
|
3041
|
-
|
|
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
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
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
|
|
2985
|
+
return true;
|
|
3049
2986
|
}
|
|
3050
|
-
|
|
3051
|
-
if (
|
|
3052
|
-
ctx.
|
|
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 (
|
|
3063
|
-
ctx.
|
|
3064
|
-
|
|
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
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
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
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
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(
|
|
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
|
-
|
|
3085
|
+
ctx.setCompletions([]);
|
|
3086
|
+
ctx.setCompletionStart(-1);
|
|
3087
|
+
return true;
|
|
3110
3088
|
}
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
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
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
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
|
-
|
|
3218
|
+
actions
|
|
3232
3219
|
]);
|
|
3233
|
-
|
|
3234
|
-
|
|
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
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
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
|
-
|
|
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
|
|
3487
|
+
* StatusBar — bottom status line with three‑section layout.
|
|
3417
3488
|
*
|
|
3418
|
-
*
|
|
3419
|
-
*
|
|
3420
|
-
*
|
|
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
|
-
*
|
|
3436
|
-
* - < 50
|
|
3437
|
-
* - 50–80
|
|
3438
|
-
* - > 80
|
|
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
|
-
|
|
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
|
|
3451
|
-
const
|
|
3452
|
-
|
|
3453
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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+
|
|
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+
|
|
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: "
|
|
3960
|
-
action: "
|
|
4156
|
+
keys: "/",
|
|
4157
|
+
action: "斜杠命令",
|
|
3961
4158
|
category: "对话"
|
|
3962
4159
|
},
|
|
3963
4160
|
{
|
|
3964
|
-
keys: "
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
5344
|
-
|
|
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
|
-
|
|
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 <
|
|
5416
|
-
const file =
|
|
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
|
-
}, " 列表 ")),
|
|
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
|
|
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 [
|
|
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
|
-
}, [
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
7477
|
-
|
|
7478
|
-
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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
|