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
@@ -8,7 +8,9 @@ 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";
13
+ import { ResumeCommand } from "./ResumeCommand.js";
12
14
  import { HelpView } from "./HelpView.js";
13
15
  import { StatusCommand } from "./StatusCommand.js";
14
16
  import { LoginCommand } from "./LoginCommand.js";
@@ -18,10 +20,15 @@ import { WorkflowManager } from "./WorkflowManager.js";
18
20
  import { StatusLine } from "./StatusLine.js";
19
21
  import { Notifications } from "./Notifications.js";
20
22
  import { BtwDisplay } from "./BtwDisplay.js";
23
+ import { PlanView } from "./PlanView.js";
21
24
  import { useInputManager } from "../hooks/useInputManager.js";
22
25
  import { useChat } from "../contexts/useChat.js";
23
26
 
24
- import type { McpServerStatus, SlashCommand } from "wave-agent-sdk";
27
+ import type {
28
+ McpServerStatus,
29
+ SlashCommand,
30
+ HookEventConfig,
31
+ } from "wave-agent-sdk";
25
32
 
26
33
  export const INPUT_PLACEHOLDER_TEXT =
27
34
  "Type your message (use /help for more info)...";
@@ -49,6 +56,10 @@ export interface InputBoxProps {
49
56
  // Slash Command related properties
50
57
  slashCommands?: SlashCommand[];
51
58
  hasSlashCommand?: (commandId: string) => boolean;
59
+ // Hooks related properties (for /hooks overlay)
60
+ hooks?: Partial<
61
+ Record<"user" | "project" | "plugin", Record<string, HookEventConfig[]>>
62
+ >;
52
63
  // Token usage
53
64
  latestTotalTokens?: number;
54
65
  maxInputTokens?: number;
@@ -67,6 +78,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
67
78
  disconnectMcpServer = async () => false,
68
79
  slashCommands = [],
69
80
  hasSlashCommand = () => false,
81
+ hooks = {},
70
82
  latestTotalTokens = 0,
71
83
  maxInputTokens = 200000,
72
84
  showLoginHint = false,
@@ -75,6 +87,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
75
87
  permissionMode: chatPermissionMode,
76
88
  setPermissionMode: setChatPermissionMode,
77
89
  handleRewindSelect,
90
+ resumeSession,
78
91
  backgroundCurrentTask,
79
92
  messages,
80
93
  getFullMessageThread,
@@ -84,15 +97,21 @@ export const InputBox: React.FC<InputBoxProps> = ({
84
97
  clearMessages,
85
98
  compact,
86
99
  addDir,
100
+ renameSession,
87
101
  currentModel,
88
102
  configuredModels,
89
103
  setModel,
90
- recreateAgent,
104
+ reloadPlugins,
105
+ notifyPluginChange,
106
+ pluginHint,
91
107
  recallQueuedMessage,
92
108
  queuedMessages,
93
109
  setIsBtwActive,
94
110
  agentDefinitions,
95
111
  skills,
112
+ planView,
113
+ setPlanView,
114
+ handlePlanCommand,
96
115
  } = useChat();
97
116
 
98
117
  // Ref to hold setInputText so queue callbacks can access it before useInputManager returns
@@ -139,6 +158,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
139
158
  showMcpManager,
140
159
  showAgentsManager,
141
160
  showRewindManager,
161
+ showResumeSelector,
142
162
  showHelp,
143
163
  showStatusCommand,
144
164
  showLoginCommand,
@@ -146,10 +166,12 @@ export const InputBox: React.FC<InputBoxProps> = ({
146
166
  showModelSelector,
147
167
  showWorkflowManager,
148
168
  showSkillsManager,
169
+ showHooksManager,
149
170
  setShowBackgroundTaskManager,
150
171
  setShowMcpManager,
151
172
  setShowAgentsManager,
152
173
  setShowRewindManager,
174
+ setShowResumeSelector,
153
175
  setShowHelp,
154
176
  setShowStatusCommand,
155
177
  setShowLoginCommand,
@@ -157,6 +179,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
157
179
  setShowModelSelector,
158
180
  setShowWorkflowManager,
159
181
  setShowSkillsManager,
182
+ setShowHooksManager,
160
183
  // Permission mode
161
184
  permissionMode,
162
185
  setPermissionMode,
@@ -175,11 +198,14 @@ export const InputBox: React.FC<InputBoxProps> = ({
175
198
  onAskBtw: askBtw,
176
199
  onClearMessages: clearMessages,
177
200
  onCompact: compact,
201
+ onReloadPlugins: reloadPlugins,
178
202
  onAddDir: addDir,
203
+ onRenameSession: renameSession,
179
204
  onHasSlashCommand: hasSlashCommand,
180
205
  onAbortMessage: abortMessage,
181
206
  onBackgroundCurrentTask: backgroundCurrentTask,
182
207
  onPermissionModeChange: setChatPermissionMode,
208
+ onPlanCommand: handlePlanCommand,
183
209
  sessionId,
184
210
  workdir: workingDirectory,
185
211
  getFullMessageThread,
@@ -211,7 +237,9 @@ export const InputBox: React.FC<InputBoxProps> = ({
211
237
  // If they are active, we should skip InputBox's global input handling to avoid
212
238
  // duplicate dispatches or state update conflicts.
213
239
  if (
240
+ planView ||
214
241
  showRewindManager ||
242
+ showResumeSelector ||
215
243
  showHelp ||
216
244
  showStatusCommand ||
217
245
  showLoginCommand ||
@@ -221,7 +249,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
221
249
  showMcpManager ||
222
250
  showAgentsManager ||
223
251
  showWorkflowManager ||
224
- showSkillsManager
252
+ showSkillsManager ||
253
+ showHooksManager
225
254
  ) {
226
255
  return;
227
256
  }
@@ -264,6 +293,21 @@ export const InputBox: React.FC<InputBoxProps> = ({
264
293
  await handleRewindSelect(index);
265
294
  };
266
295
 
296
+ const handleResumeSelectWithClose = async (
297
+ sessionId: string,
298
+ resumeWorkdir?: string,
299
+ ) => {
300
+ setShowResumeSelector(false);
301
+ // The switch replaces the whole conversation; a draft typed for the old
302
+ // session would otherwise silently become the first message of the new one.
303
+ setInputText("");
304
+ await resumeSession(sessionId, resumeWorkdir);
305
+ };
306
+
307
+ const handleResumeCancel = () => {
308
+ setShowResumeSelector(false);
309
+ };
310
+
267
311
  return (
268
312
  <Box flexDirection="column">
269
313
  <BtwDisplay btwState={btwState} />
@@ -324,6 +368,13 @@ export const InputBox: React.FC<InputBoxProps> = ({
324
368
  />
325
369
  )}
326
370
 
371
+ {showHooksManager && (
372
+ <HooksManager
373
+ onCancel={() => setShowHooksManager(false)}
374
+ hooks={hooks}
375
+ />
376
+ )}
377
+
327
378
  {showWorkflowManager && (
328
379
  <WorkflowManager onCancel={() => setShowWorkflowManager(false)} />
329
380
  )}
@@ -337,6 +388,15 @@ export const InputBox: React.FC<InputBoxProps> = ({
337
388
  />
338
389
  )}
339
390
 
391
+ {showResumeSelector && (
392
+ <ResumeCommand
393
+ workdir={workingDirectory}
394
+ isBusy={!isIdle}
395
+ onSelect={handleResumeSelectWithClose}
396
+ onCancel={handleResumeCancel}
397
+ />
398
+ )}
399
+
340
400
  {showHelp && (
341
401
  <HelpView
342
402
  onCancel={() => setShowHelp(false)}
@@ -355,7 +415,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
355
415
  {showPluginManager && (
356
416
  <PluginManagerShell
357
417
  onCancel={() => setShowPluginManager(false)}
358
- onPluginInstalled={recreateAgent}
418
+ onPluginChanged={notifyPluginChange}
359
419
  />
360
420
  )}
361
421
 
@@ -368,56 +428,70 @@ export const InputBox: React.FC<InputBoxProps> = ({
368
428
  />
369
429
  )}
370
430
 
431
+ {planView && (
432
+ <PlanView
433
+ path={planView.path}
434
+ content={planView.content}
435
+ message={planView.message}
436
+ onCancel={() => setPlanView(null)}
437
+ />
438
+ )}
439
+
371
440
  {btwState.question || btwState.answer
372
441
  ? 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
- />
442
+ : planView
443
+ ? null
444
+ : showBackgroundTaskManager ||
445
+ showMcpManager ||
446
+ showAgentsManager ||
447
+ showSkillsManager ||
448
+ showHooksManager ||
449
+ showRewindManager ||
450
+ showResumeSelector ||
451
+ showHelp ||
452
+ showStatusCommand ||
453
+ showLoginCommand ||
454
+ showPluginManager ||
455
+ showModelSelector ||
456
+ showWorkflowManager || (
457
+ <Box flexDirection="column">
458
+ {escClearPending && (
459
+ <Text color="gray">Press Esc again to clear input</Text>
460
+ )}
461
+ <Box
462
+ borderStyle="single"
463
+ borderColor="gray"
464
+ borderLeft={false}
465
+ borderRight={false}
466
+ >
467
+ <Text color={isPlaceholder ? "gray" : "white"}>
468
+ {shouldShowCursor ? (
469
+ <>
470
+ {beforeCursor}
471
+ <Text backgroundColor="white" color="black">
472
+ {atCursor}
473
+ </Text>
474
+ {afterCursor}
475
+ </>
476
+ ) : (
477
+ displayText
478
+ )}
479
+ </Text>
480
+ </Box>
481
+ <Box justifyContent="space-between">
482
+ <StatusLine
483
+ permissionMode={permissionMode}
484
+ isShellCommand={isShellCommand}
485
+ />
486
+ <Notifications
487
+ latestTotalTokens={latestTotalTokens}
488
+ maxInputTokens={maxInputTokens}
489
+ showLoginHint={showLoginHint}
490
+ pluginHint={pluginHint}
491
+ />
492
+ </Box>
418
493
  </Box>
419
- </Box>
420
- )}
494
+ )}
421
495
  </Box>
422
496
  );
423
497
  };
@@ -137,21 +137,34 @@ class AnsiRenderer extends Renderer<string> {
137
137
 
138
138
  const createRenderer = (columns: number) => new AnsiRenderer(columns);
139
139
 
140
+ /** Renders Markdown to ANSI-styled text using the same renderer as <Markdown>.
141
+ * Callers that must linearize content into rows (e.g. the confirmation
142
+ * details area, which slices rows for PgUp/PgDn scrolling) render first and
143
+ * then split the result into lines. */
144
+ export const renderMarkdownToAnsi = (
145
+ content: string,
146
+ columns: number,
147
+ ): string => {
148
+ const ansi = marked.parse(content, {
149
+ renderer: createRenderer(columns),
150
+ gfm: true,
151
+ breaks: true,
152
+ }) as string;
153
+ return ansi.trim();
154
+ };
155
+
140
156
  // Markdown component using custom ANSI renderer
141
157
  export const Markdown = React.memo(({ children }: MarkdownProps) => {
142
158
  const { stdout } = useStdout();
143
159
  const columns = stdout?.columns ?? 80;
144
- const ansiContent = useMemo(() => {
145
- return marked.parse(children, {
146
- renderer: createRenderer(columns),
147
- gfm: true,
148
- breaks: true,
149
- }) as string;
150
- }, [children, columns]);
160
+ const ansiContent = useMemo(
161
+ () => renderMarkdownToAnsi(children, columns),
162
+ [children, columns],
163
+ );
151
164
 
152
165
  return (
153
166
  <Box flexDirection="column">
154
- <Text>{ansiContent.trim()}</Text>
167
+ <Text>{ansiContent}</Text>
155
168
  </Box>
156
169
  );
157
170
  });
@@ -1,4 +1,4 @@
1
- import React, { useReducer, useEffect, useMemo } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
4
4
  import {
@@ -6,6 +6,13 @@ import {
6
6
  type SelectorState,
7
7
  } from "../reducers/selectorReducer.js";
8
8
 
9
+ const ACTIONS = [
10
+ // 先刷新该市场检出,再把该市场内所有可更新插件一次升掉(范围恒为当前市场,
11
+ // spec 插件市场场景 13)
12
+ { id: "update", label: "Update plugins (batch)" },
13
+ { id: "remove", label: "Remove marketplace" },
14
+ ] as const;
15
+
9
16
  export const MarketplaceDetail: React.FC = () => {
10
17
  const { state, marketplaces, actions } = usePluginManagerContext();
11
18
  const [selectorState, dispatch] = useReducer(
@@ -25,23 +32,10 @@ export const MarketplaceDetail: React.FC = () => {
25
32
 
26
33
  const marketplace = marketplaces.find((m) => m.name === state.selectedId);
27
34
 
28
- const ACTIONS = useMemo(
29
- () =>
30
- [
31
- {
32
- id: "toggle-auto-update",
33
- label: `${marketplace?.autoUpdate ? "Disable" : "Enable"} auto-update`,
34
- },
35
- { id: "update", label: "Update marketplace" },
36
- { id: "remove", label: "Remove marketplace" },
37
- ] as const,
38
- [marketplace?.autoUpdate],
39
- );
40
-
41
35
  // Sync ACTIONS into reducer state
42
36
  useEffect(() => {
43
37
  dispatch({ type: "SET_ITEMS", items: [...ACTIONS] });
44
- }, [ACTIONS]);
38
+ }, []);
45
39
 
46
40
  useInput((_input, key) => {
47
41
  if (state.isLoading && !key.escape) return;
@@ -58,9 +52,7 @@ export const MarketplaceDetail: React.FC = () => {
58
52
 
59
53
  if (pendingDecision === "select" && marketplace && !state.isLoading) {
60
54
  const action = actionsList[selectedActionIndex]?.id;
61
- if (action === "toggle-auto-update") {
62
- actions.toggleAutoUpdate(marketplace.name, !marketplace.autoUpdate);
63
- } else if (action === "update") {
55
+ if (action === "update") {
64
56
  actions.updateMarketplace(marketplace.name);
65
57
  } else if (action === "remove") {
66
58
  actions.removeMarketplace(marketplace.name);
@@ -104,15 +96,6 @@ export const MarketplaceDetail: React.FC = () => {
104
96
  <Text>Source: {JSON.stringify(marketplace.source)}</Text>
105
97
  </Box>
106
98
 
107
- <Box marginBottom={1}>
108
- <Text>
109
- Auto-update:{" "}
110
- <Text color={marketplace.autoUpdate ? "green" : "red"}>
111
- {marketplace.autoUpdate ? "Enabled" : "Disabled"}
112
- </Text>
113
- </Text>
114
- </Box>
115
-
116
99
  {marketplace.lastUpdated && (
117
100
  <Box marginBottom={1}>
118
101
  <Text>
@@ -10,7 +10,12 @@ import {
10
10
  import { MarketplaceList } from "./MarketplaceList.js";
11
11
 
12
12
  export const MarketplaceView: React.FC = () => {
13
- const { marketplaces, actions } = usePluginManagerContext();
13
+ const {
14
+ marketplaces,
15
+ checkingForUpdates,
16
+ state: managerState,
17
+ actions,
18
+ } = usePluginManagerContext();
14
19
  const [state, dispatch] = useReducer(selectorReducer<KnownMarketplace>, {
15
20
  selectedIndex: 0,
16
21
  pendingDecision: null,
@@ -19,10 +24,16 @@ export const MarketplaceView: React.FC = () => {
19
24
 
20
25
  const { selectedIndex, pendingDecision, items } = state;
21
26
 
22
- // Sync marketplaces into reducer state
27
+ // Sync marketplaces into reducer state;焦点落到当前选中的市场(新增市场后由
28
+ // addMarketplace 设成新市场名,spec「管理市场」场景 5),没有匹配(首帧、市场被
29
+ // 移除)时保持 SET_ITEMS 归零后的列表首项。
23
30
  useEffect(() => {
24
31
  dispatch({ type: "SET_ITEMS", items: marketplaces });
25
- }, [marketplaces]);
32
+ const index = marketplaces.findIndex(
33
+ (m) => m.name === managerState.selectedId,
34
+ );
35
+ if (index > 0) dispatch({ type: "SET_INDEX", index });
36
+ }, [marketplaces, managerState.selectedId]);
26
37
 
27
38
  useInput((input, key) => {
28
39
  if (input === "a") {
@@ -52,6 +63,9 @@ export const MarketplaceView: React.FC = () => {
52
63
  <Box flexDirection="column">
53
64
  <Box marginBottom={1}>
54
65
  <Text color="green">Press 'a' to add a new marketplace</Text>
66
+ {/* 打开插件管理器触发的后台清单刷新(只拉检出、不升级插件):不阻塞
67
+ 列表浏览,完成后重读并呈现最新清单(spec ecosystem/plugin 场景 12) */}
68
+ {checkingForUpdates && <Text dimColor> Checking for updates...</Text>}
55
69
  </Box>
56
70
  <MarketplaceList
57
71
  marketplaces={marketplaces}
@@ -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
 
@@ -5,12 +5,18 @@ export interface NotificationsProps {
5
5
  latestTotalTokens?: number;
6
6
  maxInputTokens?: number;
7
7
  showLoginHint?: boolean;
8
+ /**
9
+ * 一次性中性提示(插件变更待应用 / 重载完成)。由宿主显式设置并在数秒后清除,
10
+ * 不提供持久可见标识(docs/specs/ecosystem/plugin.md「插件变更提示」)。
11
+ */
12
+ pluginHint?: string | null;
8
13
  }
9
14
 
10
15
  export const Notifications: React.FC<NotificationsProps> = ({
11
16
  latestTotalTokens = 0,
12
17
  maxInputTokens = 200000,
13
18
  showLoginHint = false,
19
+ pluginHint = null,
14
20
  }) => {
15
21
  const percentage =
16
22
  latestTotalTokens > 0
@@ -22,6 +28,7 @@ export const Notifications: React.FC<NotificationsProps> = ({
22
28
 
23
29
  return (
24
30
  <Box gap={1}>
31
+ {pluginHint && <Text color="gray">{pluginHint}</Text>}
25
32
  {showLoginHint && <Text color="gray">Type /login to authenticate</Text>}
26
33
  {percentage > 0 && (
27
34
  <Text color={contextColor}>{percentage}% context</Text>
@@ -0,0 +1,146 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput, useWindowSize } from "ink";
3
+ import { useLineScroll } from "../hooks/useLineScroll.js";
4
+ import { ScrolledContent } from "./ScrolledContent.js";
5
+
6
+ // Row budget for the scroll indicators (↑ more / ↓ more, up to one each).
7
+ const SCROLL_INDICATOR_BUDGET = 2;
8
+ // Row budget for the fixed scroll-key hint shown when content is scrollable.
9
+ const SCROLL_HINT_BUDGET = 1;
10
+ // Overlay height as a fraction of the terminal height (spec: 50-60%).
11
+ const PLAN_OVERLAY_HEIGHT_RATIO = 0.55;
12
+
13
+ export interface PlanViewProps {
14
+ path?: string;
15
+ content?: string;
16
+ message?: string;
17
+ /** Available height (rows) for the whole overlay, computed by default from
18
+ * the terminal size. Tests pass it explicitly for determinism. */
19
+ maxHeight?: number;
20
+ onCancel: () => void;
21
+ }
22
+
23
+ /**
24
+ * Overlay shown by the /plan command:
25
+ * - With `message` only: a plain status line (e.g. "Enabled plan mode").
26
+ * - With `path`/`content`: the current plan file contents as plain text lines
27
+ * (no Markdown), fixed at ~55% of the terminal height with PgUp/PgDn and
28
+ * Ctrl+u/Ctrl+d scrolling and ↑/↓ "N more" indicators.
29
+ * Esc dismisses the overlay.
30
+ */
31
+ export const PlanView: React.FC<PlanViewProps> = ({
32
+ path,
33
+ content,
34
+ message,
35
+ maxHeight,
36
+ onCancel,
37
+ }) => {
38
+ const { rows } = useWindowSize();
39
+ const overlayMaxHeight =
40
+ maxHeight ?? Math.max(Math.round(rows * PLAN_OVERLAY_HEIGHT_RATIO), 10);
41
+
42
+ const isMessageOnly = message !== undefined && content === undefined;
43
+
44
+ useInput((_input, key) => {
45
+ if (key.escape) {
46
+ onCancel();
47
+ }
48
+ });
49
+
50
+ // Hook order must be stable across renders, so compute the scroll state
51
+ // before the message-only early return.
52
+ const headerRows = 2 + (path ? 1 : 0); // "Current Plan" + path + footer hint
53
+ const visibleCount = Math.max(
54
+ 1,
55
+ overlayMaxHeight -
56
+ headerRows -
57
+ SCROLL_INDICATOR_BUDGET -
58
+ SCROLL_HINT_BUDGET,
59
+ );
60
+ const planLines =
61
+ !isMessageOnly && content !== undefined && content !== ""
62
+ ? content.split("\n")
63
+ : [];
64
+ // Rows really occupied by the plan (wrapped rows included), measured after
65
+ // layout by <ScrolledContent>.
66
+ const [contentRows, setContentRows] = useState(0);
67
+ const { scrollOffset, hasMoreAbove, hasMoreBelow } = useLineScroll({
68
+ totalLines: contentRows,
69
+ visibleCount,
70
+ });
71
+
72
+ if (isMessageOnly) {
73
+ return (
74
+ <Box
75
+ flexDirection="column"
76
+ paddingX={1}
77
+ borderStyle="single"
78
+ borderColor="cyan"
79
+ borderLeft={false}
80
+ borderRight={false}
81
+ >
82
+ <Text color="cyan">{message}</Text>
83
+ <Text dimColor>Press Escape to continue</Text>
84
+ </Box>
85
+ );
86
+ }
87
+
88
+ return (
89
+ <Box
90
+ flexDirection="column"
91
+ paddingX={1}
92
+ borderStyle="single"
93
+ borderColor="cyan"
94
+ borderLeft={false}
95
+ borderRight={false}
96
+ >
97
+ <Text color="cyan" bold>
98
+ Current Plan
99
+ </Text>
100
+ {path ? (
101
+ <Text dimColor wrap="truncate-end">
102
+ {path}
103
+ </Text>
104
+ ) : null}
105
+ {hasMoreAbove && (
106
+ <Box>
107
+ <Text color="gray" dimColor>
108
+ ↑ {scrollOffset} more
109
+ </Text>
110
+ </Box>
111
+ )}
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>
127
+ {hasMoreBelow && (
128
+ <Box>
129
+ <Text color="gray" dimColor>
130
+ ↓ {contentRows - scrollOffset - visibleCount} more
131
+ </Text>
132
+ </Box>
133
+ )}
134
+ {(hasMoreAbove || hasMoreBelow) && (
135
+ <Box>
136
+ <Text color="gray" dimColor>
137
+ PgUp/PgDn page • Ctrl+u/d half page
138
+ </Text>
139
+ </Box>
140
+ )}
141
+ <Text dimColor>Press Escape to continue</Text>
142
+ </Box>
143
+ );
144
+ };
145
+
146
+ PlanView.displayName = "PlanView";
@@ -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
- onPluginInstalled?: () => void;
17
- }> = ({ children, onCancel, onPluginInstalled }) => {
18
- const pluginManager = usePluginManager({ onPluginInstalled });
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) => {