wave-code 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/wave.mjs +1049 -584
- package/package.json +4 -3
- package/src/cli.tsx +9 -0
- package/src/commands/plugin/install.ts +6 -7
- package/src/commands/plugin/uninstall.ts +11 -5
- package/src/components/ChatInterface.tsx +1 -0
- package/src/components/ConfirmationDetails.tsx +36 -15
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/InputBox.tsx +39 -2
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +22 -17
- package/src/components/PluginManagerShell.tsx +3 -3
- package/src/components/PluginManagerTypes.ts +2 -1
- package/src/components/ResumeCommand.tsx +84 -0
- package/src/components/ScrolledContent.tsx +71 -0
- package/src/components/SessionSelector.tsx +184 -6
- package/src/components/SessionSelectorFlow.tsx +246 -0
- package/src/constants/commands.ts +18 -0
- package/src/contexts/useChat.tsx +158 -28
- package/src/daemon/commands.ts +569 -46
- package/src/daemon-cli.ts +6 -0
- package/src/hooks/useInputManager.ts +16 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/index.ts +62 -8
- package/src/managers/inputHandlers.ts +14 -9
- package/src/managers/inputReducer.ts +29 -9
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/selectorReducer.ts +3 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +397 -95
- package/src/stdio/protocol.ts +9 -0
- package/src/stdio-cli.ts +6 -0
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/components/PlanDisplay.tsx +0 -18
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { Box, Text, useInput, useWindowSize } from "ink";
|
|
3
3
|
import { useLineScroll } from "../hooks/useLineScroll.js";
|
|
4
|
+
import { ScrolledContent } from "./ScrolledContent.js";
|
|
4
5
|
|
|
5
6
|
// Row budget for the scroll indicators (↑ more / ↓ more, up to one each).
|
|
6
7
|
const SCROLL_INDICATOR_BUDGET = 2;
|
|
@@ -60,8 +61,11 @@ export const PlanView: React.FC<PlanViewProps> = ({
|
|
|
60
61
|
!isMessageOnly && content !== undefined && content !== ""
|
|
61
62
|
? content.split("\n")
|
|
62
63
|
: [];
|
|
64
|
+
// Rows really occupied by the plan (wrapped rows included), measured after
|
|
65
|
+
// layout by <ScrolledContent>.
|
|
66
|
+
const [contentRows, setContentRows] = useState(0);
|
|
63
67
|
const { scrollOffset, hasMoreAbove, hasMoreBelow } = useLineScroll({
|
|
64
|
-
totalLines:
|
|
68
|
+
totalLines: contentRows,
|
|
65
69
|
visibleCount,
|
|
66
70
|
});
|
|
67
71
|
|
|
@@ -81,11 +85,6 @@ export const PlanView: React.FC<PlanViewProps> = ({
|
|
|
81
85
|
);
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
const visibleLines = planLines.slice(
|
|
85
|
-
scrollOffset,
|
|
86
|
-
scrollOffset + visibleCount,
|
|
87
|
-
);
|
|
88
|
-
|
|
89
88
|
return (
|
|
90
89
|
<Box
|
|
91
90
|
flexDirection="column"
|
|
@@ -110,19 +109,25 @@ export const PlanView: React.FC<PlanViewProps> = ({
|
|
|
110
109
|
</Text>
|
|
111
110
|
</Box>
|
|
112
111
|
)}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
<ScrolledContent
|
|
113
|
+
visibleRows={visibleCount}
|
|
114
|
+
scrollOffset={scrollOffset}
|
|
115
|
+
onMeasuredRows={setContentRows}
|
|
116
|
+
>
|
|
117
|
+
{planLines.length > 0 ? (
|
|
118
|
+
planLines.map((line, index) => (
|
|
119
|
+
<Box key={index}>
|
|
120
|
+
<Text>{line || " "}</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
))
|
|
123
|
+
) : (
|
|
124
|
+
<Text>No plan written yet.</Text>
|
|
125
|
+
)}
|
|
126
|
+
</ScrolledContent>
|
|
122
127
|
{hasMoreBelow && (
|
|
123
128
|
<Box>
|
|
124
129
|
<Text color="gray" dimColor>
|
|
125
|
-
↓ {
|
|
130
|
+
↓ {contentRows - scrollOffset - visibleCount} more
|
|
126
131
|
</Text>
|
|
127
132
|
</Box>
|
|
128
133
|
)}
|
|
@@ -13,9 +13,9 @@ import { PluginManagerContext } from "../contexts/PluginManagerContext.js";
|
|
|
13
13
|
export const PluginManagerShell: React.FC<{
|
|
14
14
|
children?: React.ReactNode;
|
|
15
15
|
onCancel?: () => void;
|
|
16
|
-
|
|
17
|
-
}> = ({ children, onCancel,
|
|
18
|
-
const pluginManager = usePluginManager({
|
|
16
|
+
onPluginChanged?: () => void;
|
|
17
|
+
}> = ({ children, onCancel, onPluginChanged }) => {
|
|
18
|
+
const pluginManager = usePluginManager({ onPluginChanged });
|
|
19
19
|
const { state, actions, discoverablePlugins } = pluginManager;
|
|
20
20
|
|
|
21
21
|
const setView = (view: ViewType) => {
|
|
@@ -24,6 +24,8 @@ export interface PluginManagerState {
|
|
|
24
24
|
export interface PluginManagerContextType {
|
|
25
25
|
state: PluginManagerState;
|
|
26
26
|
marketplaces: KnownMarketplace[];
|
|
27
|
+
/** 打开插件管理器触发的后台清单刷新是否仍在进行(只拉检出、不升级插件)。 */
|
|
28
|
+
checkingForUpdates: boolean;
|
|
27
29
|
installedPlugins: (InstalledPlugin & { enabled: boolean })[];
|
|
28
30
|
discoverablePlugins: (MarketplacePluginEntry & {
|
|
29
31
|
marketplace: string;
|
|
@@ -49,7 +51,6 @@ export interface PluginManagerContextType {
|
|
|
49
51
|
) => Promise<void>;
|
|
50
52
|
uninstallPlugin: (name: string, marketplace: string) => Promise<void>;
|
|
51
53
|
updatePlugin: (name: string, marketplace: string) => Promise<void>;
|
|
52
|
-
toggleAutoUpdate: (name: string, enabled: boolean) => Promise<void>;
|
|
53
54
|
refresh: () => Promise<void>;
|
|
54
55
|
clearPluginFeedback: () => void;
|
|
55
56
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { SessionSelectorFlow } from "./SessionSelectorFlow.js";
|
|
4
|
+
import { listWorktrees } from "../utils/worktree.js";
|
|
5
|
+
|
|
6
|
+
export interface ResumeCommandProps {
|
|
7
|
+
/** The session's current working directory (the picker's default scope). */
|
|
8
|
+
workdir: string;
|
|
9
|
+
/**
|
|
10
|
+
* True while a turn is in flight. Switching mid-turn is refused: the running
|
|
11
|
+
* turn must not be aborted (its abort error would land in the session being
|
|
12
|
+
* left behind), so the picker is replaced by a notice.
|
|
13
|
+
*/
|
|
14
|
+
isBusy: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Called with the chosen session. `resumeWorkdir` is set when the session
|
|
17
|
+
* belongs to a sibling worktree of the same repo — the session then moves
|
|
18
|
+
* into that directory.
|
|
19
|
+
*/
|
|
20
|
+
onSelect: (sessionId: string, resumeWorkdir?: string) => void;
|
|
21
|
+
onCancel: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* In-TUI `/resume` picker. Thin wrapper over the shared `SessionSelectorFlow`:
|
|
26
|
+
* the worktree list is loaded lazily on open (it needs a `git worktree list`
|
|
27
|
+
* round-trip), and a cross-project selection shows a dismissible notice instead
|
|
28
|
+
* of tearing down the TUI.
|
|
29
|
+
*/
|
|
30
|
+
export const ResumeCommand: React.FC<ResumeCommandProps> = ({
|
|
31
|
+
workdir,
|
|
32
|
+
isBusy,
|
|
33
|
+
onSelect,
|
|
34
|
+
onCancel,
|
|
35
|
+
}) => {
|
|
36
|
+
const [worktreePaths, setWorktreePaths] = useState<string[] | null>(null);
|
|
37
|
+
|
|
38
|
+
useInput((_input, key) => {
|
|
39
|
+
if (isBusy && key.escape) {
|
|
40
|
+
onCancel();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (isBusy) return;
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
listWorktrees(workdir).then((paths) => {
|
|
48
|
+
if (!cancelled) setWorktreePaths(paths);
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
}, [workdir, isBusy]);
|
|
54
|
+
|
|
55
|
+
if (isBusy) {
|
|
56
|
+
return (
|
|
57
|
+
<Box flexDirection="column" paddingX={1}>
|
|
58
|
+
<Text color="yellow">
|
|
59
|
+
Cannot resume a conversation while the agent is running.
|
|
60
|
+
</Text>
|
|
61
|
+
<Text dimColor>Esc to go back</Text>
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!worktreePaths) {
|
|
67
|
+
return (
|
|
68
|
+
<Box paddingX={1}>
|
|
69
|
+
<Text dimColor>Loading conversations…</Text>
|
|
70
|
+
</Box>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<SessionSelectorFlow
|
|
76
|
+
currentWorkdir={workdir}
|
|
77
|
+
worktreePaths={worktreePaths}
|
|
78
|
+
crossProjectMode="dismiss"
|
|
79
|
+
onDone={(result) =>
|
|
80
|
+
result ? onSelect(result.sessionId, result.resumeWorkdir) : onCancel()
|
|
81
|
+
}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useRef } from "react";
|
|
2
|
+
import { Box, type DOMElement } from "ink";
|
|
3
|
+
|
|
4
|
+
export interface ScrolledContentProps {
|
|
5
|
+
/** Every content row — rows are never sliced, the viewport clips them. */
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
/** Largest height the viewport may take, in terminal rows (the row budget
|
|
8
|
+
* for the content area). Shorter content keeps its natural height. */
|
|
9
|
+
visibleRows: number;
|
|
10
|
+
/** Rows scrolled off the top. */
|
|
11
|
+
scrollOffset: number;
|
|
12
|
+
/** Reports the content height in terminal rows, re-reporting on change. */
|
|
13
|
+
onMeasuredRows: (rows: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hard-bounded, clipping viewport for content that can be wider than the
|
|
18
|
+
* terminal: the content is rendered once and translated up by `scrollOffset`
|
|
19
|
+
* rows, and rows outside the viewport are clipped away.
|
|
20
|
+
*
|
|
21
|
+
* Height is bounded by construction — a line that wraps grows the content
|
|
22
|
+
* instead of the frame — so the row budget of the surrounding layout holds no
|
|
23
|
+
* matter how many rows the content really needs.
|
|
24
|
+
*
|
|
25
|
+
* The row count is read back from the layout rather than predicted from the
|
|
26
|
+
* text: by the time this effect runs, Yoga has wrapped the content to the
|
|
27
|
+
* terminal width, so the computed height already includes wrapped rows.
|
|
28
|
+
*/
|
|
29
|
+
export const ScrolledContent: React.FC<ScrolledContentProps> = ({
|
|
30
|
+
children,
|
|
31
|
+
visibleRows,
|
|
32
|
+
scrollOffset,
|
|
33
|
+
onMeasuredRows,
|
|
34
|
+
}) => {
|
|
35
|
+
const contentRef = useRef<DOMElement | null>(null);
|
|
36
|
+
const reportedRows = useRef(0);
|
|
37
|
+
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
const yogaNode = contentRef.current?.yogaNode;
|
|
40
|
+
if (!yogaNode) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const rows = Math.round(yogaNode.getComputedHeight());
|
|
44
|
+
if (rows > 0 && rows !== reportedRows.current) {
|
|
45
|
+
reportedRows.current = rows;
|
|
46
|
+
onMeasuredRows(rows);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Box
|
|
52
|
+
maxHeight={visibleRows}
|
|
53
|
+
overflow="hidden"
|
|
54
|
+
flexShrink={0}
|
|
55
|
+
flexDirection="column"
|
|
56
|
+
>
|
|
57
|
+
<Box
|
|
58
|
+
ref={contentRef}
|
|
59
|
+
flexDirection="column"
|
|
60
|
+
// Without this the fixed-height parent shrinks the content to fit and
|
|
61
|
+
// renders garbled rows instead of clipping them.
|
|
62
|
+
flexShrink={0}
|
|
63
|
+
marginTop={-scrollOffset}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</Box>
|
|
67
|
+
</Box>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
ScrolledContent.displayName = "ScrolledContent";
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
1
|
+
import React, {
|
|
2
|
+
useReducer,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Box, Text, useInput, type Key } from "ink";
|
|
3
9
|
import type { SessionMetadata } from "wave-agent-sdk";
|
|
4
10
|
import {
|
|
5
11
|
selectorReducer,
|
|
@@ -23,8 +29,22 @@ export interface SessionSelectorProps {
|
|
|
23
29
|
onToggleAllProjects?: (showAllProjects: boolean) => void;
|
|
24
30
|
/** Emitted with the new scope when the user toggles all-worktrees (Ctrl+W). */
|
|
25
31
|
onToggleAllWorktrees?: (showAllWorktrees: boolean) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Ctrl+R renames the focused session. The parent persists the new title
|
|
34
|
+
* (the session may belong to another project, so only it knows the file);
|
|
35
|
+
* the row shows the new title as soon as the promise resolves. Rejecting
|
|
36
|
+
* keeps the old title and surfaces a visible failure notice.
|
|
37
|
+
*/
|
|
38
|
+
onRename?: (sessionId: string, title: string) => Promise<void>;
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
type RenameState = {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
value: string;
|
|
44
|
+
cursor: number;
|
|
45
|
+
saving: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
28
48
|
export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
29
49
|
sessions,
|
|
30
50
|
onSelect,
|
|
@@ -33,6 +53,7 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
33
53
|
showProjectPath = false,
|
|
34
54
|
onToggleAllProjects,
|
|
35
55
|
onToggleAllWorktrees,
|
|
56
|
+
onRename,
|
|
36
57
|
}) => {
|
|
37
58
|
const [state, dispatch] = useReducer(selectorReducer<SessionItem>, {
|
|
38
59
|
selectedIndex: 0,
|
|
@@ -50,12 +71,113 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
50
71
|
showAllWorktrees,
|
|
51
72
|
} = state;
|
|
52
73
|
|
|
74
|
+
// Titles committed during this picker session. The parent's list is only
|
|
75
|
+
// re-synced through SET_ITEMS (which resets the selection), so overlaying the
|
|
76
|
+
// new title here keeps the row, the scope and the selected index intact.
|
|
77
|
+
const [renamedTitles, setRenamedTitles] = useState<Record<string, string>>(
|
|
78
|
+
{},
|
|
79
|
+
);
|
|
80
|
+
const [renaming, setRenaming] = useState<RenameState | null>(null);
|
|
81
|
+
const [renameError, setRenameError] = useState<string | null>(null);
|
|
82
|
+
|
|
53
83
|
// Sync sessions into reducer state
|
|
54
84
|
useEffect(() => {
|
|
55
85
|
dispatch({ type: "SET_ITEMS", items: sessions });
|
|
56
86
|
}, [sessions]);
|
|
57
87
|
|
|
88
|
+
const submitRename = useCallback(async () => {
|
|
89
|
+
if (!renaming || renaming.saving) return;
|
|
90
|
+
const { sessionId, value } = renaming;
|
|
91
|
+
const title = value.trim();
|
|
92
|
+
// Blank title: nothing to save, just leave the editor (spec: 空标题为 no-op).
|
|
93
|
+
if (!title) {
|
|
94
|
+
setRenaming(null);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setRenaming({ ...renaming, saving: true });
|
|
98
|
+
try {
|
|
99
|
+
await onRename?.(sessionId, title);
|
|
100
|
+
setRenamedTitles((prev) => ({ ...prev, [sessionId]: title }));
|
|
101
|
+
setRenameError(null);
|
|
102
|
+
setRenaming(null);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
setRenaming(null);
|
|
105
|
+
setRenameError(
|
|
106
|
+
`Failed to rename: ${
|
|
107
|
+
error instanceof Error ? error.message : String(error)
|
|
108
|
+
}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}, [renaming, onRename]);
|
|
112
|
+
|
|
113
|
+
const handleRenameKeys = (input: string, key: Key) => {
|
|
114
|
+
// While saving, swallow everything — the editor closes on the response.
|
|
115
|
+
if (!renaming || renaming.saving) return;
|
|
116
|
+
|
|
117
|
+
if (key.escape) {
|
|
118
|
+
setRenaming(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.return) {
|
|
122
|
+
void submitRename();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (key.backspace || key.delete) {
|
|
126
|
+
if (renaming.cursor === 0) return;
|
|
127
|
+
const nextCursor = renaming.cursor - 1;
|
|
128
|
+
setRenaming({
|
|
129
|
+
...renaming,
|
|
130
|
+
value:
|
|
131
|
+
renaming.value.slice(0, nextCursor) +
|
|
132
|
+
renaming.value.slice(renaming.cursor),
|
|
133
|
+
cursor: nextCursor,
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (key.leftArrow) {
|
|
138
|
+
setRenaming({
|
|
139
|
+
...renaming,
|
|
140
|
+
cursor: Math.max(0, renaming.cursor - 1),
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (key.rightArrow) {
|
|
145
|
+
setRenaming({
|
|
146
|
+
...renaming,
|
|
147
|
+
cursor: Math.min(renaming.value.length, renaming.cursor + 1),
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
input &&
|
|
153
|
+
!key.ctrl &&
|
|
154
|
+
!key.meta &&
|
|
155
|
+
!key.return &&
|
|
156
|
+
!key.escape &&
|
|
157
|
+
!key.backspace &&
|
|
158
|
+
!key.delete &&
|
|
159
|
+
!key.leftArrow &&
|
|
160
|
+
!key.rightArrow
|
|
161
|
+
) {
|
|
162
|
+
setRenaming({
|
|
163
|
+
...renaming,
|
|
164
|
+
value:
|
|
165
|
+
renaming.value.slice(0, renaming.cursor) +
|
|
166
|
+
input +
|
|
167
|
+
renaming.value.slice(renaming.cursor),
|
|
168
|
+
cursor: renaming.cursor + input.length,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// One subscription for both modes: while the inline title editor is open it
|
|
174
|
+
// owns every key (spec: 编辑期间按键不得冒泡触发列表操作), so a single handler
|
|
175
|
+
// keeps the editor from racing a second `useInput` subscription.
|
|
58
176
|
useInput((input, key) => {
|
|
177
|
+
if (renaming) {
|
|
178
|
+
handleRenameKeys(input, key);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
59
181
|
// Ctrl+A — toggle all-projects scope (only when the parent supports it)
|
|
60
182
|
if (key.ctrl && input.toLowerCase() === "a" && onToggleAllProjects) {
|
|
61
183
|
dispatch({ type: "TOGGLE_ALL_PROJECTS" });
|
|
@@ -71,6 +193,24 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
71
193
|
dispatch({ type: "TOGGLE_ALL_WORKTREES" });
|
|
72
194
|
return;
|
|
73
195
|
}
|
|
196
|
+
// Ctrl+R — rename the focused session in place
|
|
197
|
+
if (key.ctrl && input.toLowerCase() === "r" && onRename) {
|
|
198
|
+
const target = items[selectedIndex];
|
|
199
|
+
if (!target) return;
|
|
200
|
+
const initial =
|
|
201
|
+
renamedTitles[target.id] ??
|
|
202
|
+
target.customTitle ??
|
|
203
|
+
target.firstMessage ??
|
|
204
|
+
"";
|
|
205
|
+
setRenameError(null);
|
|
206
|
+
setRenaming({
|
|
207
|
+
sessionId: target.id,
|
|
208
|
+
value: initial,
|
|
209
|
+
cursor: initial.length,
|
|
210
|
+
saving: false,
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
74
214
|
dispatch({ type: "HANDLE_KEY", key, hasInsert: false });
|
|
75
215
|
});
|
|
76
216
|
|
|
@@ -135,6 +275,14 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
135
275
|
startIndex + MAX_VISIBLE_ITEMS,
|
|
136
276
|
);
|
|
137
277
|
|
|
278
|
+
// Display precedence: custom title > first message > placeholder (shared with
|
|
279
|
+
// every other host — spec: session-management.md 用户故事「会话自定义标题」).
|
|
280
|
+
const sessionTitle = (session: SessionItem): string =>
|
|
281
|
+
renamedTitles[session.id] ??
|
|
282
|
+
session.customTitle ??
|
|
283
|
+
session.firstMessage ??
|
|
284
|
+
"";
|
|
285
|
+
|
|
138
286
|
const hints = [
|
|
139
287
|
onToggleAllProjects
|
|
140
288
|
? `Ctrl+A ${showAllProjects ? "current directory" : "all projects"}`
|
|
@@ -142,6 +290,7 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
142
290
|
onToggleAllWorktrees && worktreePaths.length > 1
|
|
143
291
|
? `Ctrl+W ${showAllWorktrees ? "current worktree" : "all worktrees"}`
|
|
144
292
|
: null,
|
|
293
|
+
onRename ? "Ctrl+R rename" : null,
|
|
145
294
|
"↑↓ select",
|
|
146
295
|
"Enter confirm",
|
|
147
296
|
"Esc cancel",
|
|
@@ -171,6 +320,7 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
171
320
|
const actualIndex = startIndex + index;
|
|
172
321
|
const isSelected = actualIndex === selectedIndex;
|
|
173
322
|
const lastActiveAt = new Date(session.lastActiveAt).toLocaleString();
|
|
323
|
+
const isRenaming = renaming?.sessionId === session.id;
|
|
174
324
|
|
|
175
325
|
return (
|
|
176
326
|
<Box key={session.id} flexDirection="column" width="100%">
|
|
@@ -200,12 +350,28 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
200
350
|
</Text>
|
|
201
351
|
</Box>
|
|
202
352
|
</Box>
|
|
203
|
-
{
|
|
353
|
+
{isRenaming && renaming ? (
|
|
204
354
|
<Box marginLeft={2} width="100%">
|
|
205
|
-
<Text
|
|
206
|
-
|
|
355
|
+
<Text>
|
|
356
|
+
<Text dimColor>Rename: </Text>
|
|
357
|
+
{renaming.value.slice(0, renaming.cursor)}
|
|
358
|
+
<Text backgroundColor="white" color="black">
|
|
359
|
+
{renaming.cursor < renaming.value.length
|
|
360
|
+
? renaming.value[renaming.cursor]
|
|
361
|
+
: " "}
|
|
362
|
+
</Text>
|
|
363
|
+
{renaming.value.slice(renaming.cursor + 1)}
|
|
207
364
|
</Text>
|
|
208
365
|
</Box>
|
|
366
|
+
) : (
|
|
367
|
+
isSelected &&
|
|
368
|
+
sessionTitle(session) && (
|
|
369
|
+
<Box marginLeft={2} width="100%">
|
|
370
|
+
<Text dimColor italic wrap="truncate-end">
|
|
371
|
+
{sessionTitle(session).replace(/\n/g, "\\n")}
|
|
372
|
+
</Text>
|
|
373
|
+
</Box>
|
|
374
|
+
)
|
|
209
375
|
)}
|
|
210
376
|
</Box>
|
|
211
377
|
);
|
|
@@ -220,8 +386,20 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|
|
220
386
|
</Box>
|
|
221
387
|
)}
|
|
222
388
|
|
|
389
|
+
{renameError && (
|
|
390
|
+
<Box>
|
|
391
|
+
<Text color="red">{renameError}</Text>
|
|
392
|
+
</Box>
|
|
393
|
+
)}
|
|
394
|
+
|
|
223
395
|
<Box>
|
|
224
|
-
<Text dimColor>
|
|
396
|
+
<Text dimColor>
|
|
397
|
+
{renaming
|
|
398
|
+
? renaming.saving
|
|
399
|
+
? "Saving…"
|
|
400
|
+
: "Enter save · Esc cancel"
|
|
401
|
+
: hints}
|
|
402
|
+
</Text>
|
|
225
403
|
</Box>
|
|
226
404
|
</Box>
|
|
227
405
|
);
|