@miphamai/cli 0.85.2 → 0.85.4
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 +42 -2
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +9 -2
- package/src/agent/sub-agent.ts +23 -2
- package/src/agent/types.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +46 -0
- package/src/agent-view/dashboard.tsx +84 -16
- package/src/agent-view/session-view.tsx +128 -0
- package/src/commands/project.ts +80 -27
- package/src/config/loader.ts +98 -6
- package/src/core/context.ts +35 -6
- package/src/core/instructions.ts +69 -48
- package/src/core/permission-classifier.ts +21 -3
- package/src/core/permission-config.ts +28 -7
- package/src/daemon/attach-protocol.ts +30 -3
- package/src/daemon/remote-engine.ts +173 -24
- package/src/daemon/server.ts +51 -1
- package/src/daemon/session-worker.ts +34 -1
- package/src/index.tsx +69 -17
- package/src/shared/arg-validation.ts +74 -2
- package/src/shared/package-info.ts +1 -1
- package/src/skills/bundled-skills.ts +1 -1
- package/src/ui/app.tsx +30 -1
- package/src/ui/commands.ts +68 -29
package/bin/mipham.ts
CHANGED
|
@@ -437,6 +437,19 @@ async function runAttachCLI(): Promise<boolean> {
|
|
|
437
437
|
const args = process.argv.slice(2)
|
|
438
438
|
if (args[0] !== 'attach') return false
|
|
439
439
|
|
|
440
|
+
// `--permission <mode>` is the same flag here as on the interactive path, because it
|
|
441
|
+
// is the same gate — it just lives on the other side of the socket. The request goes
|
|
442
|
+
// out as `set_mode` when the TUI starts, and the daemon's answer is what the footer
|
|
443
|
+
// shows: it clamps, so `--permission bypassPermissions` under an org cap comes back
|
|
444
|
+
// narrower. Checked before the daemon is contacted: a misspelled mode is wrong whether
|
|
445
|
+
// or not a daemon happens to be running.
|
|
446
|
+
const { parsePermissionFlag, firstPositional } = await import('../src/shared/arg-validation')
|
|
447
|
+
const permissionFlag = parsePermissionFlag(args)
|
|
448
|
+
if (permissionFlag.kind === 'error') {
|
|
449
|
+
console.error(permissionFlag.message)
|
|
450
|
+
process.exit(1)
|
|
451
|
+
}
|
|
452
|
+
|
|
440
453
|
const { getPort, getDaemonStatus } = await import('../src/daemon/index')
|
|
441
454
|
const { join } = await import('node:path')
|
|
442
455
|
const { readFileSync, existsSync } = await import('node:fs')
|
|
@@ -479,7 +492,10 @@ async function runAttachCLI(): Promise<boolean> {
|
|
|
479
492
|
}
|
|
480
493
|
|
|
481
494
|
const latestFlag = args.includes('--latest')
|
|
482
|
-
|
|
495
|
+
// Skip flags **and the values of value-taking flags**: with `--permission plan <id>`
|
|
496
|
+
// the old `args[1]` read `--permission` (starts with `-`, so no id) and silently fell
|
|
497
|
+
// through to the session list — the flag would have looked accepted and done nothing.
|
|
498
|
+
const sessionIdArg = firstPositional(args.slice(1)) ?? undefined
|
|
483
499
|
|
|
484
500
|
let targetSession: SessionInfo | null = null
|
|
485
501
|
|
|
@@ -534,6 +550,7 @@ async function runAttachCLI(): Promise<boolean> {
|
|
|
534
550
|
const { runApp } = await import('../src/index')
|
|
535
551
|
await runApp({
|
|
536
552
|
version,
|
|
553
|
+
permission: permissionFlag.kind === 'ok' ? permissionFlag.mode : undefined,
|
|
537
554
|
remoteSession: {
|
|
538
555
|
sessionId: targetSession.id,
|
|
539
556
|
port,
|
|
@@ -1198,6 +1215,10 @@ async function main() {
|
|
|
1198
1215
|
process.argv.includes('-h') ||
|
|
1199
1216
|
process.argv.slice(2).some((a) => a === 'help')
|
|
1200
1217
|
) {
|
|
1218
|
+
// The mode list is **derived**, never typed out here — a help screen with its own copy
|
|
1219
|
+
// is how it comes to advertise a mode the flag then refuses (see `arg-validation`:
|
|
1220
|
+
// the list in the error message and the list in the help are the same array).
|
|
1221
|
+
const { ALL_MODES } = await import('../src/core/permission-config')
|
|
1201
1222
|
console.log(`Mipham Code — AI-powered coding terminal
|
|
1202
1223
|
|
|
1203
1224
|
Usage:
|
|
@@ -1226,6 +1247,8 @@ Flags:
|
|
|
1226
1247
|
--dump-config Print the assembled profile tree
|
|
1227
1248
|
--safe-mode Skip custom agents, skills, hooks, plugins
|
|
1228
1249
|
--resume <name> Open a saved session (see /resume for names)
|
|
1250
|
+
--permission <mode> Start in this mode: ${ALL_MODES.join('|')}
|
|
1251
|
+
(also accepted by 'mipham attach'; the daemon may clamp it)
|
|
1229
1252
|
--version, -v, -V Print version
|
|
1230
1253
|
|
|
1231
1254
|
Docs: https://mipham.ai/code
|
|
@@ -1348,9 +1371,26 @@ npm: https://www.npmjs.com/package/@miphamai/cli`)
|
|
|
1348
1371
|
}
|
|
1349
1372
|
}
|
|
1350
1373
|
|
|
1374
|
+
// Parse --permission <mode>: the session's starting mode. `runApp` has accepted
|
|
1375
|
+
// `options.permission` all along — the system prompt and the engine both read the live
|
|
1376
|
+
// permission system it is applied to — but **nothing ever passed it**, so the only ways
|
|
1377
|
+
// to pick a mode were `config.yml`, Shift+Tab after startup, or the daemon's env var.
|
|
1378
|
+
// Same shape as `--resume` above; same remedy, and the value is refused rather than
|
|
1379
|
+
// silently replaced (see `parsePermissionFlag`).
|
|
1380
|
+
const { parsePermissionFlag } = await import('../src/shared/arg-validation')
|
|
1381
|
+
const permissionFlag = parsePermissionFlag(process.argv.slice(2))
|
|
1382
|
+
if (permissionFlag.kind === 'error') {
|
|
1383
|
+
console.error(permissionFlag.message)
|
|
1384
|
+
process.exit(1)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1351
1387
|
try {
|
|
1352
1388
|
const { runApp } = await import('../src/index')
|
|
1353
|
-
await runApp({
|
|
1389
|
+
await runApp({
|
|
1390
|
+
version: APP_VERSION,
|
|
1391
|
+
resume: resumeName,
|
|
1392
|
+
permission: permissionFlag.kind === 'ok' ? permissionFlag.mode : undefined,
|
|
1393
|
+
})
|
|
1354
1394
|
} catch (err: unknown) {
|
|
1355
1395
|
const msg = err instanceof Error ? err.message : String(err)
|
|
1356
1396
|
if (msg.includes('react-devtools-core')) {
|
package/package.json
CHANGED
|
@@ -360,13 +360,20 @@ Press **Shift+Tab** to change the mode live; it cycles
|
|
|
360
360
|
`bypassPermissions` is a legal mode but is deliberately **not** on the wheel —
|
|
361
361
|
it is reached by naming it in config, where the user has said what they mean.
|
|
362
362
|
|
|
363
|
-
To persist a mode
|
|
363
|
+
To persist a mode, name it in `~/.mipham/config.yml`:
|
|
364
364
|
|
|
365
365
|
```yaml
|
|
366
366
|
permission: default
|
|
367
367
|
```
|
|
368
368
|
|
|
369
|
-
|
|
369
|
+
Or in `~/.mipham/settings.json` as `permissions.defaultMode` (the key Claude Code
|
|
370
|
+
users already have). Any mode name from the table above is accepted in either,
|
|
371
|
+
including `bypassPermissions`.
|
|
372
|
+
|
|
373
|
+
A **project-level** `.mipham/config.yml` cannot set a mode — that file arrives with
|
|
374
|
+
the code, so whoever wrote the repository would be choosing the approval gate. A
|
|
375
|
+
mode found there is ignored and reported; the fix is to move the line to the user
|
|
376
|
+
level, or to pass `--permission <mode>` for one invocation.
|
|
370
377
|
|
|
371
378
|
Or via slash command:
|
|
372
379
|
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { getBackgroundAgentRegistry } from './background-registry'
|
|
|
7
7
|
import { formatInboundMessage, getMessageBus } from './message-bus'
|
|
8
8
|
import type { HookEngine } from '../core/hooks'
|
|
9
9
|
import { PermissionSystem } from '../core/permission'
|
|
10
|
+
import { buildPermissionBlock } from '../core/instructions'
|
|
10
11
|
import { AgentExperience } from './agent-experience'
|
|
11
12
|
import { PatternAnalyzer } from './pattern-analyzer.js'
|
|
12
13
|
import { getWorkspaceTrust } from '../core/workspace-trust'
|
|
@@ -148,7 +149,10 @@ export class SubAgent {
|
|
|
148
149
|
|
|
149
150
|
// ── Synchronous execution path ──
|
|
150
151
|
try {
|
|
151
|
-
|
|
152
|
+
// A caller that spawned the registry task itself owns the only abort
|
|
153
|
+
// controller for it; without passing it down the run ignores every check
|
|
154
|
+
// inside `runExecution` and cannot be stopped from the dashboard.
|
|
155
|
+
const result = await this.runExecution(prompt, options, options.signal)
|
|
152
156
|
if (this.hookEngine) {
|
|
153
157
|
await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
|
|
154
158
|
}
|
|
@@ -385,7 +389,24 @@ export class SubAgent {
|
|
|
385
389
|
let hitMaxTurns = false
|
|
386
390
|
|
|
387
391
|
let currentMessages = messages
|
|
388
|
-
|
|
392
|
+
// ── 子代理报**它自己的档**,不是父档 ──
|
|
393
|
+
// `gate` 是每个工具调用真正过的那个闸门(上面构造的那个,缺省是 `default`);
|
|
394
|
+
// 闸门的模式取自 `createSubAgentPermission` 的**解析结果** —— 定义写了
|
|
395
|
+
// `permissionMode` 的就是它自己那一档,写 `inherit` 的才是父档 —— 这里只读结果、
|
|
396
|
+
// 不重算,免得同一个事实有第二份推导。读 `getMode()` 而不是定义里那个字符串:
|
|
397
|
+
// 组织级上限(`maxAllowedMode`/`forbiddenModes`)会静默把模式钳走,报请求档就是
|
|
398
|
+
// 报得比实际宽 —— 与系统提示那边(`index.tsx` 的接线行)同一个理由。
|
|
399
|
+
//
|
|
400
|
+
// 它烘在**这一处**而不是走 `ContextManager.setPermissionContextSource`:子代理的请求
|
|
401
|
+
// 读的是下面这个局部变量(`systemPrompt: currentSystemPrompt`),**从不读上下文**的
|
|
402
|
+
// 系统提示 —— 挂在上下文上会是一处装饰(有接线、请求里一个字都到不了),正是本仓库
|
|
403
|
+
// 反复收的那种账。同样因为后续回合把提示清成 `''`(`currentSystemPrompt = ''`,
|
|
404
|
+
// 首轮才带系统提示),
|
|
405
|
+
// 权限段随**唯一**带提示的那一轮走,而不是每轮派生。
|
|
406
|
+
const permissionBlock = buildPermissionBlock(gate.getMode())
|
|
407
|
+
let currentSystemPrompt = permissionBlock
|
|
408
|
+
? `${systemPrompt}\n\n---\n\n${permissionBlock}`
|
|
409
|
+
: systemPrompt
|
|
389
410
|
let totalTokens = 0
|
|
390
411
|
|
|
391
412
|
// Context for the sub-agent's own tool calls. Built once per run: the caller's
|
package/src/agent/types.ts
CHANGED
|
@@ -55,6 +55,16 @@ export interface SubAgentOptions {
|
|
|
55
55
|
worktreePath?: string
|
|
56
56
|
/** Seed the sub-agent with a parent conversation prefix (e.g., fork inheritance). */
|
|
57
57
|
inheritContext?: { messages: Message[] }
|
|
58
|
+
/**
|
|
59
|
+
* Abort signal for a caller-owned execution.
|
|
60
|
+
*
|
|
61
|
+
* The `runInBackground` path mints its own controller inside `SubAgent`, so it
|
|
62
|
+
* needs nothing here. A caller that spawned the task in the registry itself
|
|
63
|
+
* (`/bg`, `/fork`) holds the only handle that can stop it — and if it does not
|
|
64
|
+
* hand that signal down, the signal is decoration: the registry's `stop()`
|
|
65
|
+
* aborts a controller nobody reads, and the agent runs to completion.
|
|
66
|
+
*/
|
|
67
|
+
signal?: AbortSignal
|
|
58
68
|
/**
|
|
59
69
|
* Who authored the prompt. `'script'` marks text computed by a workflow
|
|
60
70
|
* script — which is not the user speaking, even though it arrives as the
|
|
@@ -34,6 +34,16 @@ export interface AgentSession {
|
|
|
34
34
|
branch?: string
|
|
35
35
|
/** Created PR URL (if requested) */
|
|
36
36
|
prUrl?: string
|
|
37
|
+
/**
|
|
38
|
+
* Id of the `BackgroundAgentRegistry` task actually doing the work, when this
|
|
39
|
+
* session was spawned by `/bg` or `/fork`.
|
|
40
|
+
*
|
|
41
|
+
* The two id spaces are minted separately (`agent-…` here, `bg-…` there), so
|
|
42
|
+
* without this the dashboard holds a row it cannot act on: its own `kill()`
|
|
43
|
+
* only flips the row's status and leaves the real task running with no handle
|
|
44
|
+
* left in the UI. This field is that handle.
|
|
45
|
+
*/
|
|
46
|
+
taskId?: string
|
|
37
47
|
/** Working directory the session was spawned from (used for directory grouping). */
|
|
38
48
|
directory: string
|
|
39
49
|
}
|
|
@@ -60,6 +70,35 @@ export class AgentViewManager {
|
|
|
60
70
|
private sessions: Map<string, AgentSession> = new Map()
|
|
61
71
|
private sessionOrder: string[] = []
|
|
62
72
|
private idCounter = 0
|
|
73
|
+
private listeners: Set<() => void> = new Set()
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Subscribe to any mutation of the session set. Returns an unsubscribe fn.
|
|
77
|
+
*
|
|
78
|
+
* The dashboard renders from this object, but the changes originate elsewhere
|
|
79
|
+
* (`/bg` and `/fork` mutate it from their executors, which resolve long after
|
|
80
|
+
* the keystroke that spawned them). Without a subscription the view is a
|
|
81
|
+
* snapshot painted once at mount: `setVersion` had exactly one caller
|
|
82
|
+
* (Ctrl+X), so a row that finished while the panel was open stayed `working`
|
|
83
|
+
* on screen forever.
|
|
84
|
+
*/
|
|
85
|
+
onChange(listener: () => void): () => void {
|
|
86
|
+
this.listeners.add(listener)
|
|
87
|
+
return () => {
|
|
88
|
+
this.listeners.delete(listener)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One listener throwing must not stop the others, nor the mutation itself. */
|
|
93
|
+
private notify(): void {
|
|
94
|
+
for (const listener of this.listeners) {
|
|
95
|
+
try {
|
|
96
|
+
listener()
|
|
97
|
+
} catch {
|
|
98
|
+
// A broken view must never break the state transition that triggered it.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
63
102
|
|
|
64
103
|
/**
|
|
65
104
|
* Create a new background agent session.
|
|
@@ -82,6 +121,7 @@ export class AgentViewManager {
|
|
|
82
121
|
|
|
83
122
|
this.sessions.set(id, session)
|
|
84
123
|
this.sessionOrder.push(id)
|
|
124
|
+
this.notify()
|
|
85
125
|
|
|
86
126
|
return session
|
|
87
127
|
}
|
|
@@ -171,6 +211,7 @@ export class AgentViewManager {
|
|
|
171
211
|
session.startedAt = new Date()
|
|
172
212
|
}
|
|
173
213
|
|
|
214
|
+
this.notify()
|
|
174
215
|
return session
|
|
175
216
|
}
|
|
176
217
|
|
|
@@ -189,6 +230,7 @@ export class AgentViewManager {
|
|
|
189
230
|
|
|
190
231
|
session.status = 'failed'
|
|
191
232
|
session.completedAt = new Date()
|
|
233
|
+
this.notify()
|
|
192
234
|
return true
|
|
193
235
|
}
|
|
194
236
|
|
|
@@ -208,6 +250,7 @@ export class AgentViewManager {
|
|
|
208
250
|
session.completedAt = new Date()
|
|
209
251
|
}
|
|
210
252
|
|
|
253
|
+
this.notify()
|
|
211
254
|
return true
|
|
212
255
|
}
|
|
213
256
|
|
|
@@ -219,6 +262,7 @@ export class AgentViewManager {
|
|
|
219
262
|
if (!session) return false
|
|
220
263
|
|
|
221
264
|
session.messages.push(message)
|
|
265
|
+
this.notify()
|
|
222
266
|
return true
|
|
223
267
|
}
|
|
224
268
|
|
|
@@ -247,6 +291,7 @@ export class AgentViewManager {
|
|
|
247
291
|
const session = this.sessions.get(id)
|
|
248
292
|
if (!session) return false
|
|
249
293
|
session.title = newTitle
|
|
294
|
+
this.notify()
|
|
250
295
|
return true
|
|
251
296
|
}
|
|
252
297
|
|
|
@@ -257,6 +302,7 @@ export class AgentViewManager {
|
|
|
257
302
|
if (!this.sessions.has(id)) return false
|
|
258
303
|
this.sessions.delete(id)
|
|
259
304
|
this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
|
|
305
|
+
this.notify()
|
|
260
306
|
return true
|
|
261
307
|
}
|
|
262
308
|
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* mipham agents (from CLI)
|
|
9
9
|
* /agents (from slash command within a running session)
|
|
10
10
|
*/
|
|
11
|
-
import React, { useState, useCallback, useMemo } from 'react'
|
|
11
|
+
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
|
12
12
|
import { Box, Text, useInput } from 'ink'
|
|
13
|
+
import TextInput from 'ink-text-input'
|
|
13
14
|
import { AgentViewManager, type AgentSession, type SessionStatus } from './agent-view-manager'
|
|
14
15
|
import { SessionRow } from './session-row'
|
|
15
16
|
import { SessionPeek } from './session-peek'
|
|
16
17
|
import { useCtrlCConfirm } from '../ui/ctrl-c-confirm'
|
|
18
|
+
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
17
19
|
|
|
18
20
|
interface DashboardProps {
|
|
19
21
|
manager: AgentViewManager
|
|
@@ -33,8 +35,10 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
33
35
|
const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
|
|
34
36
|
const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
|
|
35
37
|
const [feedback, setFeedback] = useState<string | null>(null)
|
|
36
|
-
// Bump to force flatList recompute after
|
|
38
|
+
// Bump to force flatList recompute after the session set changes.
|
|
37
39
|
const [version, setVersion] = useState(0)
|
|
40
|
+
// Non-null while the rename box is open; holds the row being renamed + the draft.
|
|
41
|
+
const [renaming, setRenaming] = useState<{ id: string; draft: string } | null>(null)
|
|
38
42
|
|
|
39
43
|
// Ctrl+C 的「再按一次才退」,与主界面同源(见 ui/ctrl-c-confirm.ts)
|
|
40
44
|
const ctrlC = useCtrlCConfirm()
|
|
@@ -45,6 +49,12 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
45
49
|
setTimeout(() => setFeedback(null), 1800)
|
|
46
50
|
}, [])
|
|
47
51
|
|
|
52
|
+
// The mutations that matter happen outside this component: `/bg` and `/fork`
|
|
53
|
+
// resolve their executors long after the keystroke that spawned them, so a
|
|
54
|
+
// run that finishes while this panel is open used to leave the row — and the
|
|
55
|
+
// header counts — frozen at whatever they were when the panel mounted.
|
|
56
|
+
useEffect(() => manager.onChange(() => setVersion((v) => v + 1)), [manager])
|
|
57
|
+
|
|
48
58
|
// Build a flat list of sessions in group order, with group headers
|
|
49
59
|
const flatList = useMemo(() => {
|
|
50
60
|
const result: Array<
|
|
@@ -115,7 +125,37 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
115
125
|
[manager, onAttach],
|
|
116
126
|
)
|
|
117
127
|
|
|
128
|
+
// Commit the open rename box. Empty title cancels — a session with no name is
|
|
129
|
+
// indistinguishable in the list, so the box refuses rather than writing ''.
|
|
130
|
+
const handleRenameSubmit = useCallback(
|
|
131
|
+
(value: string) => {
|
|
132
|
+
const id = renaming?.id
|
|
133
|
+
setRenaming(null)
|
|
134
|
+
if (!id) return
|
|
135
|
+
const title = value.trim()
|
|
136
|
+
if (!title) {
|
|
137
|
+
showFeedback('Rename cancelled — title cannot be empty')
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
manager.rename(id, title)
|
|
141
|
+
showFeedback(`Renamed to ${title}`)
|
|
142
|
+
},
|
|
143
|
+
[manager, renaming, showFeedback],
|
|
144
|
+
)
|
|
145
|
+
|
|
118
146
|
useInput((input, key) => {
|
|
147
|
+
// While the rename box is open it owns the keyboard: the list keys below
|
|
148
|
+
// must not fire on the same keystroke that is being typed into the title
|
|
149
|
+
// (Ink delivers every key to every mounted useInput).
|
|
150
|
+
if (renaming) {
|
|
151
|
+
if (key.escape || (key.ctrl && input === 'c')) {
|
|
152
|
+
setRenaming(null)
|
|
153
|
+
ctrlC.reset()
|
|
154
|
+
showFeedback('Rename cancelled')
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
119
159
|
// Ctrl+C 不再一下就退出(Ink 的 `exitOnCtrlC` 已在 render 处关掉,见
|
|
120
160
|
// src/index.tsx):第一次只提示,再按一次才走。面板里 Esc 已经是退出键,
|
|
121
161
|
// 所以这里只补「误按一次不带走整个面板」。
|
|
@@ -150,7 +190,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
150
190
|
return
|
|
151
191
|
}
|
|
152
192
|
|
|
153
|
-
// Ctrl+R — rename selected session
|
|
193
|
+
// Ctrl+R — open the rename box on the selected session
|
|
154
194
|
if (key.ctrl && input === 'r') {
|
|
155
195
|
if (sessionsOnly.length === 0) {
|
|
156
196
|
showFeedback('No sessions to rename')
|
|
@@ -158,13 +198,13 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
158
198
|
}
|
|
159
199
|
const current = sessionsOnly[selectedIndex]
|
|
160
200
|
if (!current) return
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
201
|
+
// Seeded with the current title: renaming is usually an edit, and a box
|
|
202
|
+
// that opens blank silently invites the user to retype a whole task name.
|
|
203
|
+
setRenaming({ id: current.session.id, draft: current.session.title })
|
|
164
204
|
return
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
// Ctrl+X —
|
|
207
|
+
// Ctrl+X — stop the selected session's work (if any) and drop its row
|
|
168
208
|
if (key.ctrl && input === 'x') {
|
|
169
209
|
if (sessionsOnly.length === 0) {
|
|
170
210
|
showFeedback('No sessions to remove')
|
|
@@ -172,11 +212,20 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
172
212
|
}
|
|
173
213
|
const current = sessionsOnly[selectedIndex]
|
|
174
214
|
if (!current) return
|
|
175
|
-
|
|
215
|
+
const session = current.session
|
|
216
|
+
// Discarding a row whose work is still running would throw away the only
|
|
217
|
+
// handle to it — the manager's own `kill()` flips a status label and
|
|
218
|
+
// stops nothing, and the running sub-agent checks the registry's abort
|
|
219
|
+
// signal. So: stop the task, then remove the row.
|
|
220
|
+
const stopped = session.taskId ? getBackgroundAgentRegistry().stop(session.taskId) : false
|
|
221
|
+
manager.remove(session.id)
|
|
176
222
|
setPeekingSessionId(null)
|
|
177
223
|
setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessionsOnly.length - 2)))
|
|
178
|
-
|
|
179
|
-
|
|
224
|
+
showFeedback(
|
|
225
|
+
stopped
|
|
226
|
+
? `Stopped + removed ${session.title || session.id}`
|
|
227
|
+
: `Removed ${session.title || session.id}`,
|
|
228
|
+
)
|
|
180
229
|
return
|
|
181
230
|
}
|
|
182
231
|
|
|
@@ -220,11 +269,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
220
269
|
}
|
|
221
270
|
const current = sessionsOnly[selectedIndex]
|
|
222
271
|
if (!current) return
|
|
223
|
-
|
|
224
|
-
handleAttach(current.session.id)
|
|
225
|
-
} else {
|
|
226
|
-
handleAttach(current.session.id)
|
|
227
|
-
}
|
|
272
|
+
handleAttach(current.session.id)
|
|
228
273
|
return
|
|
229
274
|
}
|
|
230
275
|
})
|
|
@@ -233,7 +278,9 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
233
278
|
const peekData = useMemo(() => {
|
|
234
279
|
if (!peekingSessionId) return null
|
|
235
280
|
return manager.peek(peekingSessionId) ?? null
|
|
236
|
-
|
|
281
|
+
// `version` is a dependency so an open peek keeps up with a session that
|
|
282
|
+
// reports new messages while the viewer sits on it.
|
|
283
|
+
}, [manager, peekingSessionId, version])
|
|
237
284
|
|
|
238
285
|
const totalSessions = sessionsOnly.length
|
|
239
286
|
const counts = manager.countByStatus()
|
|
@@ -323,6 +370,27 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
323
370
|
<SessionPeek session={peekData.session} recentMessages={peekData.recentMessages} />
|
|
324
371
|
)}
|
|
325
372
|
|
|
373
|
+
{/* Rename box — opens on Ctrl+R, owns the keyboard until Enter/Esc */}
|
|
374
|
+
{renaming && (
|
|
375
|
+
<Box
|
|
376
|
+
flexDirection="column"
|
|
377
|
+
marginTop={1}
|
|
378
|
+
borderStyle="single"
|
|
379
|
+
borderColor="cyan"
|
|
380
|
+
padding={1}
|
|
381
|
+
>
|
|
382
|
+
<Text dimColor>Rename session — Enter to save · Esc to cancel</Text>
|
|
383
|
+
<Box>
|
|
384
|
+
<Text color="cyan">{'> '}</Text>
|
|
385
|
+
<TextInput
|
|
386
|
+
value={renaming.draft}
|
|
387
|
+
onChange={(draft) => setRenaming((cur) => (cur ? { ...cur, draft } : cur))}
|
|
388
|
+
onSubmit={handleRenameSubmit}
|
|
389
|
+
/>
|
|
390
|
+
</Box>
|
|
391
|
+
</Box>
|
|
392
|
+
)}
|
|
393
|
+
|
|
326
394
|
{/* Feedback toast — flashes briefly on action */}
|
|
327
395
|
{feedback && (
|
|
328
396
|
<Box marginTop={1}>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentSessionView — where Enter ("attach") in the Agent View dashboard lands.
|
|
3
|
+
*
|
|
4
|
+
* Read-only on purpose. The session's work runs inside its own `SubAgent` with
|
|
5
|
+
* its own context; this CLI has no channel back into that loop, so a view that
|
|
6
|
+
* accepted input would be collecting keystrokes it could not deliver. What this
|
|
7
|
+
* shows is the real thing instead: the transcript the session actually
|
|
8
|
+
* accumulated, kept current through the manager's change notifications.
|
|
9
|
+
*
|
|
10
|
+
* Esc returns to the dashboard.
|
|
11
|
+
*/
|
|
12
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
13
|
+
import { Box, Text, useInput } from 'ink'
|
|
14
|
+
import { AgentViewManager, type SessionStatus } from './agent-view-manager'
|
|
15
|
+
import { ChatPanel } from '../ui/chat'
|
|
16
|
+
|
|
17
|
+
interface AgentSessionViewProps {
|
|
18
|
+
manager: AgentViewManager
|
|
19
|
+
sessionId: string
|
|
20
|
+
onDetach: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATUS_COLORS: Record<SessionStatus, string> = {
|
|
24
|
+
'needs-input': 'yellow',
|
|
25
|
+
working: 'cyan',
|
|
26
|
+
completed: 'green',
|
|
27
|
+
failed: 'red',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STATUS_LABELS: Record<SessionStatus, string> = {
|
|
31
|
+
'needs-input': 'needs input',
|
|
32
|
+
working: 'working',
|
|
33
|
+
completed: 'completed',
|
|
34
|
+
failed: 'failed',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function AgentSessionView({ manager, sessionId, onDetach }: AgentSessionViewProps) {
|
|
38
|
+
const [version, setVersion] = useState(0)
|
|
39
|
+
|
|
40
|
+
// The session keeps running while we look at it — follow it rather than
|
|
41
|
+
// photographing it once at mount.
|
|
42
|
+
useEffect(() => manager.onChange(() => setVersion((v) => v + 1)), [manager])
|
|
43
|
+
|
|
44
|
+
const session = useMemo(
|
|
45
|
+
() => manager.get(sessionId),
|
|
46
|
+
// `version` is a dependency so the transcript keeps up with a session that
|
|
47
|
+
// reports new messages while the viewer sits on it.
|
|
48
|
+
[manager, sessionId, version],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
useInput((_input, key) => {
|
|
52
|
+
if (key.escape) onDetach()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Esc while the agent view is open is the app's "go back" — but the app-level
|
|
56
|
+
// handler lives in `app.tsx` and only knows its own layers, so this component
|
|
57
|
+
// answers for itself.
|
|
58
|
+
const status = session ? STATUS_LABELS[session.status] : 'gone'
|
|
59
|
+
const statusColor = session ? STATUS_COLORS[session.status] : 'red'
|
|
60
|
+
|
|
61
|
+
const transcript = session?.messages ?? []
|
|
62
|
+
const emptyState = useCallback(
|
|
63
|
+
() => (
|
|
64
|
+
<Box paddingLeft={2}>
|
|
65
|
+
<Text dimColor>(no messages yet)</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
),
|
|
68
|
+
[],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (!session) {
|
|
72
|
+
return (
|
|
73
|
+
<Box flexDirection="column" padding={1} height="100%">
|
|
74
|
+
<Text bold color="red">
|
|
75
|
+
Session {sessionId} no longer exists.
|
|
76
|
+
</Text>
|
|
77
|
+
<Box marginTop={1}>
|
|
78
|
+
<Text dimColor>Esc back to Agent View</Text>
|
|
79
|
+
</Box>
|
|
80
|
+
</Box>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Box flexDirection="column" padding={1} height="100%">
|
|
86
|
+
{/* Header */}
|
|
87
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
88
|
+
<Box>
|
|
89
|
+
<Text bold color="cyan">
|
|
90
|
+
Attached
|
|
91
|
+
</Text>
|
|
92
|
+
<Text dimColor> — {session.id}</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
<Box>
|
|
95
|
+
<Text>{session.title}</Text>
|
|
96
|
+
<Text dimColor>
|
|
97
|
+
{' '}
|
|
98
|
+
· {session.provider}/{session.model} · <Text color={statusColor}>{status}</Text>
|
|
99
|
+
</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
{session.branch && (
|
|
102
|
+
<Box>
|
|
103
|
+
<Text dimColor>
|
|
104
|
+
branch {session.branch} · worktree {session.worktree}
|
|
105
|
+
</Text>
|
|
106
|
+
</Box>
|
|
107
|
+
)}
|
|
108
|
+
<Box>
|
|
109
|
+
<Text dimColor>
|
|
110
|
+
{transcript.length} message(s) · read-only transcript · Esc back to Agent View
|
|
111
|
+
</Text>
|
|
112
|
+
</Box>
|
|
113
|
+
</Box>
|
|
114
|
+
|
|
115
|
+
{/* Divider */}
|
|
116
|
+
<Box marginBottom={1}>
|
|
117
|
+
<Text dimColor>{'─'.repeat(70)}</Text>
|
|
118
|
+
</Box>
|
|
119
|
+
|
|
120
|
+
{/* Transcript */}
|
|
121
|
+
{transcript.length === 0 ? (
|
|
122
|
+
emptyState()
|
|
123
|
+
) : (
|
|
124
|
+
<ChatPanel messages={transcript} focusMode={false} />
|
|
125
|
+
)}
|
|
126
|
+
</Box>
|
|
127
|
+
)
|
|
128
|
+
}
|