wave-code 1.1.5 → 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 +1075 -613
- 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 +3 -0
- package/src/components/ConfirmationDetails.tsx +36 -15
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/HooksManager.tsx +286 -0
- package/src/components/InputBox.tsx +125 -51
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/MessageBlockItem.tsx +0 -5
- package/src/components/MessageList.tsx +0 -1
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +146 -0
- 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 +30 -0
- package/src/contexts/useChat.tsx +252 -98
- package/src/daemon/commands.ts +1046 -67
- package/src/daemon-cli.ts +6 -0
- package/src/hooks/useInputManager.ts +30 -0
- package/src/hooks/useLineScroll.ts +58 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/index.ts +156 -16
- package/src/managers/inputHandlers.ts +16 -9
- package/src/managers/inputReducer.ts +39 -9
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/hooksManagerReducer.ts +92 -0
- package/src/reducers/selectorReducer.ts +3 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +932 -133
- package/src/stdio/daemonServer.ts +26 -82
- package/src/stdio/protocol.ts +23 -4
- package/src/stdio-cli.ts +60 -0
- package/src/utils/rewindCheckpoints.ts +2 -2
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/utils/worktree.ts +175 -50
- package/src/components/BangDisplay.tsx +0 -41
- package/src/components/PlanDisplay.tsx +0 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-code",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "CLI-based code assistant powered by AI, built with React and Ink",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@vscode/ripgrep": "^1.18.0"
|
|
31
|
+
"@vscode/ripgrep": "^1.18.0",
|
|
32
|
+
"sharp": "^0.35.4"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/react": "^19.1.8",
|
|
@@ -56,7 +57,7 @@
|
|
|
56
57
|
"wrap-ansi": "^10.0.0",
|
|
57
58
|
"yargs": "^17.7.2",
|
|
58
59
|
"zod": "^3.23.8",
|
|
59
|
-
"wave-agent-sdk": "1.
|
|
60
|
+
"wave-agent-sdk": "1.3.0"
|
|
60
61
|
},
|
|
61
62
|
"engines": {
|
|
62
63
|
"node": ">=22"
|
package/src/cli.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { render } from "ink";
|
|
3
|
+
import { ensureRuntimeDeps } from "wave-agent-sdk";
|
|
3
4
|
import { App } from "./components/App.js";
|
|
4
5
|
import { cleanupLogs } from "./utils/logger.js";
|
|
5
6
|
import { removeWorktree } from "./utils/worktree.js";
|
|
@@ -11,6 +12,14 @@ export interface CliOptions extends BaseAppProps {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export async function startCli(options: CliOptions): Promise<void> {
|
|
15
|
+
// Install the on-demand runtime dependencies (image codec + ripgrep) before
|
|
16
|
+
// the UI comes up. Waiting is the point: pasting an image or running a search
|
|
17
|
+
// a second later must not race a download. Costs ~16ms once they are
|
|
18
|
+
// installed (marker hit), ~6MB/20MB worth of time on the first run of a
|
|
19
|
+
// host-bundled CLI. A failed download only degrades the affected tool — never
|
|
20
|
+
// throws.
|
|
21
|
+
await ensureRuntimeDeps();
|
|
22
|
+
|
|
14
23
|
const {
|
|
15
24
|
restoreSessionId,
|
|
16
25
|
continueLastSession,
|
|
@@ -8,17 +8,16 @@ export async function installPluginCommand(argv: {
|
|
|
8
8
|
const pluginCore = new PluginCore(workdir);
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
|
-
|
|
11
|
+
// When no scope is given, default to user scope (matches Claude Code).
|
|
12
|
+
const scope = argv.scope ?? "user";
|
|
13
|
+
const installed = await pluginCore.installPlugin(argv.plugin, scope);
|
|
12
14
|
console.log(
|
|
13
15
|
`Successfully installed plugin: ${installed.name} v${installed.version} from ${installed.marketplace}`,
|
|
14
16
|
);
|
|
15
17
|
console.log(`Cache path: ${installed.cachePath}`);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
await pluginCore.enablePlugin(pluginId, argv.scope);
|
|
20
|
-
console.log(`Plugin ${pluginId} enabled in ${argv.scope} scope`);
|
|
21
|
-
}
|
|
18
|
+
console.log(
|
|
19
|
+
`Plugin ${installed.name}@${installed.marketplace} enabled in ${scope} scope`,
|
|
20
|
+
);
|
|
22
21
|
|
|
23
22
|
process.exit(0);
|
|
24
23
|
} catch (error) {
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { PluginCore } from "wave-agent-sdk";
|
|
1
|
+
import { PluginCore, Scope } from "wave-agent-sdk";
|
|
2
2
|
|
|
3
|
-
export async function uninstallPluginCommand(argv: {
|
|
3
|
+
export async function uninstallPluginCommand(argv: {
|
|
4
|
+
plugin: string;
|
|
5
|
+
scope?: Scope;
|
|
6
|
+
}) {
|
|
4
7
|
const workdir = process.cwd();
|
|
5
8
|
const pluginCore = new PluginCore(workdir);
|
|
6
9
|
|
|
7
10
|
try {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
// Only the given scope is uninstalled (spec plugin A-015). Without --scope
|
|
12
|
+
// the core picks the scope the plugin is enabled in for this directory.
|
|
13
|
+
const scope = await pluginCore.uninstallPlugin(argv.plugin, argv.scope);
|
|
14
|
+
console.log(
|
|
15
|
+
`Successfully uninstalled plugin: ${argv.plugin} (scope: ${scope})`,
|
|
16
|
+
);
|
|
11
17
|
process.exit(0);
|
|
12
18
|
} catch (error) {
|
|
13
19
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -29,6 +29,7 @@ export const ChatInterface: React.FC = () => {
|
|
|
29
29
|
maxInputTokens,
|
|
30
30
|
slashCommands,
|
|
31
31
|
hasSlashCommand,
|
|
32
|
+
hooks,
|
|
32
33
|
isConfirmationVisible,
|
|
33
34
|
confirmingTool,
|
|
34
35
|
handleConfirmationDecision,
|
|
@@ -109,6 +110,7 @@ export const ChatInterface: React.FC = () => {
|
|
|
109
110
|
disconnectMcpServer={disconnectMcpServer}
|
|
110
111
|
slashCommands={slashCommands}
|
|
111
112
|
hasSlashCommand={hasSlashCommand}
|
|
113
|
+
hooks={hooks}
|
|
112
114
|
latestTotalTokens={latestTotalTokens}
|
|
113
115
|
maxInputTokens={maxInputTokens}
|
|
114
116
|
showLoginHint={showLoginHint}
|
|
@@ -130,6 +132,7 @@ export const ChatInterface: React.FC = () => {
|
|
|
130
132
|
toolInput={confirmingTool!.input}
|
|
131
133
|
suggestedPrefix={confirmingTool!.suggestedPrefix}
|
|
132
134
|
hidePersistentOption={confirmingTool!.hidePersistentOption}
|
|
135
|
+
outsideSafeZoneDirectory={confirmingTool!.outsideSafeZoneDirectory}
|
|
133
136
|
permissionMode={confirmingTool!.permissionMode}
|
|
134
137
|
isExpanded={isExpanded}
|
|
135
138
|
onDecision={handleConfirmationDecision}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useState } from "react";
|
|
2
|
-
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
3
|
import {
|
|
4
4
|
BASH_TOOL_NAME,
|
|
5
5
|
EDIT_TOOL_NAME,
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
ARTIFACT_TOOL_NAME,
|
|
11
11
|
} from "wave-agent-sdk";
|
|
12
12
|
import { buildDiffLinesFromParams } from "./DiffDisplay.js";
|
|
13
|
+
import { renderMarkdownToAnsi } from "./Markdown.js";
|
|
14
|
+
import { ScrolledContent } from "./ScrolledContent.js";
|
|
13
15
|
import { highlightToAnsi } from "../utils/highlightUtils.js";
|
|
14
16
|
|
|
15
17
|
// Helper function to generate descriptive action text
|
|
@@ -35,6 +37,9 @@ const getActionDescription = (
|
|
|
35
37
|
case ASK_USER_QUESTION_TOOL_NAME:
|
|
36
38
|
return "Answer questions to clarify intent";
|
|
37
39
|
case ARTIFACT_TOOL_NAME:
|
|
40
|
+
if (toolInput.action === "read") {
|
|
41
|
+
return `Read artifact: ${toolInput.url || "unknown artifact"}`;
|
|
42
|
+
}
|
|
38
43
|
return `Publish file: ${toolInput.file_path || "unknown file"}`;
|
|
39
44
|
default:
|
|
40
45
|
return "Execute operation";
|
|
@@ -68,6 +73,11 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
|
|
|
68
73
|
(toolInput?.startLineNumber as number | undefined) ??
|
|
69
74
|
(toolName === WRITE_TOOL_NAME ? 1 : undefined);
|
|
70
75
|
|
|
76
|
+
// Terminal width is needed to render the plan's Markdown (table column widths
|
|
77
|
+
// are computed from it), matching <Markdown>'s own default.
|
|
78
|
+
const { stdout } = useStdout();
|
|
79
|
+
const columns = stdout?.columns ?? 80;
|
|
80
|
+
|
|
71
81
|
const headerRows = 2 + (warning ? 1 : 0);
|
|
72
82
|
|
|
73
83
|
// Number of content rows rendered at once. The header stays fixed above the
|
|
@@ -117,12 +127,17 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
|
|
|
117
127
|
toolName === EXIT_PLAN_MODE_TOOL_NAME &&
|
|
118
128
|
!!planContent;
|
|
119
129
|
|
|
130
|
+
// The plan is Markdown (the agent writes it to a .md file), so it is rendered
|
|
131
|
+
// with the same renderer as assistant messages and then linearized into rows
|
|
132
|
+
// for the row-slicing scroll above — same treatment as the JSON/diff rows.
|
|
120
133
|
const planLines = showPlan
|
|
121
|
-
? planContent
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
? renderMarkdownToAnsi(planContent!, columns)
|
|
135
|
+
.split("\n")
|
|
136
|
+
.map((line, index) => (
|
|
137
|
+
<Box key={`plan-${index}`}>
|
|
138
|
+
<Text>{line || " "}</Text>
|
|
139
|
+
</Box>
|
|
140
|
+
))
|
|
126
141
|
: [];
|
|
127
142
|
|
|
128
143
|
const contentLines: React.ReactNode[] = [
|
|
@@ -130,9 +145,11 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
|
|
|
130
145
|
...jsonLines,
|
|
131
146
|
...planLines,
|
|
132
147
|
];
|
|
133
|
-
const totalLines = contentLines.length;
|
|
134
|
-
const maxScroll = Math.max(0, totalLines - visibleCount);
|
|
135
148
|
|
|
149
|
+
// Rows really occupied by the content (wrapped rows included), measured after
|
|
150
|
+
// layout by <ScrolledContent> — see that component for why it is measured
|
|
151
|
+
// instead of counted from the row array.
|
|
152
|
+
const [contentRows, setContentRows] = useState(0);
|
|
136
153
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
137
154
|
|
|
138
155
|
// Reset scrolling when a new confirmation appears.
|
|
@@ -140,6 +157,8 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
|
|
|
140
157
|
setScrollOffset(0);
|
|
141
158
|
}, [toolName, toolInput, planContent]);
|
|
142
159
|
|
|
160
|
+
const maxScroll = Math.max(0, contentRows - visibleCount);
|
|
161
|
+
|
|
143
162
|
const halfPage = Math.max(1, Math.ceil(visibleCount / 2));
|
|
144
163
|
useInput((input, key) => {
|
|
145
164
|
if (key.pageUp) {
|
|
@@ -159,12 +178,8 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
|
|
|
159
178
|
});
|
|
160
179
|
|
|
161
180
|
const clampedOffset = Math.min(scrollOffset, maxScroll);
|
|
162
|
-
const visibleLines = contentLines.slice(
|
|
163
|
-
clampedOffset,
|
|
164
|
-
clampedOffset + visibleCount,
|
|
165
|
-
);
|
|
166
181
|
const hasMoreAbove = clampedOffset > 0;
|
|
167
|
-
const hasMoreBelow = clampedOffset + visibleCount <
|
|
182
|
+
const hasMoreBelow = clampedOffset + visibleCount < contentRows;
|
|
168
183
|
|
|
169
184
|
return (
|
|
170
185
|
<Box
|
|
@@ -195,11 +210,17 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
|
|
|
195
210
|
</Text>
|
|
196
211
|
</Box>
|
|
197
212
|
)}
|
|
198
|
-
|
|
213
|
+
<ScrolledContent
|
|
214
|
+
visibleRows={visibleCount}
|
|
215
|
+
scrollOffset={clampedOffset}
|
|
216
|
+
onMeasuredRows={setContentRows}
|
|
217
|
+
>
|
|
218
|
+
{contentLines}
|
|
219
|
+
</ScrolledContent>
|
|
199
220
|
{hasMoreBelow && (
|
|
200
221
|
<Box>
|
|
201
222
|
<Text color="gray" dimColor>
|
|
202
|
-
↓ {
|
|
223
|
+
↓ {contentRows - clampedOffset - visibleCount} more
|
|
203
224
|
</Text>
|
|
204
225
|
</Box>
|
|
205
226
|
)}
|
|
@@ -7,8 +7,10 @@ import type {
|
|
|
7
7
|
} from "wave-agent-sdk";
|
|
8
8
|
import {
|
|
9
9
|
BASH_TOOL_NAME,
|
|
10
|
+
EDIT_TOOL_NAME,
|
|
10
11
|
EXIT_PLAN_MODE_TOOL_NAME,
|
|
11
12
|
ENTER_PLAN_MODE_TOOL_NAME,
|
|
13
|
+
WRITE_TOOL_NAME,
|
|
12
14
|
ASK_USER_QUESTION_TOOL_NAME,
|
|
13
15
|
} from "wave-agent-sdk";
|
|
14
16
|
import { confirmationReducer } from "../reducers/confirmationReducer.js";
|
|
@@ -24,11 +26,19 @@ const getHeaderColor = (header: string) => {
|
|
|
24
26
|
return colors[Math.abs(hash) % colors.length];
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
/** Last path segment of a directory path (handles both `/` and `\` separators). */
|
|
30
|
+
const lastPathSegment = (dirPath: string): string => {
|
|
31
|
+
const segments = dirPath.split(/[\\/]/).filter(Boolean);
|
|
32
|
+
return segments[segments.length - 1] ?? dirPath;
|
|
33
|
+
};
|
|
34
|
+
|
|
27
35
|
export interface ConfirmationSelectorProps {
|
|
28
36
|
toolName: string;
|
|
29
37
|
toolInput?: Record<string, unknown>;
|
|
30
38
|
suggestedPrefix?: string;
|
|
31
39
|
hidePersistentOption?: boolean;
|
|
40
|
+
/** Directory holding an out-of-Safe-Zone Write/Edit target, when the host reports one. */
|
|
41
|
+
outsideSafeZoneDirectory?: string;
|
|
32
42
|
permissionMode?: PermissionMode;
|
|
33
43
|
isExpanded?: boolean;
|
|
34
44
|
onDecision: (decision: PermissionDecision) => void;
|
|
@@ -40,6 +50,7 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
|
|
|
40
50
|
toolInput,
|
|
41
51
|
suggestedPrefix,
|
|
42
52
|
hidePersistentOption,
|
|
53
|
+
outsideSafeZoneDirectory,
|
|
43
54
|
permissionMode,
|
|
44
55
|
isExpanded = false,
|
|
45
56
|
onDecision,
|
|
@@ -104,6 +115,15 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
|
|
|
104
115
|
return "Yes, and auto-accept edits";
|
|
105
116
|
};
|
|
106
117
|
|
|
118
|
+
// Only Write/Edit can be out of the Safe Zone, and the host only reports the
|
|
119
|
+
// directory for such requests.
|
|
120
|
+
const showAddDirectoryOption =
|
|
121
|
+
(toolName === EDIT_TOOL_NAME || toolName === WRITE_TOOL_NAME) &&
|
|
122
|
+
!!outsideSafeZoneDirectory;
|
|
123
|
+
const addDirectoryOptionText = `Yes, and allow all edits in ${lastPathSegment(
|
|
124
|
+
outsideSafeZoneDirectory ?? "",
|
|
125
|
+
)}/ this session`;
|
|
126
|
+
|
|
107
127
|
const pasteDetectorRef = useRef(createBracketedPasteDetector());
|
|
108
128
|
|
|
109
129
|
useInput((input, key) => {
|
|
@@ -140,6 +160,7 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
|
|
|
140
160
|
toolInput,
|
|
141
161
|
suggestedPrefix,
|
|
142
162
|
hidePersistentOption,
|
|
163
|
+
outsideSafeZoneDirectory,
|
|
143
164
|
permissionMode,
|
|
144
165
|
});
|
|
145
166
|
}
|
|
@@ -248,6 +269,24 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
|
|
|
248
269
|
: "Yes, proceed"}
|
|
249
270
|
</Text>
|
|
250
271
|
</Box>
|
|
272
|
+
{showAddDirectoryOption && (
|
|
273
|
+
<Box key="add-directory-option">
|
|
274
|
+
<Text
|
|
275
|
+
color={
|
|
276
|
+
state.selectedOption === "addDirectory" ? "black" : "white"
|
|
277
|
+
}
|
|
278
|
+
backgroundColor={
|
|
279
|
+
state.selectedOption === "addDirectory"
|
|
280
|
+
? "yellow"
|
|
281
|
+
: undefined
|
|
282
|
+
}
|
|
283
|
+
bold={state.selectedOption === "addDirectory"}
|
|
284
|
+
>
|
|
285
|
+
{state.selectedOption === "addDirectory" ? "> " : " "}
|
|
286
|
+
{addDirectoryOptionText}
|
|
287
|
+
</Text>
|
|
288
|
+
</Box>
|
|
289
|
+
)}
|
|
251
290
|
{!hidePersistentOption && (
|
|
252
291
|
<Box key="auto-option">
|
|
253
292
|
<Text
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useReducer } from "react";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
|
+
import {
|
|
4
|
+
HOOK_EVENT_SUMMARIES,
|
|
5
|
+
type HookEvent,
|
|
6
|
+
type HookEventConfig,
|
|
7
|
+
} from "wave-agent-sdk";
|
|
8
|
+
import {
|
|
9
|
+
hooksManagerReducer,
|
|
10
|
+
type HooksManagerState,
|
|
11
|
+
} from "../reducers/hooksManagerReducer.js";
|
|
12
|
+
|
|
13
|
+
export interface HooksManagerProps {
|
|
14
|
+
onCancel: () => void;
|
|
15
|
+
/** Hook configs per scope (user/project/plugin settings.json + plugin set). */
|
|
16
|
+
hooks: Partial<
|
|
17
|
+
Record<"user" | "project" | "plugin", Record<string, HookEventConfig[]>>
|
|
18
|
+
>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DisplayEntry {
|
|
22
|
+
kind: "header" | "hook" | "empty";
|
|
23
|
+
label: string;
|
|
24
|
+
sub?: string;
|
|
25
|
+
scope?: "user" | "project" | "plugin";
|
|
26
|
+
selectableIndex: number; // -1 for non-selectable rows
|
|
27
|
+
hook?: {
|
|
28
|
+
event: string;
|
|
29
|
+
matcher?: string;
|
|
30
|
+
config: HookEventConfig;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SCOPE_LABELS: Record<"user" | "project" | "plugin", string> = {
|
|
35
|
+
user: "User hooks",
|
|
36
|
+
project: "Project hooks",
|
|
37
|
+
plugin: "Plugin hooks",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SCOPE_ORDER: Array<"user" | "project" | "plugin"> = [
|
|
41
|
+
"user",
|
|
42
|
+
"project",
|
|
43
|
+
"plugin",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const initialState: HooksManagerState = {
|
|
47
|
+
selectedIndex: 0,
|
|
48
|
+
viewMode: "list",
|
|
49
|
+
pendingEffect: null,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Hook name for display: `Event:Matcher` when a matcher is present, else the
|
|
53
|
+
* event name alone. */
|
|
54
|
+
function formatHookName(event: string, matcher?: string): string {
|
|
55
|
+
return matcher ? `${event}:${matcher}` : event;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const HooksManager: React.FC<HooksManagerProps> = ({
|
|
59
|
+
onCancel,
|
|
60
|
+
hooks,
|
|
61
|
+
}) => {
|
|
62
|
+
const [state, dispatch] = useReducer(hooksManagerReducer, initialState);
|
|
63
|
+
const { stdout } = useStdout();
|
|
64
|
+
|
|
65
|
+
// Handle pending effects
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!state.pendingEffect) return;
|
|
68
|
+
const effect = state.pendingEffect;
|
|
69
|
+
dispatch({ type: "CLEAR_PENDING_EFFECT" });
|
|
70
|
+
if (effect.type === "CANCEL") {
|
|
71
|
+
onCancel();
|
|
72
|
+
}
|
|
73
|
+
}, [state.pendingEffect, onCancel]);
|
|
74
|
+
|
|
75
|
+
// Flatten hooks (grouped by scope) into one navigable list. Headers and
|
|
76
|
+
// the empty-state line are non-selectable.
|
|
77
|
+
const entries = useMemo<DisplayEntry[]>(() => {
|
|
78
|
+
const result: DisplayEntry[] = [];
|
|
79
|
+
let selectableCount = 0;
|
|
80
|
+
|
|
81
|
+
result.push({ kind: "header", label: "HOOKS", selectableIndex: -1 });
|
|
82
|
+
let hookCount = 0;
|
|
83
|
+
for (const scope of SCOPE_ORDER) {
|
|
84
|
+
const scoped = hooks[scope] ?? {};
|
|
85
|
+
const rows: DisplayEntry[] = [];
|
|
86
|
+
for (const [event, configs] of Object.entries(scoped)) {
|
|
87
|
+
for (const config of configs ?? []) {
|
|
88
|
+
const matcher = config.matcher;
|
|
89
|
+
const name = formatHookName(event, matcher);
|
|
90
|
+
rows.push({
|
|
91
|
+
kind: "hook",
|
|
92
|
+
label: name,
|
|
93
|
+
sub:
|
|
94
|
+
HOOK_EVENT_SUMMARIES[event as HookEvent] ??
|
|
95
|
+
config.hooks[0]?.command,
|
|
96
|
+
scope,
|
|
97
|
+
selectableIndex: selectableCount++,
|
|
98
|
+
hook: { event, matcher, config },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (rows.length === 0) continue;
|
|
103
|
+
result.push({
|
|
104
|
+
kind: "header",
|
|
105
|
+
label: SCOPE_LABELS[scope],
|
|
106
|
+
scope,
|
|
107
|
+
selectableIndex: -1,
|
|
108
|
+
});
|
|
109
|
+
result.push(...rows);
|
|
110
|
+
hookCount += rows.length;
|
|
111
|
+
}
|
|
112
|
+
if (hookCount === 0) {
|
|
113
|
+
result.push({
|
|
114
|
+
kind: "empty",
|
|
115
|
+
label: "No hooks configured",
|
|
116
|
+
selectableIndex: -1,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}, [hooks]);
|
|
122
|
+
|
|
123
|
+
const itemCount = entries.filter((e) => e.selectableIndex >= 0).length;
|
|
124
|
+
|
|
125
|
+
// Window slice: center the selected item within the visible area, clamping
|
|
126
|
+
// to the terminal's available rows (reusable pattern from
|
|
127
|
+
// AgentsManager/SkillsManager).
|
|
128
|
+
const availableRows = stdout?.rows ?? 24;
|
|
129
|
+
const maxVisible = Math.max(3, Math.min(15, availableRows - 12));
|
|
130
|
+
const selectedFlatIndex = entries.findIndex(
|
|
131
|
+
(e) => e.selectableIndex === state.selectedIndex,
|
|
132
|
+
);
|
|
133
|
+
const startIndex = Math.max(
|
|
134
|
+
0,
|
|
135
|
+
Math.min(
|
|
136
|
+
selectedFlatIndex - Math.floor(maxVisible / 2),
|
|
137
|
+
Math.max(0, entries.length - maxVisible),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
const visibleEntries = entries.slice(startIndex, startIndex + maxVisible);
|
|
141
|
+
|
|
142
|
+
useInput((input, key) => {
|
|
143
|
+
dispatch({ type: "HANDLE_KEY", input, key, itemCount });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const selectedEntry = entries.find(
|
|
147
|
+
(e) => e.selectableIndex === state.selectedIndex,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Detail view — body renders fully expanded with no height limit, no
|
|
151
|
+
// clipping and no scrolling (aligned with AgentsManager/SkillsManager).
|
|
152
|
+
if (state.viewMode === "detail" && selectedEntry?.hook) {
|
|
153
|
+
const { event, matcher, config } = selectedEntry.hook;
|
|
154
|
+
const summary = HOOK_EVENT_SUMMARIES[event as HookEvent];
|
|
155
|
+
return (
|
|
156
|
+
<Box
|
|
157
|
+
flexDirection="column"
|
|
158
|
+
borderStyle="single"
|
|
159
|
+
borderColor="cyan"
|
|
160
|
+
borderBottom={false}
|
|
161
|
+
borderLeft={false}
|
|
162
|
+
borderRight={false}
|
|
163
|
+
paddingTop={1}
|
|
164
|
+
gap={1}
|
|
165
|
+
>
|
|
166
|
+
<Box>
|
|
167
|
+
<Text color="cyan" bold>
|
|
168
|
+
Hook: {formatHookName(event, matcher)}
|
|
169
|
+
</Text>
|
|
170
|
+
</Box>
|
|
171
|
+
|
|
172
|
+
<Box flexDirection="column" gap={1}>
|
|
173
|
+
<Box>
|
|
174
|
+
<Text wrap="wrap">
|
|
175
|
+
<Text color="blue">Event:</Text> {event}
|
|
176
|
+
{summary ? ` — ${summary}` : ""}
|
|
177
|
+
</Text>
|
|
178
|
+
</Box>
|
|
179
|
+
{matcher && (
|
|
180
|
+
<Box>
|
|
181
|
+
<Text>
|
|
182
|
+
<Text color="blue">Matcher:</Text> {matcher}
|
|
183
|
+
</Text>
|
|
184
|
+
</Box>
|
|
185
|
+
)}
|
|
186
|
+
<Box flexDirection="column" gap={0}>
|
|
187
|
+
<Text color="blue">Commands:</Text>
|
|
188
|
+
{config.hooks.map((hook, index) => (
|
|
189
|
+
<Text key={index} wrap="wrap">
|
|
190
|
+
{index + 1}. {hook.command}
|
|
191
|
+
{hook.async ? " (async)" : ""}
|
|
192
|
+
{hook.timeout ? ` (timeout ${hook.timeout}s)` : ""}
|
|
193
|
+
</Text>
|
|
194
|
+
))}
|
|
195
|
+
</Box>
|
|
196
|
+
</Box>
|
|
197
|
+
|
|
198
|
+
<Box marginTop={1}>
|
|
199
|
+
<Text dimColor>Esc or Enter to go back</Text>
|
|
200
|
+
</Box>
|
|
201
|
+
</Box>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (itemCount === 0) {
|
|
206
|
+
return (
|
|
207
|
+
<Box
|
|
208
|
+
flexDirection="column"
|
|
209
|
+
borderStyle="single"
|
|
210
|
+
borderColor="cyan"
|
|
211
|
+
borderBottom={false}
|
|
212
|
+
borderLeft={false}
|
|
213
|
+
borderRight={false}
|
|
214
|
+
paddingTop={1}
|
|
215
|
+
>
|
|
216
|
+
<Text color="cyan" bold>
|
|
217
|
+
Hooks
|
|
218
|
+
</Text>
|
|
219
|
+
<Text>No hooks configured</Text>
|
|
220
|
+
<Text dimColor>
|
|
221
|
+
Configure hooks in the hooks field of ~/.wave/settings.json or
|
|
222
|
+
.wave/settings.json, or ask Claude to set one up for you
|
|
223
|
+
</Text>
|
|
224
|
+
<Text dimColor>Press Escape to close</Text>
|
|
225
|
+
</Box>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<Box
|
|
231
|
+
flexDirection="column"
|
|
232
|
+
borderStyle="single"
|
|
233
|
+
borderColor="cyan"
|
|
234
|
+
borderBottom={false}
|
|
235
|
+
borderLeft={false}
|
|
236
|
+
borderRight={false}
|
|
237
|
+
paddingTop={1}
|
|
238
|
+
gap={1}
|
|
239
|
+
>
|
|
240
|
+
<Box>
|
|
241
|
+
<Text color="cyan" bold>
|
|
242
|
+
Hooks
|
|
243
|
+
</Text>
|
|
244
|
+
</Box>
|
|
245
|
+
<Text dimColor>Select a hook to view details</Text>
|
|
246
|
+
|
|
247
|
+
<Box flexDirection="column">
|
|
248
|
+
{visibleEntries.map((entry, index) => {
|
|
249
|
+
const isSelected = entry.selectableIndex === state.selectedIndex;
|
|
250
|
+
if (entry.kind === "header") {
|
|
251
|
+
return (
|
|
252
|
+
<Text key={`${entry.kind}-${entry.label}-${index}`} dimColor bold>
|
|
253
|
+
{entry.label}
|
|
254
|
+
</Text>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (entry.kind === "empty") {
|
|
258
|
+
return (
|
|
259
|
+
<Text key={`empty-${index}`} dimColor>
|
|
260
|
+
{entry.label}
|
|
261
|
+
</Text>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return (
|
|
265
|
+
<Text
|
|
266
|
+
key={`${entry.kind}-${entry.selectableIndex}`}
|
|
267
|
+
color={isSelected ? "black" : "white"}
|
|
268
|
+
backgroundColor={isSelected ? "cyan" : undefined}
|
|
269
|
+
wrap="truncate-end"
|
|
270
|
+
>
|
|
271
|
+
{isSelected ? "▶ " : " "}
|
|
272
|
+
{entry.selectableIndex + 1}. {entry.label}
|
|
273
|
+
{entry.sub ? ` · ${entry.sub}` : ""}
|
|
274
|
+
</Text>
|
|
275
|
+
);
|
|
276
|
+
})}
|
|
277
|
+
</Box>
|
|
278
|
+
|
|
279
|
+
<Box marginTop={1}>
|
|
280
|
+
<Text dimColor>
|
|
281
|
+
↑/↓ to select · Enter to view details · Esc to close
|
|
282
|
+
</Text>
|
|
283
|
+
</Box>
|
|
284
|
+
</Box>
|
|
285
|
+
);
|
|
286
|
+
};
|