@miphamai/cli 0.85.5 → 0.85.7
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/bin/mipham.ts +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/agent-view/dashboard.tsx +7 -0
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +75 -35
- package/src/core/dream-engine.ts +17 -2
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/core/session-log.ts +9 -0
- package/src/daemon/launch.ts +69 -2
- package/src/i18n-core/locales/en-US.json +81 -142
- package/src/i18n-core/locales/zh-CN.json +81 -142
- package/src/providers/anthropic.ts +147 -133
- package/src/providers/fetch-utils.ts +12 -29
- package/src/providers/openai-compat.ts +121 -106
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/update.ts +290 -146
- package/src/telemetry/consent.ts +50 -4
- package/src/telemetry/index.ts +27 -0
- package/src/tools/exec/task.ts +73 -61
- package/src/ui/commands.ts +40 -17
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
- package/src/ui/picker.tsx +19 -15
- package/src/workflow/primitives/agent.ts +23 -7
package/src/telemetry/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { getMetrics } from '../core/metrics'
|
|
3
|
+
import { settingsPathFor } from '../config/loader'
|
|
3
4
|
import { resolveTelemetry, getOrCreateInstallId, type TelemetryConsent } from './consent'
|
|
4
5
|
import { enqueueSync } from './queue'
|
|
5
6
|
import { buildSessionEvent } from './payload'
|
|
@@ -50,6 +51,12 @@ export function initTelemetry(cwd: string = process.cwd()): TelemetryConsent {
|
|
|
50
51
|
|
|
51
52
|
state = { consent, installId, startedAt: Date.now(), flushed: false }
|
|
52
53
|
|
|
54
|
+
// A project that declared a telemetry key we do not honour is told so, here
|
|
55
|
+
// rather than in the TUI path — this is the one place consent is resolved, so
|
|
56
|
+
// it is also the one place that knows something was withheld, and it covers
|
|
57
|
+
// remote-attach and non-interactive runs the TUI setup never reaches.
|
|
58
|
+
announceIgnoredProjectKeys(consent, cwd)
|
|
59
|
+
|
|
53
60
|
// Crash capture is installed unconditionally, even when telemetry is off:
|
|
54
61
|
// it is what keeps a crash from becoming a silent hang. When telemetry is
|
|
55
62
|
// off the record is simply never uploaded.
|
|
@@ -71,6 +78,26 @@ export function initTelemetry(cwd: string = process.cwd()): TelemetryConsent {
|
|
|
71
78
|
return consent
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Tell the user that a project-level telemetry key was read and not honoured.
|
|
83
|
+
*
|
|
84
|
+
* The same shape as `index.tsx`'s `projectModeSkipped` notice, and for the same
|
|
85
|
+
* reason: "ignored on purpose" and "never read your file" are the same silence
|
|
86
|
+
* from the outside — and this is the user's own line, in a file they can still
|
|
87
|
+
* open. Once per startup, on stderr, so it cannot corrupt stdout rendering.
|
|
88
|
+
*
|
|
89
|
+
* Not a permission prompt: the outcome is already final by the time this runs.
|
|
90
|
+
* It only removes the ambiguity about *why*.
|
|
91
|
+
*/
|
|
92
|
+
function announceIgnoredProjectKeys(consent: TelemetryConsent, cwd: string): void {
|
|
93
|
+
if (consent.ignoredProjectKeys.length === 0) return
|
|
94
|
+
const keys = consent.ignoredProjectKeys.map((k) => `telemetry.${k}`).join(', ')
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
`⚠ Mipham Code: ignored ${keys} from ${settingsPathFor('project', cwd)}\n` +
|
|
97
|
+
` (a repository may turn telemetry off, but must not turn it on or choose where it goes — set that in ${settingsPathFor('user', cwd)})\n`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
74
101
|
/**
|
|
75
102
|
* Record a slash-command invocation.
|
|
76
103
|
*
|
package/src/tools/exec/task.ts
CHANGED
|
@@ -112,6 +112,78 @@ function getBlockingIds(task: Task): string[] {
|
|
|
112
112
|
})
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Render the session's task list: status-grouped, availability-sorted, blockers marked.
|
|
117
|
+
*
|
|
118
|
+
* **One renderer, two readers.** The `list` action answers the model, and the
|
|
119
|
+
* `/tasks` panel answers the human — both show the same session registry, so
|
|
120
|
+
* both go through here. A second renderer for the panel would be the "two
|
|
121
|
+
* paths, one wired" mistake this repo has already paid for once.
|
|
122
|
+
*/
|
|
123
|
+
export function formatTaskList(): string {
|
|
124
|
+
const all = getTasks()
|
|
125
|
+
if (all.length === 0) return '(no tasks)'
|
|
126
|
+
|
|
127
|
+
// Sort by availability: in_progress → pending(available) → pending(blocked) → completed
|
|
128
|
+
const sortOrder: Record<string, number> = {
|
|
129
|
+
in_progress: 0,
|
|
130
|
+
pending: 1,
|
|
131
|
+
completed: 2,
|
|
132
|
+
failed: 3,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sorted = [...all].sort((a, b) => {
|
|
136
|
+
const orderA = sortOrder[a.status] ?? 3
|
|
137
|
+
const orderB = sortOrder[b.status] ?? 3
|
|
138
|
+
if (orderA !== orderB) return orderA - orderB
|
|
139
|
+
// Within same status: available before blocked
|
|
140
|
+
if (a.status === 'pending' && b.status === 'pending') {
|
|
141
|
+
const aBlocked = isBlocked(a)
|
|
142
|
+
const bBlocked = isBlocked(b)
|
|
143
|
+
if (aBlocked !== bBlocked) return aBlocked ? 1 : -1
|
|
144
|
+
}
|
|
145
|
+
return 0
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const lines: string[] = []
|
|
149
|
+
const statusLabels: Record<string, { emoji: string; label: string }> = {
|
|
150
|
+
in_progress: { emoji: '🔄', label: 'In Progress' },
|
|
151
|
+
pending: { emoji: '📋', label: 'Pending' },
|
|
152
|
+
completed: { emoji: '✅', label: 'Completed' },
|
|
153
|
+
failed: { emoji: '❌', label: 'Failed' },
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let currentStatus = ''
|
|
157
|
+
for (const t of sorted) {
|
|
158
|
+
const statusInfo = statusLabels[t.status]
|
|
159
|
+
if (!statusInfo) continue
|
|
160
|
+
|
|
161
|
+
if (statusInfo.label !== currentStatus) {
|
|
162
|
+
currentStatus = statusInfo.label
|
|
163
|
+
// Count tasks in this status group
|
|
164
|
+
const count = sorted.filter((s) => statusLabels[s.status]?.label === currentStatus).length
|
|
165
|
+
lines.push(`${statusInfo.emoji} ${currentStatus} (${count}):`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const blocked = isBlocked(t)
|
|
169
|
+
const prefix = blocked && t.status === 'pending' ? '🚫 ' : ' '
|
|
170
|
+
const blockingNote = blocked ? ` 🚫 阻塞中 (等待: ${getBlockingIds(t).join(', ')})` : ''
|
|
171
|
+
lines.push(`${prefix}${formatTask(t)}${blockingNote}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Summary footer
|
|
175
|
+
const available = all.filter((t) => t.status === 'pending' && !isBlocked(t)).length
|
|
176
|
+
const blockedCount = all.filter((t) => t.status === 'pending' && isBlocked(t)).length
|
|
177
|
+
if (blockedCount > 0) {
|
|
178
|
+
lines.push('')
|
|
179
|
+
lines.push(
|
|
180
|
+
`📊 ${available} available · ${blockedCount} blocked · ${all.filter((t) => t.status === 'completed').length} done`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return lines.join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
export const taskTool: ToolDefinition = {
|
|
116
188
|
name: 'Task',
|
|
117
189
|
description:
|
|
@@ -205,67 +277,7 @@ export const taskTool: ToolDefinition = {
|
|
|
205
277
|
|
|
206
278
|
// ── LIST ──
|
|
207
279
|
if (action === 'list') {
|
|
208
|
-
|
|
209
|
-
if (all.length === 0) return { success: true, content: '(no tasks)' }
|
|
210
|
-
|
|
211
|
-
// Sort by availability: in_progress → pending(available) → pending(blocked) → completed
|
|
212
|
-
const sortOrder: Record<string, number> = {
|
|
213
|
-
in_progress: 0,
|
|
214
|
-
pending: 1,
|
|
215
|
-
completed: 2,
|
|
216
|
-
failed: 3,
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const sorted = [...all].sort((a, b) => {
|
|
220
|
-
const orderA = sortOrder[a.status] ?? 3
|
|
221
|
-
const orderB = sortOrder[b.status] ?? 3
|
|
222
|
-
if (orderA !== orderB) return orderA - orderB
|
|
223
|
-
// Within same status: available before blocked
|
|
224
|
-
if (a.status === 'pending' && b.status === 'pending') {
|
|
225
|
-
const aBlocked = isBlocked(a)
|
|
226
|
-
const bBlocked = isBlocked(b)
|
|
227
|
-
if (aBlocked !== bBlocked) return aBlocked ? 1 : -1
|
|
228
|
-
}
|
|
229
|
-
return 0
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
const lines: string[] = []
|
|
233
|
-
const statusLabels: Record<string, { emoji: string; label: string }> = {
|
|
234
|
-
in_progress: { emoji: '🔄', label: 'In Progress' },
|
|
235
|
-
pending: { emoji: '📋', label: 'Pending' },
|
|
236
|
-
completed: { emoji: '✅', label: 'Completed' },
|
|
237
|
-
failed: { emoji: '❌', label: 'Failed' },
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
let currentStatus = ''
|
|
241
|
-
for (const t of sorted) {
|
|
242
|
-
const statusInfo = statusLabels[t.status]
|
|
243
|
-
if (!statusInfo) continue
|
|
244
|
-
|
|
245
|
-
if (statusInfo.label !== currentStatus) {
|
|
246
|
-
currentStatus = statusInfo.label
|
|
247
|
-
// Count tasks in this status group
|
|
248
|
-
const count = sorted.filter((s) => statusLabels[s.status]?.label === currentStatus).length
|
|
249
|
-
lines.push(`${statusInfo.emoji} ${currentStatus} (${count}):`)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const blocked = isBlocked(t)
|
|
253
|
-
const prefix = blocked && t.status === 'pending' ? '🚫 ' : ' '
|
|
254
|
-
const blockingNote = blocked ? ` 🚫 阻塞中 (等待: ${getBlockingIds(t).join(', ')})` : ''
|
|
255
|
-
lines.push(`${prefix}${formatTask(t)}${blockingNote}`)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Summary footer
|
|
259
|
-
const available = all.filter((t) => t.status === 'pending' && !isBlocked(t)).length
|
|
260
|
-
const blockedCount = all.filter((t) => t.status === 'pending' && isBlocked(t)).length
|
|
261
|
-
if (blockedCount > 0) {
|
|
262
|
-
lines.push('')
|
|
263
|
-
lines.push(
|
|
264
|
-
`📊 ${available} available · ${blockedCount} blocked · ${all.filter((t) => t.status === 'completed').length} done`,
|
|
265
|
-
)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return { success: true, content: lines.join('\n') }
|
|
280
|
+
return { success: true, content: formatTaskList() }
|
|
269
281
|
}
|
|
270
282
|
|
|
271
283
|
// ── GET ──
|
package/src/ui/commands.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { SkillsLoader } from '../skills/loader'
|
|
|
11
11
|
import { loadSkillUsage } from '../skills/usage'
|
|
12
12
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
13
13
|
import type { Message } from '../shared/types.js'
|
|
14
|
-
import type { UpdateStatus } from '../shared/update'
|
|
14
|
+
import type { InstallState, UpdateStatus } from '../shared/update'
|
|
15
15
|
import { McpClient } from '../mcp/client'
|
|
16
16
|
import { unregisterMcpServerTools } from '../mcp/registry'
|
|
17
17
|
import { buildCapabilityReport } from '../core/capability-inventory'
|
|
@@ -110,6 +110,7 @@ import { listActiveAutoloops, formatLoopRows } from '../commands/autoloop-journa
|
|
|
110
110
|
import { execSync } from 'node:child_process'
|
|
111
111
|
import { MIPHAM_DIR, OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
112
112
|
import { renameActiveSession } from '../agent/cross-session/discovery'
|
|
113
|
+
import { formatTaskList } from '../tools/exec/task'
|
|
113
114
|
|
|
114
115
|
export interface CommandContext {
|
|
115
116
|
engine: QueryEngine
|
|
@@ -2775,7 +2776,11 @@ const rewindCmd: CommandHandler = (ctx) => {
|
|
|
2775
2776
|
count: String(result.messageCount),
|
|
2776
2777
|
remaining: String(c.getCheckpoints().length),
|
|
2777
2778
|
}),
|
|
2779
|
+
// `clearMessages` 单用是 `/clear` 的形状(屏幕上**没有**该留的东西);回退不一样 ——
|
|
2780
|
+
// 留到那一点为止的历史仍然算数,只是屏幕上的那份旧了。不带 `forwardedMessages`
|
|
2781
|
+
// 就会把消息列表清成空、再也不回填:模型看得见回退后的历史,用户看得见一片空白。
|
|
2778
2782
|
clearMessages: true,
|
|
2783
|
+
forwardedMessages: c.getMessages(),
|
|
2779
2784
|
}
|
|
2780
2785
|
}
|
|
2781
2786
|
|
|
@@ -2918,24 +2923,20 @@ const ultracodeCmd: CommandHandler = (ctx, args) => {
|
|
|
2918
2923
|
|
|
2919
2924
|
const tasksCmd: CommandHandler = (ctx) => {
|
|
2920
2925
|
const t = resolveT(ctx)
|
|
2921
|
-
const c = ctx.engine.getContext()
|
|
2922
|
-
const msgs = c.getMessages()
|
|
2923
2926
|
|
|
2924
|
-
//
|
|
2925
|
-
//
|
|
2926
|
-
//
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
return []
|
|
2932
|
-
})
|
|
2927
|
+
// 读**登记表**,不数历史。此前这个面板数的是历史里 `Task` 工具调用块的个数,
|
|
2928
|
+
// 于是压缩掉历史之后(块没了、任务还在)它会说「尚未跟踪任何任务」,而没有任务
|
|
2929
|
+
// 却有历史时又会说「检测到 N 次任务操作」。面板自称 "Background tasks"、标题写
|
|
2930
|
+
// 「后台任务」、空态写「尚未跟踪任何任务」—— 全是登记表词汇,而登记表就在
|
|
2931
|
+
// 一步之外(`getTasks()`,`GoalProgress` 早就接了)。
|
|
2932
|
+
const taskList = formatTaskList()
|
|
2933
|
+
const hasTasks = taskList !== '(no tasks)'
|
|
2933
2934
|
|
|
2934
2935
|
return {
|
|
2935
2936
|
content: stripIndent`
|
|
2936
2937
|
${t('commands.task_list.title')}
|
|
2937
2938
|
|
|
2938
|
-
${
|
|
2939
|
+
${hasTasks ? taskList : t('commands.task_list.no_tasks')}
|
|
2939
2940
|
|
|
2940
2941
|
${t('commands.task_list.reference')}
|
|
2941
2942
|
Task(action: "create") — create a new task
|
|
@@ -4001,6 +4002,14 @@ const telemetryCmd: CommandHandler = async (ctx, args) => {
|
|
|
4001
4002
|
`| Source | \`${consent.endpointSource}\` |`,
|
|
4002
4003
|
`| Install id | ${settings.installId ?? '_(not yet generated)_'} |`,
|
|
4003
4004
|
`| Prompted | ${settings.promptedAt ?? '_(never)_'} |`,
|
|
4005
|
+
// Only when there is something to say. An always-present "Ignored: _(none)_"
|
|
4006
|
+
// row would be noise in the common case, and this table is read when
|
|
4007
|
+
// something already looks wrong.
|
|
4008
|
+
...(consent.ignoredProjectKeys.length > 0
|
|
4009
|
+
? [
|
|
4010
|
+
`| Ignored from project | ${consent.ignoredProjectKeys.map((k) => `\`telemetry.${k}\``).join(', ')} — a repository may veto, not grant |`,
|
|
4011
|
+
]
|
|
4012
|
+
: []),
|
|
4004
4013
|
'',
|
|
4005
4014
|
'Full data dictionary: `docs/telemetry.md`',
|
|
4006
4015
|
]
|
|
@@ -4438,6 +4447,21 @@ const memoryCmd: CommandHandler = async (ctx, args) => {
|
|
|
4438
4447
|
// Upgrade
|
|
4439
4448
|
// ═══════════════════════════════════════════════════════════════
|
|
4440
4449
|
|
|
4450
|
+
/**
|
|
4451
|
+
* 失败后「我手里还有没有 CLI」这句话的四种说法。
|
|
4452
|
+
*
|
|
4453
|
+
* `Record<InstallState, …>` 而非内联三元:漏一个状态**编译期就红**。写成
|
|
4454
|
+
* `result.rolledBack ? A : B` 时漏掉的那一格会静默落进 else,而 else 说的正是**最重的
|
|
4455
|
+
* 那句**(「原安装未能恢复」)—— 拿「最坏情况」当兜底,就等于把每条没想清楚的路径都
|
|
4456
|
+
* 报成灾难。
|
|
4457
|
+
*/
|
|
4458
|
+
const UPGRADE_FAILURE_NOTE: Record<InstallState, string> = {
|
|
4459
|
+
untouched: 'commands.upgrade.install_untouched',
|
|
4460
|
+
restored: 'commands.upgrade.rolled_back',
|
|
4461
|
+
broken: 'commands.upgrade.no_rollback',
|
|
4462
|
+
unknown: 'commands.upgrade.install_unknown',
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4441
4465
|
const upgradeCmd: CommandHandler = async (ctx) => {
|
|
4442
4466
|
const t = resolveT(ctx)
|
|
4443
4467
|
const { checkForUpdates, backupConfig, performUpdate, restoreConfig, getConfigPath } =
|
|
@@ -4500,10 +4524,9 @@ const upgradeCmd: CommandHandler = async (ctx) => {
|
|
|
4500
4524
|
lines.push(t('commands.upgrade.old_version_warning'))
|
|
4501
4525
|
} else {
|
|
4502
4526
|
lines.push('')
|
|
4503
|
-
// 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
)
|
|
4527
|
+
// 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI**。四态各有各的说法 ——
|
|
4528
|
+
// 尤其「旧树没被碰过」与「旧树没能恢复」是两件不同的事,合并成一句后者必定说谎。
|
|
4529
|
+
const rollbackNote = t(UPGRADE_FAILURE_NOTE[result.installState])
|
|
4507
4530
|
lines.push(
|
|
4508
4531
|
t('commands.upgrade.update_failed', {
|
|
4509
4532
|
reason: result.reason ?? '',
|
package/src/ui/graft-status.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
2
|
import { Box, Text } from 'ink'
|
|
3
3
|
import { basename } from 'node:path'
|
|
4
4
|
import { findGraftStats, type GraftStats } from '../shared/graft-stats'
|
|
@@ -13,17 +13,46 @@ function freshnessSegment(stats: GraftStats): { label: string; color: string } {
|
|
|
13
13
|
return { label: '✓ synced', color: 'blue' }
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 只有**真变了**才换 state —— 否则每秒一次 setState 会把整棵树重渲染一遍。
|
|
18
|
+
* 两侧都出自 `findGraftStats` 的同一个 return 字面量,键序恒等,故字符串比较是稳的;
|
|
19
|
+
* 用整体比较而不是逐字段比,是为了将来给 `GraftStats` 加字段时**不必回来补这一行**。
|
|
20
|
+
*/
|
|
21
|
+
function sameStats(a: GraftStats | null, b: GraftStats | null): boolean {
|
|
22
|
+
if (a === b) return true
|
|
23
|
+
if (!a || !b) return false
|
|
24
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 重读间隔。与 `goal-progress` / `workflow-progress` 同法(那两处也是 1s 一拍)。 */
|
|
28
|
+
const STATS_REFRESH_MS = 1000
|
|
29
|
+
|
|
16
30
|
/**
|
|
17
31
|
* Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
|
|
18
32
|
* ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
|
|
19
33
|
* ▸ ctx X% · last: file
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
34
|
+
*
|
|
35
|
+
* `graft/.cache/stats.json`(向上走到仓库根)是**别人在后台写的**:graft 自己重建索引时
|
|
36
|
+
* 先落 `syncing: true`,建完再落回来。挂载时读一次就永远说那一拍的话 ⇒ 启动正好撞上索引
|
|
37
|
+
* 重建的话,页脚会一直挂着 `syncing…`,而磁盘上早已 `"syncing": false` ——「有人的读数与
|
|
38
|
+
* 没人读的那个对象不一致」。故这里**按拍重读**。
|
|
39
|
+
*
|
|
40
|
+
* 别把它降成「每次渲染读一遍」:那要靠别的组件把这一棵带着重渲染(敲键盘 / agent tick)
|
|
41
|
+
* 才生效,而索引建完的那一刻**恰恰可能一个键都没有** —— 页脚会一直撒谎到用户下次敲键。
|
|
42
|
+
*
|
|
43
|
+
* 其余同前:tok-saved 每次渲染实时读;只在有 graft 图或有已知 ctx% 时渲染 —— ctx% 行
|
|
44
|
+
* 在本目录没建过 graft 时也照常显示。
|
|
24
45
|
*/
|
|
25
46
|
export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
|
|
26
|
-
const [stats] = useState(() => findGraftStats(cwd))
|
|
47
|
+
const [stats, setStats] = useState(() => findGraftStats(cwd))
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const timer = setInterval(() => {
|
|
51
|
+
const next = findGraftStats(cwd)
|
|
52
|
+
setStats((prev) => (sameStats(prev, next) ? prev : next))
|
|
53
|
+
}, STATS_REFRESH_MS)
|
|
54
|
+
return () => clearInterval(timer)
|
|
55
|
+
}, [cwd])
|
|
27
56
|
const showCtx = typeof ctxPct === 'number'
|
|
28
57
|
if (!stats && !showCtx) return null
|
|
29
58
|
const fresh = stats ? freshnessSegment(stats) : null
|
package/src/ui/input.tsx
CHANGED
|
@@ -324,7 +324,12 @@ export function InputBar({
|
|
|
324
324
|
const suggestionReqIdRef = useRef(0)
|
|
325
325
|
|
|
326
326
|
// 统一清理:清 suggestion + 使在途/待发请求失效 + 取消防抖定时器。
|
|
327
|
-
// Escape / handleSubmit /
|
|
327
|
+
// Escape / handleSubmit / 翻历史 / **Tab 接受** 四处共用,避免各自手写漏掉某一环。
|
|
328
|
+
// Tab 那一处原先只手写 `setSuggestion(null)`(ROADMAP D8 缺口③):今天**不可达**
|
|
329
|
+
// ——有建议显示就说明上次请求已结束、定时器早烧掉了——但「接受后继续续写」一加上,
|
|
330
|
+
// 那个漏掉的环立刻变成洞。走同一入口,洞就不存在了。
|
|
331
|
+
// (`onChange` 里另有一处内联的同样三步,**不共用**:那里要的是把 reqId 推成**新**的,
|
|
332
|
+
// 与这里的「作废」是两件事。)
|
|
328
333
|
const clearSuggestion = () => {
|
|
329
334
|
setSuggestion(null)
|
|
330
335
|
suggestionReqIdRef.current++
|
|
@@ -411,7 +416,7 @@ export function InputBar({
|
|
|
411
416
|
const next = valueRef.current + suggestion
|
|
412
417
|
setValue(next)
|
|
413
418
|
valueRef.current = next
|
|
414
|
-
|
|
419
|
+
clearSuggestion()
|
|
415
420
|
return
|
|
416
421
|
}
|
|
417
422
|
// Ctrl+P → toggle model picker
|
package/src/ui/picker.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useCallback } from 'react'
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
3
|
import { useI18n } from '../i18n-context'
|
|
4
4
|
import { useKeyState } from './use-key-state'
|
|
@@ -34,7 +34,11 @@ export function ModelPicker({
|
|
|
34
34
|
const providers = config.providers.filter((p) => p.status !== 'upcoming')
|
|
35
35
|
|
|
36
36
|
// State
|
|
37
|
-
|
|
37
|
+
// 面板选择同样走 `useKeyState` —— 它与两个光标是**同一次按键里一起判读**的。
|
|
38
|
+
// 反例是「Tab ↓ Enter」一次刷进来:Ink 把 Tab 之后那段也拆成两拍,于是 ↓ 与
|
|
39
|
+
// Enter 都还在按**切换之前**那一拍算 —— ↓ 去动 provider 光标,Enter 走
|
|
40
|
+
// `goToProvider`(provider 面板上那条路)⇒ 一次都没选中。
|
|
41
|
+
const activePanel = useKeyState<Panel>('provider')
|
|
38
42
|
// 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),判据必须
|
|
39
43
|
// 读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
|
|
40
44
|
const providerIdx = useKeyState(() => {
|
|
@@ -51,7 +55,7 @@ export function ModelPicker({
|
|
|
51
55
|
(idx: number) => {
|
|
52
56
|
providerIdx.set(wrap(idx, providers.length))
|
|
53
57
|
modelIdx.set(0)
|
|
54
|
-
|
|
58
|
+
activePanel.set('model') // auto-switch to model panel
|
|
55
59
|
},
|
|
56
60
|
[providers.length, providerIdx, modelIdx],
|
|
57
61
|
)
|
|
@@ -93,7 +97,7 @@ export function ModelPicker({
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
if (key.return) {
|
|
96
|
-
if (activePanel === 'provider') {
|
|
100
|
+
if (activePanel.read() === 'provider') {
|
|
97
101
|
goToProvider(providerIdx.read()) // switches to model panel
|
|
98
102
|
} else {
|
|
99
103
|
confirmSelection()
|
|
@@ -102,20 +106,20 @@ export function ModelPicker({
|
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
// Tab or right arrow → switch to model panel
|
|
105
|
-
if (key.tab || (activePanel === 'provider' && input === 'l')) {
|
|
106
|
-
|
|
109
|
+
if (key.tab || (activePanel.read() === 'provider' && input === 'l')) {
|
|
110
|
+
activePanel.set('model')
|
|
107
111
|
return
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
// Left arrow → switch to provider panel
|
|
111
|
-
if (key.leftArrow || (activePanel === 'model' && input === 'h')) {
|
|
112
|
-
|
|
115
|
+
if (key.leftArrow || (activePanel.read() === 'model' && input === 'h')) {
|
|
116
|
+
activePanel.set('provider')
|
|
113
117
|
return
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
// Up/Down navigation
|
|
117
121
|
if (key.upArrow) {
|
|
118
|
-
if (activePanel === 'provider') {
|
|
122
|
+
if (activePanel.read() === 'provider') {
|
|
119
123
|
providerIdx.set((prev) => wrap(prev - 1, providers.length))
|
|
120
124
|
} else {
|
|
121
125
|
stepModel(-1)
|
|
@@ -124,7 +128,7 @@ export function ModelPicker({
|
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
if (key.downArrow) {
|
|
127
|
-
if (activePanel === 'provider') {
|
|
131
|
+
if (activePanel.read() === 'provider') {
|
|
128
132
|
providerIdx.set((prev) => wrap(prev + 1, providers.length))
|
|
129
133
|
} else {
|
|
130
134
|
stepModel(1)
|
|
@@ -153,11 +157,11 @@ export function ModelPicker({
|
|
|
153
157
|
flexDirection="column"
|
|
154
158
|
width={28}
|
|
155
159
|
borderStyle="single"
|
|
156
|
-
borderColor={activePanel === 'provider' ? 'cyan' : 'gray'}
|
|
160
|
+
borderColor={activePanel.value === 'provider' ? 'cyan' : 'gray'}
|
|
157
161
|
padding={1}
|
|
158
162
|
>
|
|
159
163
|
<Text bold underline dimColor>
|
|
160
|
-
{t('ui.picker.provider_label')} {activePanel === 'provider' ? '◀' : ''}
|
|
164
|
+
{t('ui.picker.provider_label')} {activePanel.value === 'provider' ? '◀' : ''}
|
|
161
165
|
</Text>
|
|
162
166
|
{providers.map((p, i) => {
|
|
163
167
|
const isCurrent = p.id === currentProvider
|
|
@@ -186,11 +190,11 @@ export function ModelPicker({
|
|
|
186
190
|
flexDirection="column"
|
|
187
191
|
width={42}
|
|
188
192
|
borderStyle="single"
|
|
189
|
-
borderColor={activePanel === 'model' ? 'cyan' : 'gray'}
|
|
193
|
+
borderColor={activePanel.value === 'model' ? 'cyan' : 'gray'}
|
|
190
194
|
padding={1}
|
|
191
195
|
>
|
|
192
196
|
<Text bold underline dimColor>
|
|
193
|
-
{t('ui.picker.models_label')} {activePanel === 'model' ? '◀' : ''}
|
|
197
|
+
{t('ui.picker.models_label')} {activePanel.value === 'model' ? '◀' : ''}
|
|
194
198
|
{selectedProvider ? ` — ${selectedProvider.name}` : ''}
|
|
195
199
|
</Text>
|
|
196
200
|
{models.length === 0 && <Text dimColor> {t('ui.picker.no_active_models')}</Text>}
|
|
@@ -221,7 +225,7 @@ export function ModelPicker({
|
|
|
221
225
|
{/* Footer hint */}
|
|
222
226
|
<Box marginTop={1}>
|
|
223
227
|
<Text dimColor>
|
|
224
|
-
{activePanel === 'provider'
|
|
228
|
+
{activePanel.value === 'provider'
|
|
225
229
|
? t('ui.picker.select_provider_hint')
|
|
226
230
|
: t('ui.picker.select_model_hint')}
|
|
227
231
|
</Text>
|
|
@@ -46,13 +46,6 @@ export async function workflowAgent(
|
|
|
46
46
|
opts: WorkflowAgentOpts = {},
|
|
47
47
|
llm?: Llm,
|
|
48
48
|
): Promise<unknown> {
|
|
49
|
-
// If provider override, switch temporarily
|
|
50
|
-
if (opts.provider) {
|
|
51
|
-
registry.switchProvider(opts.provider, opts.model)
|
|
52
|
-
} else if (opts.model) {
|
|
53
|
-
registry.switchProvider(registry.getActive().config.id, opts.model)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
49
|
const maxRetries = opts.maxRetries ?? 2
|
|
57
50
|
|
|
58
51
|
// ── Worktree isolation setup ──
|
|
@@ -93,7 +86,26 @@ export async function workflowAgent(
|
|
|
93
86
|
let lastResult = ''
|
|
94
87
|
let lastErrors: string[] = []
|
|
95
88
|
|
|
89
|
+
/** Set only when we actually switched — and then it is the way back. */
|
|
90
|
+
let restore: (() => void) | undefined
|
|
91
|
+
|
|
96
92
|
try {
|
|
93
|
+
// ── Provider override, scoped to this call ──
|
|
94
|
+
// 「临时」必须是真的。registry 的 active 是**会话级**的:`SubAgent` 在
|
|
95
|
+
// `runExecution` 里读 `registry.getActiveModel()`、`registry.chat` 按 active 路由,
|
|
96
|
+
// 引擎的页脚与 `/model` 面板读的也是它 —— 覆盖不还原,等于一个 workflow 里的一次
|
|
97
|
+
// `agent(…, { provider: 'x' })` 把整台机器的会话换到 x 上。
|
|
98
|
+
//
|
|
99
|
+
// 切在这里而不是函数开头:上面那段建 worktree 会抛(`git worktree add` 非 0 即
|
|
100
|
+
// throw),抛在切走之前就不该已经切走。
|
|
101
|
+
if (opts.provider || opts.model) {
|
|
102
|
+
const prevProviderId = registry.getActive().config.id
|
|
103
|
+
const prevModelId = registry.getActiveModel()
|
|
104
|
+
// `opts.provider` 缺席时只换模型 —— provider 保持这一次调用开始时的那一个。
|
|
105
|
+
registry.switchProvider(opts.provider ?? prevProviderId, opts.model)
|
|
106
|
+
restore = () => registry.switchProvider(prevProviderId, prevModelId)
|
|
107
|
+
}
|
|
108
|
+
|
|
97
109
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
98
110
|
const retryPrompt =
|
|
99
111
|
attempt === 0
|
|
@@ -158,6 +170,10 @@ export async function workflowAgent(
|
|
|
158
170
|
|
|
159
171
|
return result
|
|
160
172
|
} finally {
|
|
173
|
+
// 还原排在 finally 的**第一件**:下面的 worktree 清理要 spawn git、还可能抛,
|
|
174
|
+
// 排在它之后就等于「清理顺利时才还原」—— 而那正是最不需要还原的场合。
|
|
175
|
+
restore?.()
|
|
176
|
+
|
|
161
177
|
// ── Cleanup worktree ──
|
|
162
178
|
if (worktreePath) {
|
|
163
179
|
// Best-effort: auto-commit any changes
|