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.
Files changed (46) hide show
  1. package/dist/bundle/wave.mjs +1075 -613
  2. package/package.json +4 -3
  3. package/src/cli.tsx +9 -0
  4. package/src/commands/plugin/install.ts +6 -7
  5. package/src/commands/plugin/uninstall.ts +11 -5
  6. package/src/components/ChatInterface.tsx +3 -0
  7. package/src/components/ConfirmationDetails.tsx +36 -15
  8. package/src/components/ConfirmationSelector.tsx +39 -0
  9. package/src/components/HooksManager.tsx +286 -0
  10. package/src/components/InputBox.tsx +125 -51
  11. package/src/components/Markdown.tsx +21 -8
  12. package/src/components/MarketplaceDetail.tsx +10 -27
  13. package/src/components/MarketplaceView.tsx +17 -3
  14. package/src/components/MessageBlockItem.tsx +0 -5
  15. package/src/components/MessageList.tsx +0 -1
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +146 -0
  18. package/src/components/PluginManagerShell.tsx +3 -3
  19. package/src/components/PluginManagerTypes.ts +2 -1
  20. package/src/components/ResumeCommand.tsx +84 -0
  21. package/src/components/ScrolledContent.tsx +71 -0
  22. package/src/components/SessionSelector.tsx +184 -6
  23. package/src/components/SessionSelectorFlow.tsx +246 -0
  24. package/src/constants/commands.ts +30 -0
  25. package/src/contexts/useChat.tsx +252 -98
  26. package/src/daemon/commands.ts +1046 -67
  27. package/src/daemon-cli.ts +6 -0
  28. package/src/hooks/useInputManager.ts +30 -0
  29. package/src/hooks/useLineScroll.ts +58 -0
  30. package/src/hooks/usePluginManager.ts +104 -78
  31. package/src/index.ts +156 -16
  32. package/src/managers/inputHandlers.ts +16 -9
  33. package/src/managers/inputReducer.ts +39 -9
  34. package/src/reducers/confirmationReducer.ts +20 -1
  35. package/src/reducers/hooksManagerReducer.ts +92 -0
  36. package/src/reducers/selectorReducer.ts +3 -0
  37. package/src/session-selector-cli.tsx +9 -255
  38. package/src/stdio/agentBridge.ts +932 -133
  39. package/src/stdio/daemonServer.ts +26 -82
  40. package/src/stdio/protocol.ts +23 -4
  41. package/src/stdio-cli.ts +60 -0
  42. package/src/utils/rewindCheckpoints.ts +2 -2
  43. package/src/utils/sessionOwnership.ts +62 -0
  44. package/src/utils/worktree.ts +175 -50
  45. package/src/components/BangDisplay.tsx +0 -41
  46. 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.1.5",
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.1.5"
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
- const installed = await pluginCore.installPlugin(argv.plugin);
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
- 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
- }
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: { plugin: string }) {
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
- await pluginCore.uninstallPlugin(argv.plugin);
9
- console.log(`Successfully uninstalled plugin: ${argv.plugin}`);
10
- console.log(`Cleaned up plugin configuration from all scopes`);
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!.split("\n").map((line, index) => (
122
- <Box key={`plan-${index}`}>
123
- <Text>{line || " "}</Text>
124
- </Box>
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 < totalLines;
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
- {visibleLines}
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
- ↓ {totalLines - clampedOffset - visibleCount} more
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
+ };