wave-code 1.1.5 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,7 +56,7 @@
56
56
  "wrap-ansi": "^10.0.0",
57
57
  "yargs": "^17.7.2",
58
58
  "zod": "^3.23.8",
59
- "wave-agent-sdk": "1.1.5"
59
+ "wave-agent-sdk": "1.2.0"
60
60
  },
61
61
  "engines": {
62
62
  "node": ">=22"
@@ -14,11 +14,11 @@ export async function installPluginCommand(argv: {
14
14
  );
15
15
  console.log(`Cache path: ${installed.cachePath}`);
16
16
 
17
- if (argv.scope) {
18
- const pluginId = `${installed.name}@${installed.marketplace}`;
19
- await pluginCore.enablePlugin(pluginId, argv.scope);
20
- console.log(`Plugin ${pluginId} enabled in ${argv.scope} scope`);
21
- }
17
+ // When no scope is given, default to user scope (matches Claude Code).
18
+ const scope = argv.scope ?? "user";
19
+ const pluginId = `${installed.name}@${installed.marketplace}`;
20
+ await pluginCore.enablePlugin(pluginId, scope);
21
+ console.log(`Plugin ${pluginId} enabled in ${scope} scope`);
22
22
 
23
23
  process.exit(0);
24
24
  } catch (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}
@@ -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
+ };
@@ -8,6 +8,7 @@ import { BackgroundTaskManager } from "./BackgroundTaskManager.js";
8
8
  import { McpManager } from "./McpManager.js";
9
9
  import { AgentsManager } from "./AgentsManager.js";
10
10
  import { SkillsManager } from "./SkillsManager.js";
11
+ import { HooksManager } from "./HooksManager.js";
11
12
  import { RewindCommand } from "./RewindCommand.js";
12
13
  import { HelpView } from "./HelpView.js";
13
14
  import { StatusCommand } from "./StatusCommand.js";
@@ -18,10 +19,15 @@ import { WorkflowManager } from "./WorkflowManager.js";
18
19
  import { StatusLine } from "./StatusLine.js";
19
20
  import { Notifications } from "./Notifications.js";
20
21
  import { BtwDisplay } from "./BtwDisplay.js";
22
+ import { PlanView } from "./PlanView.js";
21
23
  import { useInputManager } from "../hooks/useInputManager.js";
22
24
  import { useChat } from "../contexts/useChat.js";
23
25
 
24
- import type { McpServerStatus, SlashCommand } from "wave-agent-sdk";
26
+ import type {
27
+ McpServerStatus,
28
+ SlashCommand,
29
+ HookEventConfig,
30
+ } from "wave-agent-sdk";
25
31
 
26
32
  export const INPUT_PLACEHOLDER_TEXT =
27
33
  "Type your message (use /help for more info)...";
@@ -49,6 +55,10 @@ export interface InputBoxProps {
49
55
  // Slash Command related properties
50
56
  slashCommands?: SlashCommand[];
51
57
  hasSlashCommand?: (commandId: string) => boolean;
58
+ // Hooks related properties (for /hooks overlay)
59
+ hooks?: Partial<
60
+ Record<"user" | "project" | "plugin", Record<string, HookEventConfig[]>>
61
+ >;
52
62
  // Token usage
53
63
  latestTotalTokens?: number;
54
64
  maxInputTokens?: number;
@@ -67,6 +77,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
67
77
  disconnectMcpServer = async () => false,
68
78
  slashCommands = [],
69
79
  hasSlashCommand = () => false,
80
+ hooks = {},
70
81
  latestTotalTokens = 0,
71
82
  maxInputTokens = 200000,
72
83
  showLoginHint = false,
@@ -93,6 +104,9 @@ export const InputBox: React.FC<InputBoxProps> = ({
93
104
  setIsBtwActive,
94
105
  agentDefinitions,
95
106
  skills,
107
+ planView,
108
+ setPlanView,
109
+ handlePlanCommand,
96
110
  } = useChat();
97
111
 
98
112
  // Ref to hold setInputText so queue callbacks can access it before useInputManager returns
@@ -146,6 +160,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
146
160
  showModelSelector,
147
161
  showWorkflowManager,
148
162
  showSkillsManager,
163
+ showHooksManager,
149
164
  setShowBackgroundTaskManager,
150
165
  setShowMcpManager,
151
166
  setShowAgentsManager,
@@ -157,6 +172,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
157
172
  setShowModelSelector,
158
173
  setShowWorkflowManager,
159
174
  setShowSkillsManager,
175
+ setShowHooksManager,
160
176
  // Permission mode
161
177
  permissionMode,
162
178
  setPermissionMode,
@@ -180,6 +196,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
180
196
  onAbortMessage: abortMessage,
181
197
  onBackgroundCurrentTask: backgroundCurrentTask,
182
198
  onPermissionModeChange: setChatPermissionMode,
199
+ onPlanCommand: handlePlanCommand,
183
200
  sessionId,
184
201
  workdir: workingDirectory,
185
202
  getFullMessageThread,
@@ -211,6 +228,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
211
228
  // If they are active, we should skip InputBox's global input handling to avoid
212
229
  // duplicate dispatches or state update conflicts.
213
230
  if (
231
+ planView ||
214
232
  showRewindManager ||
215
233
  showHelp ||
216
234
  showStatusCommand ||
@@ -221,7 +239,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
221
239
  showMcpManager ||
222
240
  showAgentsManager ||
223
241
  showWorkflowManager ||
224
- showSkillsManager
242
+ showSkillsManager ||
243
+ showHooksManager
225
244
  ) {
226
245
  return;
227
246
  }
@@ -324,6 +343,13 @@ export const InputBox: React.FC<InputBoxProps> = ({
324
343
  />
325
344
  )}
326
345
 
346
+ {showHooksManager && (
347
+ <HooksManager
348
+ onCancel={() => setShowHooksManager(false)}
349
+ hooks={hooks}
350
+ />
351
+ )}
352
+
327
353
  {showWorkflowManager && (
328
354
  <WorkflowManager onCancel={() => setShowWorkflowManager(false)} />
329
355
  )}
@@ -368,56 +394,67 @@ export const InputBox: React.FC<InputBoxProps> = ({
368
394
  />
369
395
  )}
370
396
 
397
+ {planView && (
398
+ <PlanView
399
+ path={planView.path}
400
+ content={planView.content}
401
+ message={planView.message}
402
+ onCancel={() => setPlanView(null)}
403
+ />
404
+ )}
405
+
371
406
  {btwState.question || btwState.answer
372
407
  ? null
373
- : showBackgroundTaskManager ||
374
- showMcpManager ||
375
- showAgentsManager ||
376
- showSkillsManager ||
377
- showRewindManager ||
378
- showHelp ||
379
- showStatusCommand ||
380
- showLoginCommand ||
381
- showPluginManager ||
382
- showModelSelector ||
383
- showWorkflowManager || (
384
- <Box flexDirection="column">
385
- {escClearPending && (
386
- <Text color="gray">Press Esc again to clear input</Text>
387
- )}
388
- <Box
389
- borderStyle="single"
390
- borderColor="gray"
391
- borderLeft={false}
392
- borderRight={false}
393
- >
394
- <Text color={isPlaceholder ? "gray" : "white"}>
395
- {shouldShowCursor ? (
396
- <>
397
- {beforeCursor}
398
- <Text backgroundColor="white" color="black">
399
- {atCursor}
400
- </Text>
401
- {afterCursor}
402
- </>
403
- ) : (
404
- displayText
405
- )}
406
- </Text>
407
- </Box>
408
- <Box justifyContent="space-between">
409
- <StatusLine
410
- permissionMode={permissionMode}
411
- isShellCommand={isShellCommand}
412
- />
413
- <Notifications
414
- latestTotalTokens={latestTotalTokens}
415
- maxInputTokens={maxInputTokens}
416
- showLoginHint={showLoginHint}
417
- />
408
+ : planView
409
+ ? null
410
+ : showBackgroundTaskManager ||
411
+ showMcpManager ||
412
+ showAgentsManager ||
413
+ showSkillsManager ||
414
+ showRewindManager ||
415
+ showHelp ||
416
+ showStatusCommand ||
417
+ showLoginCommand ||
418
+ showPluginManager ||
419
+ showModelSelector ||
420
+ showWorkflowManager || (
421
+ <Box flexDirection="column">
422
+ {escClearPending && (
423
+ <Text color="gray">Press Esc again to clear input</Text>
424
+ )}
425
+ <Box
426
+ borderStyle="single"
427
+ borderColor="gray"
428
+ borderLeft={false}
429
+ borderRight={false}
430
+ >
431
+ <Text color={isPlaceholder ? "gray" : "white"}>
432
+ {shouldShowCursor ? (
433
+ <>
434
+ {beforeCursor}
435
+ <Text backgroundColor="white" color="black">
436
+ {atCursor}
437
+ </Text>
438
+ {afterCursor}
439
+ </>
440
+ ) : (
441
+ displayText
442
+ )}
443
+ </Text>
444
+ </Box>
445
+ <Box justifyContent="space-between">
446
+ <StatusLine
447
+ permissionMode={permissionMode}
448
+ isShellCommand={isShellCommand}
449
+ />
450
+ <Notifications
451
+ latestTotalTokens={latestTotalTokens}
452
+ maxInputTokens={maxInputTokens}
453
+ showLoginHint={showLoginHint}
454
+ />
455
+ </Box>
418
456
  </Box>
419
- </Box>
420
- )}
457
+ )}
421
458
  </Box>
422
459
  );
423
460
  };
@@ -2,7 +2,6 @@ import React from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import type { Message, MessageBlock } from "wave-agent-sdk";
4
4
  import { MessageSource } from "wave-agent-sdk";
5
- import { BangDisplay } from "./BangDisplay.js";
6
5
  import { ToolDisplay } from "./ToolDisplay.js";
7
6
  import { CompactDisplay } from "./CompactDisplay.js";
8
7
  import { ReasoningDisplay } from "./ReasoningDisplay.js";
@@ -54,10 +53,6 @@ export const MessageBlockItem = ({
54
53
  </Box>
55
54
  )}
56
55
 
57
- {block.type === "bang" && (
58
- <BangDisplay block={block} isExpanded={isExpanded} />
59
- )}
60
-
61
56
  {block.type === "tool" && (
62
57
  <ToolDisplay block={block} isExpanded={isExpanded} />
63
58
  )}
@@ -47,7 +47,6 @@ export const MessageList = React.memo(
47
47
  (b.stage === "running" ||
48
48
  b.stage === "streaming" ||
49
49
  b.stage === "start")) ||
50
- (b.type === "bang" && b.stage === "running") ||
51
50
  (b.type === "reasoning" && b.stage === "streaming") ||
52
51
  (b.type === "text" && b.stage === "streaming");
53
52