@unscientificjszhai/howto 1.0.0 → 1.0.1

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 (40) hide show
  1. package/README.CN.md +32 -3
  2. package/README.md +32 -3
  3. package/THIRD_PARTY_NOTICES.md +5 -4
  4. package/dist/ai/errors.js +7 -23
  5. package/dist/ai/gemini.js +67 -27
  6. package/dist/ai/openai.js +35 -27
  7. package/dist/cli.js +12 -1
  8. package/dist/config-file.js +97 -51
  9. package/dist/config.js +7 -6
  10. package/dist/errors.js +17 -16
  11. package/dist/execute.js +12 -25
  12. package/dist/index.js +84 -95
  13. package/dist/init/InitializationApp.js +175 -32
  14. package/dist/init/index.js +35 -33
  15. package/dist/init/state.js +46 -23
  16. package/dist/prompt.js +4 -2
  17. package/dist/safety/dangerous-command.js +262 -93
  18. package/dist/shell/command-analysis.js +373 -0
  19. package/dist/terminal-text.js +33 -0
  20. package/dist/ui/App.js +64 -25
  21. package/dist/ui/ConfirmView.js +163 -67
  22. package/dist/ui/InteractiveSessionProvider.js +19 -0
  23. package/dist/ui/ResolvePlaceholdersView.js +107 -27
  24. package/dist/ui/SelectCommandView.js +73 -23
  25. package/dist/ui/SelectedCommandDisplay.js +11 -9
  26. package/dist/ui/interactive-session.js +407 -0
  27. package/dist/ui/paste-framing.js +105 -0
  28. package/dist/ui/placeholder-logic.js +28 -16
  29. package/dist/ui/resize-safe-output.js +73 -0
  30. package/dist/ui/run-interactive-command.js +55 -0
  31. package/dist/ui/single-line-preview.js +28 -0
  32. package/dist/ui/text-input.js +53 -0
  33. package/dist/ui/use-keyboard-input.js +12 -0
  34. package/dist/ui/use-paste-aware-input.js +7 -0
  35. package/dist/user-visible-error.js +19 -0
  36. package/dist/validation/ai-response.js +11 -4
  37. package/dist/validation/command-tool.js +25 -127
  38. package/dist/validation/generated-commands.js +5 -16
  39. package/dist/version.js +38 -0
  40. package/package.json +18 -15
@@ -1,84 +1,180 @@
1
- import React, { useState } from "react";
2
- import { Box, Text, useInput } from "ink";
3
- import { SelectedCommandDisplay } from "./SelectedCommandDisplay.js";
1
+ import React, { useRef, useState } from "react";
2
+ import { Box, Text, useStdout } from "ink";
3
+ import { hasUnsafeTerminalControlCharacters } from "../terminal-text.js";
4
+ import { deleteLastGrapheme, isTextInputEvent } from "./text-input.js";
5
+ import { toSingleLinePreview, toTailPreview } from "./single-line-preview.js";
6
+ import { useExecutionPolicy } from "./InteractiveSessionProvider.js";
7
+ import { usePasteAwareInput } from "./use-paste-aware-input.js";
8
+ const DANGER_CONFIRMATION_PHRASE = "EXECUTE";
9
+ const DANGER_CONFIRMATION_SUFFIX = "+Enter; Esc/Ctrl+C |> ";
4
10
  export function isDangerConfirmationInput(input) {
5
- return input.toUpperCase() === "EXECUTE";
11
+ return input.toUpperCase() === DANGER_CONFIRMATION_PHRASE;
6
12
  }
7
- export const ConfirmView = ({ candidate, command, resolvedValues, danger, onConfirm, onCancel, isDone = false, }) => {
13
+ export const ConfirmView = ({ command, danger, onConfirm, onCancel, isDone = false, isInputActive = true, availableRows = 3, availableColumns, }) => {
14
+ const { policy, getPolicy } = useExecutionPolicy();
8
15
  const [buffer, setBuffer] = useState("");
9
- useInput((input, key) => {
10
- if (isDone)
11
- return;
12
- if (key.ctrl && input === "c") {
13
- onCancel();
14
- return;
15
- }
16
- if (key.escape) {
17
- onCancel();
18
- return;
19
- }
20
- if (danger) {
21
- if (key.return) {
22
- if (isDangerConfirmationInput(buffer)) {
23
- onConfirm();
24
- }
25
- else {
26
- onCancel();
27
- }
16
+ const bufferRef = useRef(buffer);
17
+ const finishedRef = useRef(false);
18
+ const { stdout } = useStdout();
19
+ const stdoutColumns = typeof stdout.columns === "number" && stdout.columns > 0 ? stdout.columns : 80;
20
+ const columns = availableColumns ?? stdoutColumns;
21
+ usePasteAwareInput({
22
+ onInput: (input, key) => {
23
+ if (finishedRef.current || !isInputActive || availableRows <= 0 || isDone)
24
+ return;
25
+ if (key.ctrl && input === "c") {
26
+ finishedRef.current = true;
27
+ onCancel();
28
+ return;
29
+ }
30
+ if (key.escape) {
31
+ finishedRef.current = true;
32
+ onCancel();
28
33
  return;
29
34
  }
30
- if (key.backspace || key.delete) {
31
- setBuffer((prev) => prev.slice(0, -1));
35
+ if (getPolicy() === "print") {
36
+ if (key.return) {
37
+ finishedRef.current = true;
38
+ onConfirm();
39
+ }
32
40
  return;
33
41
  }
34
- if (input && !Object.values(key).some(Boolean)) {
35
- setBuffer((prev) => prev + input);
42
+ if (danger) {
43
+ if (key.return) {
44
+ finishedRef.current = true;
45
+ if (isDangerConfirmationInput(bufferRef.current)) {
46
+ onConfirm();
47
+ }
48
+ else {
49
+ onCancel();
50
+ }
51
+ return;
52
+ }
53
+ if (key.backspace || key.delete) {
54
+ updateBuffer(deleteLastGrapheme);
55
+ return;
56
+ }
57
+ if (isTextInputEvent(input, key)) {
58
+ updateBuffer((previous) => previous + input);
59
+ }
36
60
  }
37
- }
38
- else {
39
- if (key.return) {
61
+ else if (key.return) {
62
+ finishedRef.current = true;
40
63
  onConfirm();
41
64
  }
42
- }
65
+ },
66
+ onPaste: (input) => {
67
+ if (finishedRef.current ||
68
+ getPolicy() === "print" ||
69
+ danger === undefined ||
70
+ hasUnsafeTerminalControlCharacters(input)) {
71
+ return;
72
+ }
73
+ updateBuffer((previous) => previous + input);
74
+ },
75
+ isPasteActive: isInputActive && availableRows > 0 && !isDone,
43
76
  });
77
+ function updateBuffer(update) {
78
+ const nextBuffer = update(bufferRef.current);
79
+ bufferRef.current = nextBuffer;
80
+ setBuffer(nextBuffer);
81
+ }
82
+ if (availableRows <= 0) {
83
+ return null;
84
+ }
85
+ if (isDone) {
86
+ return React.createElement(CommandPreview, { command: command });
87
+ }
88
+ if (policy === "print") {
89
+ if (availableRows === 1) {
90
+ return (React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
91
+ React.createElement(CommandPreview, { command: command, prefix: "\u4EC5\u8F93\u51FA:", prefixWidth: 7 }),
92
+ React.createElement(Box, { width: 8, flexShrink: 0 },
93
+ React.createElement(Text, null, " Ent Esc"))));
94
+ }
95
+ return (React.createElement(Box, { flexDirection: "column" },
96
+ danger && availableRows >= 3 && (React.createElement(Text, { color: "red", wrap: "truncate-middle" },
97
+ "Risk: ",
98
+ toSingleLinePreview(danger.reason),
99
+ " [",
100
+ toSingleLinePreview(danger.rule),
101
+ "]")),
102
+ React.createElement(Text, { wrap: "truncate-middle" },
103
+ "\u4EC5\u8F93\u51FA: ",
104
+ toSingleLinePreview(command)),
105
+ React.createElement(Text, { wrap: "truncate-end" }, "Enter\u8F93\u51FA Esc\u53D6\u6D88")));
106
+ }
107
+ if (!danger && availableRows === 1) {
108
+ return (React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
109
+ React.createElement(CommandPreview, { command: command }),
110
+ React.createElement(Box, { width: 10, flexShrink: 0 },
111
+ React.createElement(Text, null, " Enter Esc"))));
112
+ }
113
+ if (!danger && availableRows === 2) {
114
+ return (React.createElement(Box, { flexDirection: "column", height: 2, maxHeight: 2, overflowX: "hidden", overflowY: "hidden" },
115
+ React.createElement(CommandPreview, { command: command, prefix: "Final:", prefixWidth: 7 }),
116
+ React.createElement(Text, null, "Enter=run Esc=cancel")));
117
+ }
44
118
  if (danger) {
45
- return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
46
- React.createElement(Text, { color: "red", bold: true }, "Dangerous command detected."),
47
- React.createElement(Text, null,
48
- "Rule: ",
49
- danger.rule),
50
- React.createElement(Text, null,
119
+ if (availableRows === 1) {
120
+ return (React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
121
+ React.createElement(CommandPreview, { command: command, minWidth: 2 }),
122
+ React.createElement(Box, { width: 3, flexShrink: 0 },
123
+ React.createElement(Text, null, "!X:")),
124
+ React.createElement(TailClippedBuffer, { buffer: buffer, width: 7 }),
125
+ React.createElement(Box, { width: 8, flexShrink: 0 },
126
+ React.createElement(Text, null, " Ent Esc"))));
127
+ }
128
+ if (availableRows === 2) {
129
+ return (React.createElement(Box, { flexDirection: "column", height: 2, maxHeight: 2, overflowX: "hidden", overflowY: "hidden" },
130
+ React.createElement(CommandPreview, { command: command, prefix: "Danger:", prefixWidth: 8 }),
131
+ React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
132
+ React.createElement(Box, { width: 10, flexShrink: 0 },
133
+ React.createElement(Text, null, "X=EXECUTE:")),
134
+ React.createElement(TailClippedBuffer, { buffer: buffer, width: Math.max(1, columns - 18) }),
135
+ React.createElement(Box, { width: 8, flexShrink: 0 },
136
+ React.createElement(Text, null, " Ent Esc")))));
137
+ }
138
+ // 完整提示、确认短语和光标放不下时,缩短提示,为输入尾部保留空间。
139
+ const compactPrompt = columns < DANGER_CONFIRMATION_PHRASE.length * 2 + DANGER_CONFIRMATION_SUFFIX.length + 1;
140
+ const promptSuffix = compactPrompt ? ":" : DANGER_CONFIRMATION_SUFFIX;
141
+ const promptWidth = DANGER_CONFIRMATION_PHRASE.length + promptSuffix.length;
142
+ const controls = compactPrompt ? " Ent Esc" : "";
143
+ return (React.createElement(Box, { flexDirection: "column" },
144
+ React.createElement(Text, { color: "red", bold: true, wrap: "truncate-middle" },
51
145
  "Risk: ",
52
- danger.reason),
53
- React.createElement(Box, { marginTop: 1, flexDirection: "column" },
54
- React.createElement(SelectedCommandDisplay, { candidate: candidate, resolvedValues: resolvedValues }),
55
- React.createElement(Text, null,
56
- "Final command: ",
57
- React.createElement(Text, { color: "yellow" }, command))),
58
- !isDone && (React.createElement(React.Fragment, null,
59
- React.createElement(Box, { marginTop: 1 },
146
+ toSingleLinePreview(danger.reason),
147
+ " [",
148
+ toSingleLinePreview(danger.rule),
149
+ "]"),
150
+ React.createElement(Text, { wrap: "truncate-middle" },
151
+ "Final command: ",
152
+ React.createElement(Text, { color: "yellow" }, toSingleLinePreview(command))),
153
+ !isDone && (React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
154
+ React.createElement(Box, { width: promptWidth, flexShrink: 0 },
60
155
  React.createElement(Text, null,
61
- "Type ",
62
- React.createElement(Text, { color: "yellow" }, "EXECUTE"),
63
- " to continue, or anything else to cancel.")),
64
- React.createElement(Box, null,
65
- React.createElement(Text, { color: "cyan" }, "> "),
66
- React.createElement(Text, null, buffer),
67
- React.createElement(Text, { backgroundColor: "white" }, " "))))));
156
+ React.createElement(Text, { color: "yellow" }, DANGER_CONFIRMATION_PHRASE),
157
+ promptSuffix)),
158
+ React.createElement(TailClippedBuffer, { buffer: buffer, width: Math.max(1, columns - promptWidth - controls.length), showCursor: true }),
159
+ controls !== "" && (React.createElement(Box, { width: controls.length, flexShrink: 0 },
160
+ React.createElement(Text, null, controls)))))));
68
161
  }
69
- return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
70
- React.createElement(SelectedCommandDisplay, { candidate: candidate, resolvedValues: resolvedValues }),
71
- React.createElement(Text, null,
162
+ return (React.createElement(Box, { flexDirection: "column" },
163
+ React.createElement(Text, { wrap: "truncate-middle" },
72
164
  "Final command: ",
73
- React.createElement(Text, { color: "yellow" }, command)),
74
- !isDone && (React.createElement(Box, { marginTop: 1 },
75
- React.createElement(Text, null,
76
- "Press ",
77
- React.createElement(Text, { color: "green" }, "Enter"),
78
- " to execute, ",
79
- React.createElement(Text, { color: "gray" }, "Esc"),
80
- " or",
81
- " ",
82
- React.createElement(Text, { color: "gray" }, "Ctrl+C"),
83
- " to cancel.")))));
165
+ React.createElement(Text, { color: "yellow" }, toSingleLinePreview(command))),
166
+ !isDone && (React.createElement(Text, { wrap: "truncate-end" },
167
+ React.createElement(Text, { color: "green" }, "Enter"),
168
+ " execute; ",
169
+ React.createElement(Text, { color: "gray" }, "Esc/Ctrl+C"),
170
+ " cancel."))));
84
171
  };
172
+ const CommandPreview = ({ command, minWidth = 1, prefix, prefixWidth }) => (React.createElement(Box, { flexGrow: 1, flexShrink: 1, minWidth: minWidth, height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
173
+ prefix !== undefined && (React.createElement(Box, { width: prefixWidth, flexShrink: 0 },
174
+ React.createElement(Text, null, prefix))),
175
+ React.createElement(Box, { flexGrow: 1, flexShrink: 1, minWidth: 1, height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
176
+ React.createElement(Text, null, toSingleLinePreview(command)))));
177
+ const TailClippedBuffer = ({ buffer, showCursor = false, width }) => (React.createElement(Box, { width: width, flexShrink: 0, height: 1, maxHeight: 1, justifyContent: "flex-end", overflowX: "hidden", overflowY: "hidden" },
178
+ React.createElement(Text, null,
179
+ toTailPreview(buffer, Math.max(0, width - (showCursor ? 1 : 0))),
180
+ showCursor && React.createElement(Text, { backgroundColor: "white" }, " "))));
@@ -0,0 +1,19 @@
1
+ import React, { createContext, useContext, useEffect, useSyncExternalStore } from "react";
2
+ const SessionContext = createContext(undefined);
3
+ export const InteractiveSessionProvider = ({ session, children }) => {
4
+ useEffect(() => session.beginView(), [session]);
5
+ return React.createElement(SessionContext.Provider, { value: session }, children);
6
+ };
7
+ export function useInteractiveSession() {
8
+ const session = useContext(SessionContext);
9
+ if (!session)
10
+ throw new Error("交互视图必须使用受管理的终端会话。");
11
+ return session;
12
+ }
13
+ const subscribeWithoutSession = () => () => { };
14
+ const keyboardPolicy = () => "execute";
15
+ export function useExecutionPolicy() {
16
+ const session = useContext(SessionContext);
17
+ const policy = useSyncExternalStore(session?.subscribeExecutionPolicy ?? subscribeWithoutSession, session?.getExecutionPolicy ?? keyboardPolicy, keyboardPolicy);
18
+ return { policy, getPolicy: session?.getExecutionPolicy ?? keyboardPolicy };
19
+ }
@@ -1,46 +1,126 @@
1
- import React, { useState } from "react";
2
- import { Box, Text, useInput } from "ink";
1
+ import React, { useRef, useState } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { hasUnsafeTerminalControlCharacters } from "../terminal-text.js";
3
4
  import { SelectedCommandDisplay } from "./SelectedCommandDisplay.js";
5
+ import { toSingleLinePreview, toTailPreview } from "./single-line-preview.js";
6
+ import { isTextInputEvent } from "./text-input.js";
7
+ import { usePasteAwareInput } from "./use-paste-aware-input.js";
4
8
  import { applyPlaceholderResolutionInput, createPlaceholderResolution, getPlaceholderResolutionView, } from "./placeholder-logic.js";
5
- export const ResolvePlaceholdersView = ({ candidate, onResolve, onBack, onCancel, }) => {
9
+ export const ResolvePlaceholdersView = ({ candidate, availableRows, availableColumns, isInputActive = true, onResolve, onBack, onCancel, }) => {
6
10
  const [resolutionState, setResolutionState] = useState(() => createPlaceholderResolution(candidate));
11
+ const resolutionStateRef = useRef(resolutionState);
12
+ const finishedRef = useRef(false);
7
13
  const { currentPlaceholder, currentBuffer, resolvedValues } = getPlaceholderResolutionView(resolutionState);
8
- useInput((input, key) => {
9
- if (key.ctrl && input === "c") {
10
- onCancel();
14
+ const handleResolutionInput = (resolutionInput) => {
15
+ if (finishedRef.current)
11
16
  return;
12
- }
13
- const resolutionInput = toPlaceholderResolutionInput(input, key);
14
- if (!resolutionInput) {
15
- return;
16
- }
17
- const transition = applyPlaceholderResolutionInput(resolutionState, resolutionInput);
17
+ const transition = applyPlaceholderResolutionInput(resolutionStateRef.current, resolutionInput);
18
18
  switch (transition.type) {
19
19
  case "editing":
20
+ resolutionStateRef.current = transition.state;
20
21
  setResolutionState(transition.state);
21
22
  return;
22
23
  case "back-to-selection":
24
+ finishedRef.current = true;
23
25
  onBack();
24
26
  return;
25
27
  case "complete":
28
+ finishedRef.current = true;
26
29
  onResolve(transition.resolved);
27
30
  return;
28
31
  }
32
+ };
33
+ usePasteAwareInput({
34
+ onInput: (input, key) => {
35
+ if (finishedRef.current || !isInputActive || availableRows <= 0)
36
+ return;
37
+ if (key.ctrl && input === "c") {
38
+ finishedRef.current = true;
39
+ onCancel();
40
+ return;
41
+ }
42
+ const resolutionInput = toPlaceholderResolutionInput(input, key);
43
+ if (resolutionInput !== undefined) {
44
+ handleResolutionInput(resolutionInput);
45
+ }
46
+ },
47
+ onPaste: (input) => {
48
+ if (hasUnsafeTerminalControlCharacters(input)) {
49
+ return;
50
+ }
51
+ handleResolutionInput({ type: "append", value: input });
52
+ },
53
+ isPasteActive: isInputActive && availableRows > 0,
29
54
  });
30
- return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
31
- React.createElement(SelectedCommandDisplay, { candidate: candidate, resolvedValues: resolvedValues, currentBuffer: currentBuffer }),
32
- React.createElement(Text, { color: "green" }, "? Fill command placeholders"),
33
- React.createElement(Box, { flexDirection: "column", marginTop: 1 },
34
- React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
35
- React.createElement(Text, null,
36
- currentPlaceholder.name,
37
- ": ",
38
- currentPlaceholder.description),
39
- React.createElement(Box, null,
40
- React.createElement(Text, { color: "cyan" }, "> "),
41
- React.createElement(Text, null, currentBuffer.value),
42
- React.createElement(Text, { backgroundColor: "white" }, " ")))),
43
- React.createElement(Text, { dimColor: true }, "Press Enter for next value, Esc to go back, Ctrl+C to cancel.")));
55
+ if (availableRows <= 0) {
56
+ return null;
57
+ }
58
+ if (availableRows === 1) {
59
+ return (React.createElement(OneRowPlaceholderEditor, { placeholderName: currentPlaceholder.name, buffer: currentBuffer.value, availableColumns: availableColumns }));
60
+ }
61
+ if (availableRows === 2) {
62
+ return (React.createElement(Box, { flexDirection: "column", height: 2, maxHeight: 2, overflowX: "hidden", overflowY: "hidden" },
63
+ React.createElement(PlaceholderFieldLine, { name: currentPlaceholder.name, description: currentPlaceholder.description }),
64
+ React.createElement(CompactInputLine, { buffer: currentBuffer.value, availableColumns: availableColumns, controls: " Ent Esc" })));
65
+ }
66
+ const showHeading = availableRows >= 4;
67
+ const showSelectedCommand = availableRows >= 10;
68
+ return (React.createElement(Box, { flexDirection: "column", maxHeight: availableRows, overflowX: "hidden", overflowY: "hidden" },
69
+ showSelectedCommand && (React.createElement(SelectedCommandDisplay, { candidate: candidate, resolvedValues: resolvedValues, currentBuffer: currentBuffer })),
70
+ showHeading && (React.createElement(Text, { color: "green", wrap: "truncate-end" }, "? Fill command placeholders")),
71
+ React.createElement(PlaceholderFieldLine, { name: currentPlaceholder.name, description: currentPlaceholder.description }),
72
+ React.createElement(CompactInputLine, { buffer: currentBuffer.value, availableColumns: availableColumns }),
73
+ React.createElement(Text, { dimColor: true, wrap: "truncate-end" }, "Enter=next Esc=back")));
74
+ };
75
+ const COMPACT_CONTROLS = " Ent Esc";
76
+ const COMPACT_CONTROLS_WIDTH = 8;
77
+ const OneRowPlaceholderEditor = ({ placeholderName, buffer, availableColumns }) => {
78
+ const editableColumns = Math.max(0, availableColumns - COMPACT_CONTROLS_WIDTH);
79
+ const maximumLabelWidth = editableColumns <= 1 ? editableColumns : Math.max(1, Math.floor(editableColumns / 2));
80
+ const labelWidth = Math.min(maximumLabelWidth, toSingleLinePreview(placeholderName).length + 1);
81
+ const bufferWidth = Math.max(0, editableColumns - labelWidth);
82
+ return (React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
83
+ React.createElement(ClippedPlaceholderLabel, { name: placeholderName, width: labelWidth }),
84
+ React.createElement(TailClippedPlaceholderBuffer, { buffer: buffer, width: bufferWidth }),
85
+ React.createElement(Box, { width: COMPACT_CONTROLS_WIDTH, flexShrink: 0 },
86
+ React.createElement(Text, null, COMPACT_CONTROLS))));
87
+ };
88
+ const PlaceholderFieldLine = ({ name, description, }) => (React.createElement(Text, { wrap: "truncate-end" },
89
+ React.createElement(Text, { color: "green" }, "? "),
90
+ toSingleLinePreview(name),
91
+ ": ",
92
+ toSingleLinePreview(description)));
93
+ const CompactInputLine = ({ buffer, availableColumns, controls }) => {
94
+ const controlsWidth = controls === undefined ? 0 : COMPACT_CONTROLS_WIDTH;
95
+ const bufferWidth = Math.max(0, availableColumns - 2 - controlsWidth);
96
+ return (React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
97
+ React.createElement(Box, { width: 2, flexShrink: 0 },
98
+ React.createElement(Text, { color: "cyan" }, "> ")),
99
+ React.createElement(TailClippedPlaceholderBuffer, { buffer: buffer, width: bufferWidth }),
100
+ controls !== undefined && (React.createElement(Box, { width: controlsWidth, flexShrink: 0 },
101
+ React.createElement(Text, null, controls)))));
102
+ };
103
+ const ClippedPlaceholderLabel = ({ name, width }) => {
104
+ if (width <= 0) {
105
+ return null;
106
+ }
107
+ if (width === 1) {
108
+ return (React.createElement(Box, { width: 1, flexShrink: 0, overflowX: "hidden" },
109
+ React.createElement(Text, null, toSingleLinePreview(name))));
110
+ }
111
+ return (React.createElement(Box, { width: width, flexShrink: 0, overflowX: "hidden" },
112
+ React.createElement(Text, null,
113
+ toSingleLinePreview(name).slice(0, width - 1),
114
+ ":")));
115
+ };
116
+ const TailClippedPlaceholderBuffer = ({ buffer, width, }) => {
117
+ if (width <= 0) {
118
+ return null;
119
+ }
120
+ return (React.createElement(Box, { width: width, flexShrink: 0, overflowX: "hidden", overflowY: "hidden" },
121
+ React.createElement(Text, null,
122
+ toTailPreview(buffer, Math.max(0, width - 1)),
123
+ React.createElement(Text, { backgroundColor: "white" }, " "))));
44
124
  };
45
125
  function toPlaceholderResolutionInput(input, key) {
46
126
  if (key.escape) {
@@ -52,7 +132,7 @@ function toPlaceholderResolutionInput(input, key) {
52
132
  if (key.backspace || key.delete) {
53
133
  return { type: "delete" };
54
134
  }
55
- if (input && !Object.values(key).some(Boolean)) {
135
+ if (isTextInputEvent(input, key)) {
56
136
  return { type: "append", value: input };
57
137
  }
58
138
  return undefined;
@@ -1,39 +1,89 @@
1
- import React, { useState } from "react";
2
- import { Box, Text, useInput } from "ink";
3
- export const SelectCommandView = ({ candidates, onSelect, onCancel }) => {
1
+ import React, { useRef, useState } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { useKeyboardInput } from "./use-keyboard-input.js";
4
+ import { toSingleLinePreview } from "./single-line-preview.js";
5
+ export const SelectCommandView = ({ candidates, availableRows, isInputActive = true, onSelect, onCancel, }) => {
4
6
  const [activeIndex, setActiveIndex] = useState(0);
5
- useInput((input, key) => {
7
+ const activeIndexRef = useRef(0);
8
+ const finishedRef = useRef(false);
9
+ function updateActiveIndex(update) {
10
+ const nextIndex = update(activeIndexRef.current);
11
+ activeIndexRef.current = nextIndex;
12
+ setActiveIndex(nextIndex);
13
+ }
14
+ useKeyboardInput((input, key) => {
15
+ if (finishedRef.current || !isInputActive || availableRows <= 0)
16
+ return;
6
17
  if (key.escape || (key.ctrl && input === "c")) {
18
+ finishedRef.current = true;
7
19
  onCancel();
8
20
  return;
9
21
  }
10
22
  if (key.upArrow) {
11
- setActiveIndex((prev) => (prev - 1 + candidates.length) % candidates.length);
23
+ updateActiveIndex((prev) => (prev - 1 + candidates.length) % candidates.length);
12
24
  }
13
25
  if (key.downArrow) {
14
- setActiveIndex((prev) => (prev + 1) % candidates.length);
26
+ updateActiveIndex((prev) => (prev + 1) % candidates.length);
15
27
  }
16
28
  if (key.return) {
17
- onSelect(candidates[activeIndex]);
29
+ finishedRef.current = true;
30
+ onSelect(candidates[activeIndexRef.current]);
18
31
  }
19
32
  });
20
- return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
21
- React.createElement(Text, { color: "green" }, "? Select a command"),
22
- React.createElement(Box, { flexDirection: "column", marginTop: 1 }, candidates.map((candidate, index) => {
23
- const isSelected = index === activeIndex;
24
- const isLast = index === candidates.length - 1;
25
- return (React.createElement(React.Fragment, { key: index },
26
- React.createElement(Box, { flexDirection: "column", flexShrink: 0 },
27
- React.createElement(Box, null,
28
- React.createElement(Text, { color: isSelected ? "cyan" : undefined }, isSelected ? "> " : " "),
29
- React.createElement(Text, { inverse: isSelected, bold: isSelected }, candidate.title)),
30
- React.createElement(Box, { paddingLeft: 4 },
31
- React.createElement(Text, { color: "gray" }, candidate.command)),
32
- React.createElement(Box, { paddingLeft: 4 },
33
- React.createElement(Text, { italic: true, color: "dim" }, candidate.description))),
33
+ if (availableRows <= 0) {
34
+ return null;
35
+ }
36
+ const currentCandidate = candidates[activeIndex];
37
+ const page = `${activeIndex + 1}/${candidates.length}`;
38
+ if (availableRows === 1) {
39
+ return (React.createElement(Box, { height: 1, maxHeight: 1, overflowX: "hidden", overflowY: "hidden" },
40
+ React.createElement(Box, { height: 1, maxHeight: 1, flexGrow: 1, flexShrink: 1, minWidth: 1, overflowX: "hidden", overflowY: "hidden" },
41
+ React.createElement(Text, { color: "gray" }, toSingleLinePreview(currentCandidate.command))),
42
+ React.createElement(Box, { flexShrink: 0 },
43
+ React.createElement(Text, { dimColor: true }, ` ${page} UD Enter Esc`))));
44
+ }
45
+ if (availableRows === 2) {
46
+ return (React.createElement(Box, { flexDirection: "column", maxHeight: 2, overflowX: "hidden", overflowY: "hidden" },
47
+ React.createElement(Box, { flexShrink: 0, overflowX: "hidden" },
48
+ React.createElement(Box, { flexShrink: 0 },
49
+ React.createElement(Text, { color: "green" }, `? ${page} `)),
50
+ React.createElement(Box, { flexGrow: 1, flexShrink: 1, minWidth: 1, overflowX: "hidden" },
51
+ React.createElement(Text, { color: "gray", wrap: "truncate-middle" }, toSingleLinePreview(currentCandidate.command)))),
52
+ React.createElement(Box, { flexShrink: 0 },
53
+ React.createElement(Text, { dimColor: true, wrap: "truncate-end" }, "\u2191\u2193; Enter; Esc/^C"))));
54
+ }
55
+ const expandedRows = candidates.length * 4 + 1;
56
+ const isCompact = availableRows < expandedRows;
57
+ const visibleCandidates = isCompact ? [currentCandidate] : candidates;
58
+ const showCompactTitleInHeader = isCompact && availableRows < 4;
59
+ const showTitle = !showCompactTitleInHeader;
60
+ const showDescription = !isCompact || availableRows >= 5;
61
+ const showCandidateSpacing = availableRows >= (isCompact ? 5 : expandedRows) + 2;
62
+ return (React.createElement(Box, { flexDirection: "column", maxHeight: availableRows, overflowX: "hidden", overflowY: "hidden" },
63
+ showCompactTitleInHeader ? (React.createElement(Text, { wrap: "truncate-end" },
64
+ React.createElement(Text, { color: "green" }, `? Select (${activeIndex + 1}/${candidates.length}) `),
65
+ React.createElement(Text, { inverse: true, bold: true }, toSingleLinePreview(currentCandidate.title)))) : (React.createElement(Text, { color: "green", wrap: "truncate-end" },
66
+ "? Select a command",
67
+ isCompact ? ` (${activeIndex + 1}/${candidates.length})` : "")),
68
+ React.createElement(Box, { flexDirection: "column", marginY: showCandidateSpacing ? 1 : 0, overflowY: "hidden" }, visibleCandidates.map((candidate, visibleIndex) => {
69
+ const candidateIndex = isCompact ? activeIndex : visibleIndex;
70
+ const isLast = visibleIndex === visibleCandidates.length - 1;
71
+ return (React.createElement(React.Fragment, { key: candidateIndex },
72
+ React.createElement(CommandCandidate, { candidate: candidate, isSelected: candidateIndex === activeIndex, showTitle: showTitle, showDescription: showDescription }),
34
73
  !isLast && (React.createElement(Box, { height: 1, flexShrink: 0 },
35
74
  React.createElement(Text, null, " ")))));
36
75
  })),
37
- React.createElement(Box, { flexShrink: 0, marginTop: 1 },
38
- React.createElement(Text, { dimColor: true }, "Use Up/Down to move, Enter to select, Esc or Ctrl+C to cancel."))));
76
+ React.createElement(Box, { flexShrink: 0 },
77
+ React.createElement(Text, { dimColor: true, wrap: "truncate-end" }, showCompactTitleInHeader
78
+ ? "↑↓; Enter; Esc/^C"
79
+ : "Up/Down move; Enter select; Esc/Ctrl+C cancel."))));
39
80
  };
81
+ const CommandCandidate = ({ candidate, isSelected, showTitle, showDescription, }) => (React.createElement(Box, { flexDirection: "column", flexShrink: 0 },
82
+ showTitle && (React.createElement(Box, null,
83
+ React.createElement(Text, { color: isSelected ? "cyan" : undefined }, isSelected ? "> " : " "),
84
+ React.createElement(Box, { flexGrow: 1, overflowX: "hidden" },
85
+ React.createElement(Text, { inverse: isSelected, bold: isSelected, wrap: "truncate-end" }, toSingleLinePreview(candidate.title))))),
86
+ React.createElement(Box, { paddingLeft: 4, flexGrow: 1, flexShrink: 1, minWidth: 1, overflowX: "hidden" },
87
+ React.createElement(Text, { color: "gray", wrap: "truncate-middle" }, toSingleLinePreview(candidate.command))),
88
+ showDescription && (React.createElement(Box, { paddingLeft: 4 },
89
+ React.createElement(Text, { italic: true, color: "dim", wrap: "truncate-end" }, toSingleLinePreview(candidate.description))))));
@@ -1,31 +1,33 @@
1
1
  import React from "react";
2
2
  import { Box, Text } from "ink";
3
+ import { toSingleLinePreview } from "./single-line-preview.js";
3
4
  export const SelectedCommandDisplay = ({ candidate, resolvedValues, currentBuffer, }) => {
4
5
  // Split command by {{placeholder}} pattern, keeping the delimiters
5
6
  const parts = candidate.command.split(/({{.*?}})/);
6
7
  return (React.createElement(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1 },
7
8
  React.createElement(Box, null,
8
9
  React.createElement(Text, { color: "cyan", bold: true }, "> "),
9
- React.createElement(Text, { bold: true }, candidate.title)),
10
- React.createElement(Box, { marginLeft: 2 },
11
- React.createElement(Text, null, parts.map((part, index) => {
10
+ React.createElement(Box, { flexGrow: 1, overflowX: "hidden" },
11
+ React.createElement(Text, { bold: true, wrap: "truncate-end" }, toSingleLinePreview(candidate.title)))),
12
+ React.createElement(Box, { marginLeft: 2, overflowX: "hidden" },
13
+ React.createElement(Text, { wrap: "truncate-middle" }, parts.map((part, index) => {
12
14
  const match = part.match(/^{{(.*)}}$/);
13
15
  if (match) {
14
16
  const placeholderName = match[1];
15
17
  // If it's the current buffer, show the buffer in yellow
16
18
  if (currentBuffer && currentBuffer.name === placeholderName) {
17
- return (React.createElement(Text, { key: index, color: "yellow" }, currentBuffer.value || part));
19
+ return (React.createElement(Text, { key: index, color: "yellow" }, toSingleLinePreview(currentBuffer.value || part)));
18
20
  }
19
21
  // If it's already resolved, show the value in yellow
20
22
  if (resolvedValues.has(placeholderName)) {
21
- return (React.createElement(Text, { key: index, color: "yellow" }, resolvedValues.get(placeholderName)));
23
+ return (React.createElement(Text, { key: index, color: "yellow" }, toSingleLinePreview(resolvedValues.get(placeholderName) ?? "")));
22
24
  }
23
25
  // Otherwise show the original {{placeholder}} in default gray
24
- return (React.createElement(Text, { key: index, color: "gray" }, part));
26
+ return (React.createElement(Text, { key: index, color: "gray" }, toSingleLinePreview(part)));
25
27
  }
26
28
  // Normal text
27
- return React.createElement(Text, { key: index }, part);
29
+ return React.createElement(Text, { key: index }, toSingleLinePreview(part));
28
30
  }))),
29
- React.createElement(Box, { marginLeft: 2 },
30
- React.createElement(Text, { italic: true, color: "dim" }, candidate.description))));
31
+ React.createElement(Box, { marginLeft: 2, overflowX: "hidden" },
32
+ React.createElement(Text, { italic: true, color: "dim", wrap: "truncate-end" }, toSingleLinePreview(candidate.description)))));
31
33
  };