@miphamai/cli 0.85.4 → 0.85.5
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 +1 -1
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/core/context.ts +56 -5
- package/src/core/engine.ts +95 -34
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +5 -2
- package/src/i18n-core/locales/zh-CN.json +5 -2
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +36 -15
- package/src/providers/fetch-utils.ts +53 -5
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +8 -0
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +1 -1
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
|
@@ -16,6 +16,58 @@ export interface FetchWithRetryOptions {
|
|
|
16
16
|
|
|
17
17
|
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Upper bound on a server-supplied `Retry-After`, in ms.
|
|
21
|
+
*
|
|
22
|
+
* The header is a *request*, not a contract: a 5xx answering `Retry-After: 3600`
|
|
23
|
+
* used to park the CLI in `sleep` for a full hour with nothing on screen. The
|
|
24
|
+
* user cannot cancel what they cannot see, so the wait is capped and the reason
|
|
25
|
+
* is left in the comment rather than the terminal.
|
|
26
|
+
*/
|
|
27
|
+
export const RETRY_AFTER_MAX_MS = 60_000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lower bound on a server-supplied `Retry-After`, in ms.
|
|
31
|
+
*
|
|
32
|
+
* `Retry-After: 0` is a real thing servers send, and honouring it literally
|
|
33
|
+
* means retrying the instant the previous attempt failed — a hammering loop
|
|
34
|
+
* dressed up as politeness. Any present-but-tiny value lands here instead.
|
|
35
|
+
*/
|
|
36
|
+
const RETRY_AFTER_MIN_MS = 1_000
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delay before the next retry attempt.
|
|
40
|
+
*
|
|
41
|
+
* `Retry-After` is honoured when it is parseable, clamped to
|
|
42
|
+
* `[RETRY_AFTER_MIN_MS, RETRY_AFTER_MAX_MS]`, and **ignored in favour of
|
|
43
|
+
* exponential backoff when it is not** — an unparseable header must not become
|
|
44
|
+
* `sleep(NaN)`, which `setTimeout` reads as 0 (the same back-to-back retry as
|
|
45
|
+
* `Retry-After: 0`, but silent).
|
|
46
|
+
*
|
|
47
|
+
* Accepts both RFC 9110 forms: delta-seconds and an HTTP-date.
|
|
48
|
+
*/
|
|
49
|
+
export function retryDelayMs(
|
|
50
|
+
retryAfter: string | null,
|
|
51
|
+
attempt: number,
|
|
52
|
+
baseDelay: number,
|
|
53
|
+
): number {
|
|
54
|
+
const backoff = baseDelay * Math.pow(2, attempt)
|
|
55
|
+
if (retryAfter === null) return backoff
|
|
56
|
+
|
|
57
|
+
const header = retryAfter.trim()
|
|
58
|
+
const seconds = parseInt(header, 10)
|
|
59
|
+
let requested: number
|
|
60
|
+
if (!Number.isNaN(seconds)) {
|
|
61
|
+
requested = seconds * 1000
|
|
62
|
+
} else {
|
|
63
|
+
const at = Date.parse(header)
|
|
64
|
+
if (Number.isNaN(at)) return backoff // unparseable → backoff, never a zero sleep
|
|
65
|
+
requested = at - Date.now()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Math.min(Math.max(requested, RETRY_AFTER_MIN_MS), RETRY_AFTER_MAX_MS)
|
|
69
|
+
}
|
|
70
|
+
|
|
19
71
|
function isRetryableError(err: unknown): boolean {
|
|
20
72
|
if (err instanceof DOMException && err.name === 'AbortError') return false
|
|
21
73
|
return true
|
|
@@ -51,11 +103,7 @@ export async function fetchWithRetry(
|
|
|
51
103
|
|
|
52
104
|
// 429 / 5xx → retry
|
|
53
105
|
if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
|
|
54
|
-
|
|
55
|
-
const delay = retryAfter
|
|
56
|
-
? parseInt(retryAfter, 10) * 1000
|
|
57
|
-
: baseDelay * Math.pow(2, attempt)
|
|
58
|
-
await sleep(delay)
|
|
106
|
+
await sleep(retryDelayMs(response.headers.get('Retry-After'), attempt, baseDelay))
|
|
59
107
|
continue
|
|
60
108
|
}
|
|
61
109
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive `rm` whose target cannot be read off the command text.
|
|
3
|
+
*
|
|
4
|
+
* `rm -rf node_modules` names what it deletes. `rm -rf "$(pwd)"` does not — the
|
|
5
|
+
* target is produced at run time, so no allow rule, no mode and no reviewer can
|
|
6
|
+
* see how far the deletion reaches.
|
|
7
|
+
*
|
|
8
|
+
* This is deliberately **not** a blocklist of dangerous paths. Those already
|
|
9
|
+
* exist (`tools/exec/bash.ts` BLOCKED_PATTERNS refuses `/`, `~`, `*`, `.` and
|
|
10
|
+
* absolute paths). What is left uncovered is the case where the *path is not in
|
|
11
|
+
* the text at all* — and that case is invisible to every string-matching guard,
|
|
12
|
+
* which is why it has to be recognised structurally instead.
|
|
13
|
+
*
|
|
14
|
+
* Judged on every command line inside the input, via the same `flattenCommand`
|
|
15
|
+
* the deny-rule path uses: reading one normalization while the deny rules read
|
|
16
|
+
* another is how a guard fires on one spelling and not on its twin.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { flattenCommand } from '../core/permission-rules'
|
|
20
|
+
|
|
21
|
+
export type DangerousRmKind =
|
|
22
|
+
/** The target is led by command-substitution output: `rm -rf "$(pwd)"`. */
|
|
23
|
+
| 'substitution'
|
|
24
|
+
/** A variable plus one top-level directory name: `rm -rf $ROOT/usr`. */
|
|
25
|
+
| 'variable-top-level'
|
|
26
|
+
/** The target is anchored to a working-directory variable: `rm -rf $PWD`. */
|
|
27
|
+
| 'cwd-derived'
|
|
28
|
+
/** Nothing but backslashes: `rm -rf \`. */
|
|
29
|
+
| 'backslash-only'
|
|
30
|
+
|
|
31
|
+
export interface DangerousRm {
|
|
32
|
+
kind: DangerousRmKind
|
|
33
|
+
/** The offending target, with its surrounding quotes stripped. */
|
|
34
|
+
target: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Variables whose value is the directory the shell is in.
|
|
39
|
+
*
|
|
40
|
+
* The danger is not an unknown value — it is that the value is *movable*: an
|
|
41
|
+
* earlier segment of the same command line (`cd /tmp && …`) decides it, so the
|
|
42
|
+
* target is anchored to whatever directory the command happens to reach.
|
|
43
|
+
*/
|
|
44
|
+
const CWD_VARS = new Set(['PWD', 'OLDPWD'])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Directory names that sit at the filesystem root.
|
|
48
|
+
*
|
|
49
|
+
* These are what make an empty variable dangerous. If `$VAR` is unset, the shell
|
|
50
|
+
* drops it and `rm -rf $VAR/usr` runs as `rm -rf /usr` — the variable does not
|
|
51
|
+
* fail loudly, it *disappears*, and what is left behind is an absolute path to a
|
|
52
|
+
* system directory. `$VAR/node_modules` collapsing to `/node_modules` is not in
|
|
53
|
+
* that class, so the directory name is what decides, not the variable.
|
|
54
|
+
*/
|
|
55
|
+
const TOP_LEVEL_DIRS = new Set([
|
|
56
|
+
'bin',
|
|
57
|
+
'boot',
|
|
58
|
+
'dev',
|
|
59
|
+
'etc',
|
|
60
|
+
'home',
|
|
61
|
+
'lib',
|
|
62
|
+
'lib64',
|
|
63
|
+
'opt',
|
|
64
|
+
'proc',
|
|
65
|
+
'root',
|
|
66
|
+
'run',
|
|
67
|
+
'sbin',
|
|
68
|
+
'srv',
|
|
69
|
+
'sys',
|
|
70
|
+
'tmp',
|
|
71
|
+
'usr',
|
|
72
|
+
'var',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/** Strip one layer of matching quotes — `"$(pwd)"` and `$(pwd)` are one target. */
|
|
76
|
+
function stripQuotes(s: string): string {
|
|
77
|
+
if (s.length < 2) return s
|
|
78
|
+
const first = s[0]
|
|
79
|
+
const last = s[s.length - 1]
|
|
80
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return s.slice(1, -1)
|
|
81
|
+
return s
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Split an argument region into operands, keeping a quoted run or a `$(…)`
|
|
86
|
+
* substitution whole.
|
|
87
|
+
*
|
|
88
|
+
* Plain whitespace splitting is not enough here, and it fails in the direction
|
|
89
|
+
* that matters: `rm -rf "$(git rev-parse --show-toplevel)"` tokenizes to
|
|
90
|
+
* `['"$(git', 'rev-parse', '--show-toplevel)"']`, so the target never reads as a
|
|
91
|
+
* substitution at all. The cases this guard exists to catch are exactly the ones
|
|
92
|
+
* that contain spaces.
|
|
93
|
+
*/
|
|
94
|
+
function splitOperands(args: string): string[] {
|
|
95
|
+
const out: string[] = []
|
|
96
|
+
let cur = ''
|
|
97
|
+
let quote: string | null = null
|
|
98
|
+
let depth = 0
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < args.length; i++) {
|
|
101
|
+
const ch = args[i]!
|
|
102
|
+
|
|
103
|
+
if (quote) {
|
|
104
|
+
cur += ch
|
|
105
|
+
if (ch === quote) quote = null
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
109
|
+
quote = ch
|
|
110
|
+
cur += ch
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (ch === '$' && args[i + 1] === '(') {
|
|
114
|
+
depth++
|
|
115
|
+
cur += '$('
|
|
116
|
+
i++
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (depth > 0) {
|
|
120
|
+
if (ch === ')') depth--
|
|
121
|
+
cur += ch
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (/\s/.test(ch)) {
|
|
125
|
+
if (cur) out.push(cur)
|
|
126
|
+
cur = ''
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
cur += ch
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (cur) out.push(cur)
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* True for an `rm` invocation carrying a recursive flag.
|
|
138
|
+
*
|
|
139
|
+
* Force (`-f`) is deliberately not required: `rm -r "$(pwd)"` deletes just as
|
|
140
|
+
* much as `rm -rf "$(pwd)"` and asks fewer questions on the way in.
|
|
141
|
+
*/
|
|
142
|
+
function isRecursiveRm(tokens: string[]): boolean {
|
|
143
|
+
if (tokens[0] !== 'rm') return false
|
|
144
|
+
return tokens.slice(1).some((t) => {
|
|
145
|
+
if (t === '--recursive') return true
|
|
146
|
+
// A combined cluster (`-rf`, `-fr`, `-r`). A `--flag` long form is not one.
|
|
147
|
+
return /^-[A-Za-z]+$/.test(t) && t.includes('r')
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Which enumerated shape (if any) this target has. */
|
|
152
|
+
function classifyTarget(raw: string): DangerousRmKind | null {
|
|
153
|
+
const target = stripQuotes(raw)
|
|
154
|
+
|
|
155
|
+
if (/^\\+$/.test(target)) return 'backslash-only'
|
|
156
|
+
|
|
157
|
+
// Led by command-substitution output. A substitution *anywhere* in the target
|
|
158
|
+
// is not enough to judge, but a target that starts with one is anchored to a
|
|
159
|
+
// value decided at run time — including the prefix form, where the suffix only
|
|
160
|
+
// narrows an unknown directory to a named entry inside it.
|
|
161
|
+
const sub = target.match(/^(\$\([^)]*\)|`[^`]*`)(\/.*)?$/)
|
|
162
|
+
if (sub) return 'substitution'
|
|
163
|
+
|
|
164
|
+
const m = target.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?(\/\S*)?$/)
|
|
165
|
+
if (!m) return null
|
|
166
|
+
const name = m[1]!
|
|
167
|
+
const rest = m[2]
|
|
168
|
+
if (CWD_VARS.has(name)) return 'cwd-derived'
|
|
169
|
+
|
|
170
|
+
// A variable followed by exactly one more segment, and that segment is a
|
|
171
|
+
// filesystem-root directory name. Deeper paths are a named subdirectory, which
|
|
172
|
+
// is the ordinary case this must not refuse.
|
|
173
|
+
if (rest) {
|
|
174
|
+
const segments = rest.split('/').filter(Boolean)
|
|
175
|
+
if (segments.length === 1 && TOP_LEVEL_DIRS.has(segments[0]!)) return 'variable-top-level'
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Return the first unbounded recursive-`rm` target in `command`, or `null`. */
|
|
181
|
+
export function detectDangerousRm(command: string): DangerousRm | null {
|
|
182
|
+
for (const segment of flattenCommand(command)) {
|
|
183
|
+
const tokens = splitOperands(segment)
|
|
184
|
+
if (!isRecursiveRm(tokens)) continue
|
|
185
|
+
for (const token of tokens.slice(1)) {
|
|
186
|
+
if (token.startsWith('-')) continue
|
|
187
|
+
const kind = classifyTarget(token)
|
|
188
|
+
if (kind) return { kind, target: stripQuotes(token) }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
package/src/shared/constants.ts
CHANGED
|
@@ -95,6 +95,24 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
95
95
|
vision: true,
|
|
96
96
|
status: 'active',
|
|
97
97
|
},
|
|
98
|
+
{
|
|
99
|
+
id: 'claude-fable-5-1',
|
|
100
|
+
name: 'Claude Fable 5.1',
|
|
101
|
+
providerId: 'anthropic',
|
|
102
|
+
contextWindow: 1_000_000,
|
|
103
|
+
maxOutput: 128_000,
|
|
104
|
+
vision: true,
|
|
105
|
+
status: 'active',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'claude-opus-5-5',
|
|
109
|
+
name: 'Claude Opus 5.5',
|
|
110
|
+
providerId: 'anthropic',
|
|
111
|
+
contextWindow: 1_000_000,
|
|
112
|
+
maxOutput: 128_000,
|
|
113
|
+
vision: true,
|
|
114
|
+
status: 'active',
|
|
115
|
+
},
|
|
98
116
|
],
|
|
99
117
|
},
|
|
100
118
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Detection and messaging for the "current working directory was deleted"
|
|
3
|
-
* startup
|
|
3
|
+
* failure — at startup, and mid-session.
|
|
4
4
|
*
|
|
5
5
|
* `process.cwd()` throws `ENOENT` when the directory the process was launched
|
|
6
6
|
* from no longer exists (e.g. a removed git worktree). The CLI entry checks
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* error surface as a raw crash dump (matches Claude Code 2.1.239).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
|
|
11
13
|
/** True when `err` is the ENOENT thrown by `process.cwd()` on a deleted cwd. */
|
|
12
14
|
export function isDeletedCwdError(err: unknown): boolean {
|
|
13
15
|
if (!(err instanceof Error)) return false
|
|
@@ -24,3 +26,46 @@ export function deletedCwdMessage(): string {
|
|
|
24
26
|
`Change to a valid directory and run \`mipham\` again.`
|
|
25
27
|
)
|
|
26
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `dir` (default `process.cwd()`) if it still exists, otherwise `null`.
|
|
32
|
+
*
|
|
33
|
+
* The launch-time check above covers a directory that was already gone when the
|
|
34
|
+
* process started. A directory deleted *while* the session runs reaches the same
|
|
35
|
+
* state, and neither runtime reports it on its own:
|
|
36
|
+
*
|
|
37
|
+
* - Node throws `ENOENT` from `process.cwd()` — but from inside whatever call
|
|
38
|
+
* site happens to touch it first, so the message names the wrong thing;
|
|
39
|
+
* - Bun does not throw at all. It keeps returning the path it cached at startup,
|
|
40
|
+
* so the deleted directory arrives as an ordinary string and only fails later,
|
|
41
|
+
* at the first syscall that uses it (`spawn /bin/sh ENOENT` blames the shell).
|
|
42
|
+
*
|
|
43
|
+
* Asking the filesystem makes both runtimes agree, and lets the caller decide
|
|
44
|
+
* what to say. An error that is not this one is not swallowed.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveExistingCwd(dir?: string): string | null {
|
|
47
|
+
let target: string
|
|
48
|
+
try {
|
|
49
|
+
target = dir ?? process.cwd()
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (isDeletedCwdError(err)) return null
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
return existsSync(target) ? target : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Guidance for a session whose working directory was deleted while it ran.
|
|
59
|
+
*
|
|
60
|
+
* `deletedCwdMessage` is read before there is a session — it tells the reader to
|
|
61
|
+
* launch the CLI. This one is read from inside a running session, by the model
|
|
62
|
+
* and the operator, so it says what is broken now and what to do instead.
|
|
63
|
+
*/
|
|
64
|
+
export function deletedCwdSessionMessage(): string {
|
|
65
|
+
return (
|
|
66
|
+
`The working directory for this session no longer exists — it was deleted ` +
|
|
67
|
+
`while the session was running.\n` +
|
|
68
|
+
`Tools that need a directory cannot run until the session is restarted from ` +
|
|
69
|
+
`a directory that exists.`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.85.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.5' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
|
@@ -278,6 +278,14 @@ export interface HookConfig {
|
|
|
278
278
|
export interface HookDefinition {
|
|
279
279
|
event: HookEvent
|
|
280
280
|
toolName?: string
|
|
281
|
+
/**
|
|
282
|
+
* Who declared this hook, when it was not the operator — a plugin name, today.
|
|
283
|
+
*
|
|
284
|
+
* The operator is no longer the only author of the hooks that fire, and without
|
|
285
|
+
* this the engine cannot tell them apart: a failure message names only a command,
|
|
286
|
+
* health is tracked per key, and removal has nothing to scope to.
|
|
287
|
+
*/
|
|
288
|
+
source?: string
|
|
281
289
|
handler: (context: HookContext) => Promise<HookResult>
|
|
282
290
|
}
|
|
283
291
|
|
|
@@ -4,6 +4,7 @@ import TextInput from 'ink-text-input'
|
|
|
4
4
|
import { useI18n } from '../i18n-context'
|
|
5
5
|
import { getCommandList } from './commands.js'
|
|
6
6
|
import { commandToken, hasInlineArgs } from './command-token.js'
|
|
7
|
+
import { useKeyState } from './use-key-state'
|
|
7
8
|
|
|
8
9
|
interface CommandPickerProps {
|
|
9
10
|
/** Text already typed (e.g. "/", "/age") — used as initial filter */
|
|
@@ -59,7 +60,9 @@ export function CommandPicker({
|
|
|
59
60
|
const { t } = useI18n()
|
|
60
61
|
const allCommands = useMemo(() => getCommandList(), [])
|
|
61
62
|
const [filter, setFilter] = useState(initialFilter)
|
|
62
|
-
|
|
63
|
+
// 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),确认时
|
|
64
|
+
// 必须读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
|
|
65
|
+
const cursorIdx = useKeyState(0)
|
|
63
66
|
|
|
64
67
|
// Filter commands based on user input (match on the command-name token, not the
|
|
65
68
|
// full line — so "/loop 60s echo hello" still matches "/loop").
|
|
@@ -73,16 +76,16 @@ export function CommandPicker({
|
|
|
73
76
|
|
|
74
77
|
// Reset cursor when filter changes
|
|
75
78
|
useEffect(() => {
|
|
76
|
-
|
|
77
|
-
}, [filter])
|
|
79
|
+
cursorIdx.set(0)
|
|
80
|
+
}, [filter, cursorIdx])
|
|
78
81
|
|
|
79
82
|
// Scroll window: keep cursor in the visible range
|
|
80
83
|
const scrollStart = Math.max(
|
|
81
84
|
0,
|
|
82
|
-
Math.min(cursorIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
|
|
85
|
+
Math.min(cursorIdx.value - Math.floor(maxVisible / 2), filtered.length - maxVisible),
|
|
83
86
|
)
|
|
84
87
|
const visible = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
85
|
-
const _adjustedCursor = cursorIdx - scrollStart
|
|
88
|
+
const _adjustedCursor = cursorIdx.value - scrollStart
|
|
86
89
|
|
|
87
90
|
// Wrap cursor safely
|
|
88
91
|
const safeCursor = (i: number) =>
|
|
@@ -97,7 +100,7 @@ export function CommandPicker({
|
|
|
97
100
|
onSelect(trimmed)
|
|
98
101
|
return
|
|
99
102
|
}
|
|
100
|
-
const selected = filtered[cursorIdx]
|
|
103
|
+
const selected = filtered[cursorIdx.read()]
|
|
101
104
|
if (selected) onSelect(selected.name)
|
|
102
105
|
}
|
|
103
106
|
|
|
@@ -113,12 +116,12 @@ export function CommandPicker({
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
if (key.upArrow) {
|
|
116
|
-
|
|
119
|
+
cursorIdx.set((prev) => safeCursor(prev - 1))
|
|
117
120
|
return
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
if (key.downArrow) {
|
|
121
|
-
|
|
124
|
+
cursorIdx.set((prev) => safeCursor(prev + 1))
|
|
122
125
|
return
|
|
123
126
|
}
|
|
124
127
|
})
|
|
@@ -143,7 +146,7 @@ export function CommandPicker({
|
|
|
143
146
|
)}
|
|
144
147
|
{visible.map((cmd, i) => {
|
|
145
148
|
const globalIdx = i + scrollStart
|
|
146
|
-
const isCursor = globalIdx === cursorIdx
|
|
149
|
+
const isCursor = globalIdx === cursorIdx.value
|
|
147
150
|
const query = commandToken(filter)
|
|
148
151
|
const segments = highlightMatches(cmd.name, query)
|
|
149
152
|
const padLen = Math.max(0, 20 - cmd.name.length)
|
|
@@ -180,10 +183,15 @@ export function CommandPicker({
|
|
|
180
183
|
{/* Filter input */}
|
|
181
184
|
<Box>
|
|
182
185
|
<Text color="cyan">/ </Text>
|
|
186
|
+
{/*
|
|
187
|
+
Enter 由本组件自己的 `useInput` 处理(那里才知道光标停在哪一行、要不要走
|
|
188
|
+
内联参数那条路)。**不要再给 TextInput 挂 `onSubmit`** —— 它会成为第二个
|
|
189
|
+
监听者,一次 Enter 变成两次提交,而 `onSelect` 的调用点接的是「提交这条
|
|
190
|
+
命令」:多出来的一遍就是命令跑两次。
|
|
191
|
+
*/}
|
|
183
192
|
<TextInput
|
|
184
193
|
value={filter.startsWith('/') ? filter.slice(1) : filter}
|
|
185
194
|
onChange={(val) => setFilter(`/${val}`)}
|
|
186
|
-
onSubmit={() => submitSelection()}
|
|
187
195
|
placeholder={t('ui.command_picker.placeholder')}
|
|
188
196
|
/>
|
|
189
197
|
</Box>
|
package/src/ui/commands.ts
CHANGED
|
@@ -4593,7 +4593,7 @@ const workflowsCmd: CommandHandler = async () => {
|
|
|
4593
4593
|
} else {
|
|
4594
4594
|
lines.push('')
|
|
4595
4595
|
lines.push(`${found} workflow(s) found.`)
|
|
4596
|
-
lines.push('Use /
|
|
4596
|
+
lines.push('Use /workflow run <name> to run one.')
|
|
4597
4597
|
}
|
|
4598
4598
|
|
|
4599
4599
|
return { content: lines.join('\n') }
|
package/src/ui/config-wizard.tsx
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import React, { useState, useEffect } from 'react'
|
|
12
12
|
import { Box, Text, useInput } from 'ink'
|
|
13
13
|
import { useI18n } from '../i18n-context'
|
|
14
|
+
import { useKeyState } from './use-key-state'
|
|
14
15
|
import TextInput from 'ink-text-input'
|
|
15
16
|
import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
16
17
|
import type { ModelInfo } from '../shared/types'
|
|
@@ -159,7 +160,9 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
159
160
|
const [error, setError] = useState<string | null>(null)
|
|
160
161
|
|
|
161
162
|
// List navigation cursor
|
|
162
|
-
|
|
163
|
+
// 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),确认时
|
|
164
|
+
// 必须读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
|
|
165
|
+
const cursor = useKeyState(0)
|
|
163
166
|
|
|
164
167
|
// Ollama
|
|
165
168
|
const [ollamaModel, _setOllamaModel] = useState('')
|
|
@@ -174,7 +177,7 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
174
177
|
|
|
175
178
|
// Reset cursor when step changes
|
|
176
179
|
const goStep = (s: Step) => {
|
|
177
|
-
|
|
180
|
+
cursor.set(0)
|
|
178
181
|
setOllamaCursor(0)
|
|
179
182
|
setError(null)
|
|
180
183
|
setStep(s)
|
|
@@ -265,10 +268,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
265
268
|
|
|
266
269
|
// ── Mode selection ──
|
|
267
270
|
if (step === 'mode') {
|
|
268
|
-
if (key.upArrow)
|
|
269
|
-
if (key.downArrow)
|
|
271
|
+
if (key.upArrow) cursor.set((c) => (c === 0 ? 1 : 0))
|
|
272
|
+
if (key.downArrow) cursor.set((c) => (c === 0 ? 1 : 0))
|
|
270
273
|
if (key.return) {
|
|
271
|
-
if (cursor === 0) {
|
|
274
|
+
if (cursor.read() === 0) {
|
|
272
275
|
// Cloud
|
|
273
276
|
setMode('cloud')
|
|
274
277
|
goStep('provider')
|
|
@@ -284,10 +287,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
284
287
|
|
|
285
288
|
// ── Cloud provider selection ──
|
|
286
289
|
if (step === 'provider') {
|
|
287
|
-
if (key.upArrow)
|
|
288
|
-
if (key.downArrow)
|
|
290
|
+
if (key.upArrow) cursor.set((c) => (c > 0 ? c - 1 : providerList.length - 1))
|
|
291
|
+
if (key.downArrow) cursor.set((c) => (c < providerList.length - 1 ? c + 1 : 0))
|
|
289
292
|
if (key.return) {
|
|
290
|
-
const p = providerList[cursor]
|
|
293
|
+
const p = providerList[cursor.read()]
|
|
291
294
|
if (p) {
|
|
292
295
|
setProviderId(p.id)
|
|
293
296
|
goStep('model')
|
|
@@ -299,10 +302,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
299
302
|
// ── Cloud model selection ──
|
|
300
303
|
if (step === 'model') {
|
|
301
304
|
if (modelList.length === 0) return
|
|
302
|
-
if (key.upArrow)
|
|
303
|
-
if (key.downArrow)
|
|
305
|
+
if (key.upArrow) cursor.set((c) => (c > 0 ? c - 1 : modelList.length - 1))
|
|
306
|
+
if (key.downArrow) cursor.set((c) => (c < modelList.length - 1 ? c + 1 : 0))
|
|
304
307
|
if (key.return) {
|
|
305
|
-
const m = modelList[cursor]
|
|
308
|
+
const m = modelList[cursor.read()]
|
|
306
309
|
if (m) {
|
|
307
310
|
setModelId(m.id)
|
|
308
311
|
goStep('apikey')
|
|
@@ -381,8 +384,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
381
384
|
</Box>
|
|
382
385
|
|
|
383
386
|
<Box marginBottom={1}>
|
|
384
|
-
<Text color={cursor === 0 ? SELECTED_COLOR : undefined}>
|
|
385
|
-
{cursor === 0 ? '▶' : ' '} {t('ui.wizard.mode_cloud')}
|
|
387
|
+
<Text color={cursor.value === 0 ? SELECTED_COLOR : undefined}>
|
|
388
|
+
{cursor.value === 0 ? '▶' : ' '} {t('ui.wizard.mode_cloud')}
|
|
386
389
|
</Text>
|
|
387
390
|
</Box>
|
|
388
391
|
<Text dimColor>
|
|
@@ -390,8 +393,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
390
393
|
</Text>
|
|
391
394
|
|
|
392
395
|
<Box marginTop={2} marginBottom={1}>
|
|
393
|
-
<Text color={cursor === 1 ? SELECTED_COLOR : undefined}>
|
|
394
|
-
{cursor === 1 ? '▶' : ' '} {t('ui.wizard.mode_local')}
|
|
396
|
+
<Text color={cursor.value === 1 ? SELECTED_COLOR : undefined}>
|
|
397
|
+
{cursor.value === 1 ? '▶' : ' '} {t('ui.wizard.mode_local')}
|
|
395
398
|
</Text>
|
|
396
399
|
</Box>
|
|
397
400
|
<Text dimColor>{t('ui.wizard.mode_local_hint')}</Text>
|
|
@@ -410,8 +413,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
410
413
|
</Box>
|
|
411
414
|
{providerList.map((p, i) => (
|
|
412
415
|
<Box key={p.id} marginBottom={1}>
|
|
413
|
-
<Text color={i === cursor ? SELECTED_COLOR : undefined}>
|
|
414
|
-
{i === cursor ? '▶' : ' '} {p.name}
|
|
416
|
+
<Text color={i === cursor.value ? SELECTED_COLOR : undefined}>
|
|
417
|
+
{i === cursor.value ? '▶' : ' '} {p.name}
|
|
415
418
|
</Text>
|
|
416
419
|
<Text dimColor>
|
|
417
420
|
{t('ui.wizard.provider_count', {
|
|
@@ -439,8 +442,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
439
442
|
</Box>
|
|
440
443
|
{modelList.map((m, i) => (
|
|
441
444
|
<Box key={m.id} marginBottom={1}>
|
|
442
|
-
<Text color={i === cursor ? SELECTED_COLOR : undefined}>
|
|
443
|
-
{i === cursor ? '▶' : ' '} {m.name}
|
|
445
|
+
<Text color={i === cursor.value ? SELECTED_COLOR : undefined}>
|
|
446
|
+
{i === cursor.value ? '▶' : ' '} {m.name}
|
|
444
447
|
</Text>
|
|
445
448
|
<Text dimColor> ({m.id})</Text>
|
|
446
449
|
</Box>
|