@cleocode/goal 0.0.0-stage → 0.9.2
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/CHANGELOG.md +328 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/cli/ocg.ts +149 -0
- package/dist/cli.js +8708 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +1920 -0
- package/package.json +79 -4
- package/rpc.ts +2 -0
- package/server.ts +3 -0
- package/skills/write-goal/SKILL.md +211 -0
- package/skills/write-goal/assets/goal.template.yaml +38 -0
- package/skills/write-goal/references/examples.md +283 -0
- package/skills/write-goal/references/goal-schema.md +53 -0
- package/skills/write-goal/references/interview.md +36 -0
- package/src/cli/install.ts +125 -0
- package/src/cli/status.ts +116 -0
- package/src/contract/parse.ts +454 -0
- package/src/contract/render.ts +134 -0
- package/src/contract/types.ts +158 -0
- package/src/engine/amendment.ts +51 -0
- package/src/engine/amendments.ts +72 -0
- package/src/engine/cleo-bridge.ts +339 -0
- package/src/engine/cleo-link.ts +150 -0
- package/src/engine/continue-work.ts +131 -0
- package/src/engine/decisions.ts +44 -0
- package/src/engine/dogfood-record.ts +278 -0
- package/src/engine/handoff.ts +102 -0
- package/src/engine/interrupt-policy.ts +45 -0
- package/src/engine/inventory.ts +70 -0
- package/src/engine/launches.ts +82 -0
- package/src/engine/loop-stop.ts +50 -0
- package/src/engine/loop.ts +109 -0
- package/src/engine/next-work.ts +82 -0
- package/src/engine/owner-intent.ts +71 -0
- package/src/engine/owner-reviews.ts +178 -0
- package/src/engine/peer-announce.ts +67 -0
- package/src/engine/peer-transport.ts +78 -0
- package/src/engine/policy.ts +42 -0
- package/src/engine/registry.ts +119 -0
- package/src/engine/similarity.ts +65 -0
- package/src/engine/stale-decisions.ts +68 -0
- package/src/engine/state.ts +185 -0
- package/src/engine/store.ts +263 -0
- package/src/engine/tick-record.ts +129 -0
- package/src/engine/tick.ts +207 -0
- package/src/rpc.ts +182 -0
- package/src/server/app.ts +4320 -0
- package/src/server/help.ts +50 -0
- package/src/server/index.ts +23 -0
- package/src/server/intent.ts +22 -0
- package/src/server/messages.ts +228 -0
- package/src/summary/audit.ts +76 -0
- package/src/summary/build.ts +314 -0
- package/src/tui/dashboard.ts +322 -0
- package/src/tui/decision-form.ts +12 -0
- package/src/tui/format.ts +127 -0
- package/src/tui/index.tsx +718 -0
- package/src/tui/manager.ts +94 -0
- package/src/tui/navigation.ts +11 -0
- package/src/tui/pointer-select.ts +20 -0
- package/src/tui/tracer.ts +51 -0
- package/src/util/git.ts +102 -0
- package/src/util/ids.ts +20 -0
- package/src/util/shell.ts +42 -0
- package/src/verify/artifact.ts +95 -0
- package/src/verify/pipeline.ts +200 -0
- package/tui.tsx +2 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
// TUI plugin: a live goal card in the sidebar, a status pill under the prompt,
|
|
3
|
+
// a banner when the owner is needed, notices as toasts and desktop attention,
|
|
4
|
+
// and palette commands. Fed only by the server's GoalRpc (S6), never by files.
|
|
5
|
+
import type { Plugin } from "@opencode/plugin/tui"
|
|
6
|
+
import { createEffect, createSignal, For, onCleanup, Show, untrack } from "solid-js"
|
|
7
|
+
import { createStore, reconcile } from "solid-js/store"
|
|
8
|
+
import { GoalRpc, type GoalSummary, type GoalView, type LaunchView } from "../rpc"
|
|
9
|
+
import { bannerText, pillText, statusText, type Tone } from "./format"
|
|
10
|
+
import { tracerLines } from "./tracer"
|
|
11
|
+
import { cardAffordanceTab, dashboardLines, decisionRows, dialogDigest, DASHBOARD_TABS, quickActRows, type DashboardTab } from "./dashboard"
|
|
12
|
+
import { fitColumns, goalKey, launchStatusText, managerActions, orderedGoals, sidebarCards, targetOf, wrapColumns, type ManagerAction } from "./manager"
|
|
13
|
+
import { openGoalSession } from "./navigation"
|
|
14
|
+
import { pointerSelect } from "./pointer-select"
|
|
15
|
+
|
|
16
|
+
type Context = Plugin.Context
|
|
17
|
+
|
|
18
|
+
const SIDEBAR_WIDTH = 40
|
|
19
|
+
/** The name this plugin opens and renders in the session.panel slot. */
|
|
20
|
+
const PANEL_NAME = "goal-dashboard"
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
id: "opencode-goal",
|
|
24
|
+
async setup(context: Context) {
|
|
25
|
+
const rpc = context.client.rpc(GoalRpc as any) as any
|
|
26
|
+
const [views, setViews] = createStore<Record<string, GoalView | null>>({})
|
|
27
|
+
const [launches, setLaunches] = createStore<Record<string, LaunchView[]>>({})
|
|
28
|
+
const currentLaunch = (sessionID: string) => [...(launches[sessionID] ?? [])].sort((a, b) => Number(b.status === "executing") - Number(a.status === "executing") || (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]
|
|
29
|
+
const [now, setNow] = createSignal(Date.now())
|
|
30
|
+
const timer = setInterval(() => setNow(Date.now()), 1000)
|
|
31
|
+
const requested = new Set<string>()
|
|
32
|
+
const [goalsList, setGoalsList] = createSignal<GoalSummary[]>([])
|
|
33
|
+
const [goalsState, setGoalsState] = createSignal<"loading" | "ready" | "error">("loading")
|
|
34
|
+
const [selectedGoalKey, setSelectedGoalKey] = createSignal<string>()
|
|
35
|
+
const [selectedDetails, setSelectedDetails] = createSignal<GoalSummary>()
|
|
36
|
+
const [managerNotice, setManagerNotice] = createSignal<{ message: string; tone: Tone }>()
|
|
37
|
+
let detailRequest = 0
|
|
38
|
+
let goalsFetchedAt = 0
|
|
39
|
+
let goalsRequested = false
|
|
40
|
+
let goalsInFlight = false
|
|
41
|
+
let goalsRefreshAgain = false
|
|
42
|
+
let goalsDirectory: string | undefined
|
|
43
|
+
let disposed = false
|
|
44
|
+
let focusManagerSection: (() => void) | undefined
|
|
45
|
+
// OpenCode 2.0.26 can render a fullscreen session.panel while
|
|
46
|
+
// ui.panel.current() is null. Track the real mounted slot, not a guessed
|
|
47
|
+
// global toggle flag, and use its own documented close capability.
|
|
48
|
+
const mountedPanels = new Map<string, { close: () => void }>()
|
|
49
|
+
const currentMountedPanel = () => {
|
|
50
|
+
const route = context.ui.router.current()
|
|
51
|
+
return route.type === "session" ? mountedPanels.get(route.sessionID) : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const locationOf = (sessionID?: string) => (sessionID ? (context.data.session.get(sessionID) as any)?.location : undefined) ?? context.location ?? context.data.location?.default()
|
|
55
|
+
const ensureGoals = async (force = false) => {
|
|
56
|
+
goalsRequested = true
|
|
57
|
+
const route = context.ui.router.current()
|
|
58
|
+
const location = locationOf(route.type === "session" ? route.sessionID : undefined)
|
|
59
|
+
const directory = location?.directory
|
|
60
|
+
if (goalsDirectory !== directory) {
|
|
61
|
+
goalsDirectory = directory
|
|
62
|
+
goalsFetchedAt = 0
|
|
63
|
+
setGoalsList([])
|
|
64
|
+
setGoalsState("loading")
|
|
65
|
+
setSelectedGoalKey(undefined)
|
|
66
|
+
setSelectedDetails(undefined)
|
|
67
|
+
}
|
|
68
|
+
if (goalsInFlight) { if (force) goalsRefreshAgain = true; return }
|
|
69
|
+
if (!force && Date.now() - goalsFetchedAt < 4000) return
|
|
70
|
+
goalsInFlight = true
|
|
71
|
+
goalsFetchedAt = Date.now()
|
|
72
|
+
try {
|
|
73
|
+
const listed = await rpc.list({}, location ? { location } : undefined)
|
|
74
|
+
if (disposed || goalsDirectory !== directory) return
|
|
75
|
+
if (!Array.isArray(listed?.goals)) throw new Error("Goal inventory missing")
|
|
76
|
+
setGoalsList(listed.goals as GoalSummary[])
|
|
77
|
+
setGoalsState("ready")
|
|
78
|
+
} catch {
|
|
79
|
+
if (!disposed && goalsDirectory === directory) setGoalsState("error")
|
|
80
|
+
} finally {
|
|
81
|
+
goalsInFlight = false
|
|
82
|
+
if (!disposed && goalsRefreshAgain) { goalsRefreshAgain = false; void ensureGoals(true) }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// T058: events are the fast path, not the only path. A subscription that
|
|
86
|
+
// attaches before the plugin registers, a dropped stream, or a reconnect
|
|
87
|
+
// must never freeze the dashboard at its first snapshot (the live defect:
|
|
88
|
+
// stuck on S1, 0/N criteria for a whole run). Renders and a periodic
|
|
89
|
+
// ticker re-fetch snapshots that went stale, so the view converges.
|
|
90
|
+
const STALE_MS = 4000
|
|
91
|
+
const fetchedAt = new Map<string, number>()
|
|
92
|
+
const fetchView = async (sessionID: string) => {
|
|
93
|
+
fetchedAt.set(sessionID, Date.now())
|
|
94
|
+
try {
|
|
95
|
+
const location = locationOf(sessionID)
|
|
96
|
+
const result = await rpc.snapshot({ sessionID }, location ? { location } : undefined)
|
|
97
|
+
setViews(sessionID, reconcile((result?.view ?? null) as GoalView | null))
|
|
98
|
+
if (Array.isArray(result?.launches)) setLaunches(sessionID, reconcile(result.launches as LaunchView[]))
|
|
99
|
+
} catch {
|
|
100
|
+
// the server plugin may not be loaded for this location; show nothing
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const ensure = (sessionID: string) => {
|
|
104
|
+
if (requested.has(sessionID)) return
|
|
105
|
+
requested.add(sessionID)
|
|
106
|
+
void fetchView(sessionID)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const offUpdated = rpc.events.on("updated", (event: any) => {
|
|
110
|
+
const data = event.data ?? {}, { sessionID, view, launch } = data
|
|
111
|
+
if (typeof sessionID === "string") {
|
|
112
|
+
// Launch-only notifications must not erase another live run's view.
|
|
113
|
+
if ("view" in data) setViews(sessionID, reconcile((view ?? null) as GoalView | null))
|
|
114
|
+
if (launch && typeof launch.decisionId === "string") setLaunches(sessionID, reconcile([...(launches[sessionID] ?? []).filter((p) => p.decisionId !== launch.decisionId), launch]))
|
|
115
|
+
}
|
|
116
|
+
if (goalsRequested) void ensureGoals(true)
|
|
117
|
+
})
|
|
118
|
+
const offNotice = rpc.events.on("notice", (event: any) => {
|
|
119
|
+
const data = event.data ?? {}
|
|
120
|
+
const current = context.ui.router.current()
|
|
121
|
+
const here = current.type === "session" && current.sessionID === data.sessionID
|
|
122
|
+
if (data.panel && here) {
|
|
123
|
+
const view = views[data.sessionID]
|
|
124
|
+
void context.ui.dialog.alert({ title: "Goal", message: view ? statusText(view, Date.now()) : String(data.text) })
|
|
125
|
+
} else {
|
|
126
|
+
context.ui.toast.show({ title: "Goal", message: String(data.text ?? ""), variant: (data.level ?? "info") as any, duration: data.level === "error" ? 8000 : 5000, ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
|
|
127
|
+
}
|
|
128
|
+
if (data.attention)
|
|
129
|
+
void context.attention.notify({ title: "Goal", message: String(data.text ?? ""), notification: { when: "blurred" }, sound: { name: data.attention, when: "blurred" } })
|
|
130
|
+
})
|
|
131
|
+
// T045: the post-goal summary lands as a dialog for the owner (headline
|
|
132
|
+
// as the toast, full text — provenance caveats, scope audit, follow-ups —
|
|
133
|
+
// in the dialog), only for the session the goal belongs to.
|
|
134
|
+
// T065: dialogs are not scrollable — the full text once overflowed off
|
|
135
|
+
// the screen; dialogs carry a screen-fit digest, the panel keeps the rest.
|
|
136
|
+
// A summary must not displace an unanswered native Composer decision.
|
|
137
|
+
const openDecisions = new Map<string, number>()
|
|
138
|
+
const pendingSummaries = new Map<string, { headline: string; text: string; status: string; title?: string; digest?: string }>()
|
|
139
|
+
const summaryTitle = (data: any) => {
|
|
140
|
+
const label = String(data.status ?? "summary").replace(/_/g, " ")
|
|
141
|
+
return data.status === "complete" ? `✓ Goal complete — ${data.title ?? label}` : `Goal ${label}`
|
|
142
|
+
}
|
|
143
|
+
const summaryBody = (data: any) => String(data.digest ?? dialogDigest(String(data.headline ?? "summary"), String(data.text ?? "")))
|
|
144
|
+
const flushSummary = (sessionID: string) => {
|
|
145
|
+
const queued = pendingSummaries.get(sessionID)
|
|
146
|
+
pendingSummaries.delete(sessionID)
|
|
147
|
+
if (!queued) return
|
|
148
|
+
void context.ui.dialog.alert({
|
|
149
|
+
title: summaryTitle(queued),
|
|
150
|
+
message: summaryBody(queued),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
const offSummary = rpc.events.on("summary", (event: any) => {
|
|
154
|
+
const data = event.data ?? {}
|
|
155
|
+
const current = context.ui.router.current()
|
|
156
|
+
if (current.type !== "session" || current.sessionID !== data.sessionID) {
|
|
157
|
+
context.ui.toast.show({ title: "Goal summary", message: String(data.headline ?? data.text ?? ""), variant: "info", ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
// Queue the digest behind an actual pending native review.
|
|
161
|
+
if (openDecisions.has(data.sessionID)) {
|
|
162
|
+
pendingSummaries.set(data.sessionID, { headline: String(data.headline ?? "summary"), text: String(data.text ?? data.headline ?? ""), status: String(data.status ?? ""), title: data.title ? String(data.title) : undefined, digest: data.digest ? String(data.digest) : undefined })
|
|
163
|
+
context.ui.toast.show({ title: "Goal summary queued", message: String(data.headline ?? "the full summary opens after the decision"), variant: "info", ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
void context.ui.dialog.alert({
|
|
167
|
+
title: summaryTitle(data),
|
|
168
|
+
message: summaryBody(data),
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
// Composer's core form is the sole decision surface. A notification
|
|
172
|
+
// never synthesizes an answer or remembers a second popup choice.
|
|
173
|
+
const offData = context.data.listen(({ details }: any) => {
|
|
174
|
+
if (details?.type === "server.connected") {
|
|
175
|
+
for (const id of requested) void fetchView(id)
|
|
176
|
+
if (goalsRequested) void ensureGoals(true)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
const offHandoff = rpc.events.on("handoff", (event: any) => {
|
|
180
|
+
const data = event.data ?? {}
|
|
181
|
+
const route = context.ui.router.current()
|
|
182
|
+
if (typeof data.toSessionID !== "string" || typeof data.fromSessionID !== "string") return
|
|
183
|
+
if (route.type === "session" && route.sessionID === data.fromSessionID) openGoalSession(context.ui, data.toSessionID)
|
|
184
|
+
else context.ui.toast.show({ title: "Goal running elsewhere", message: `${data.slug} is running in ${data.toSessionID}; original history is preserved.`, variant: "info" })
|
|
185
|
+
void ensureGoals(true)
|
|
186
|
+
})
|
|
187
|
+
const refreshIfStale = (sessionID: string) => {
|
|
188
|
+
if (Date.now() - (fetchedAt.get(sessionID) ?? 0) <= STALE_MS) return
|
|
189
|
+
fetchedAt.set(sessionID, Date.now())
|
|
190
|
+
void fetchView(sessionID)
|
|
191
|
+
}
|
|
192
|
+
const ticker = setInterval(() => {
|
|
193
|
+
// A terminal (or empty) snapshot is not terminal for the SESSION:
|
|
194
|
+
// another goal can start here while its updated event is missed.
|
|
195
|
+
// Refresh every requested session, including old completed goals.
|
|
196
|
+
for (const id of requested) refreshIfStale(id)
|
|
197
|
+
if (goalsRequested) void ensureGoals()
|
|
198
|
+
}, STALE_MS)
|
|
199
|
+
|
|
200
|
+
// T056: the panel opens ON DEMAND (the v0.2 auto-open displaced the
|
|
201
|
+
// sidebar); the toggle command is keybindable beside OpenCode's
|
|
202
|
+
// session.sidebar toggle. T073: opening from the card's expand affordance
|
|
203
|
+
// lands on the tab whose content the sidebar truncated — the affordance
|
|
204
|
+
// is a click-through, not a dead hint.
|
|
205
|
+
let panelFocusPending = false
|
|
206
|
+
const openPanel = () => {
|
|
207
|
+
panelFocusPending = true // T074 sign-off fix: the panel TAKES focus on open so tab/↑↓/enter reach it (with the composer focused, tab cycled nothing)
|
|
208
|
+
const id = currentSession()
|
|
209
|
+
const v = id ? views[id] : undefined
|
|
210
|
+
const target = v ? cardAffordanceTab(v, now(), SIDEBAR_WIDTH) : undefined
|
|
211
|
+
if (target) setTab(target)
|
|
212
|
+
context.ui.panel.open(PANEL_NAME)
|
|
213
|
+
}
|
|
214
|
+
const togglePanel = () => {
|
|
215
|
+
// The host changes active panel ownership when tabs change. A local
|
|
216
|
+
// boolean becomes stale and turns the first F9 into an invisible close.
|
|
217
|
+
const mounted = currentMountedPanel()
|
|
218
|
+
if (mounted || context.ui.panel.current()?.name === PANEL_NAME) {
|
|
219
|
+
if (mounted) mounted.close(); else context.ui.panel.close()
|
|
220
|
+
} else openPanel()
|
|
221
|
+
}
|
|
222
|
+
const [tab, setTab] = createSignal<DashboardTab>("now")
|
|
223
|
+
const expandGoal = (goal: GoalSummary) => {
|
|
224
|
+
setManagerNotice(undefined)
|
|
225
|
+
openPanel()
|
|
226
|
+
setSelectedGoalKey(goalKey(goal))
|
|
227
|
+
setSelectedDetails(goal)
|
|
228
|
+
setTab("goals") // expand here; never jump or attach on mere selection
|
|
229
|
+
void ensureGoals(true)
|
|
230
|
+
void fetchDetails(goal)
|
|
231
|
+
}
|
|
232
|
+
const fetchDetails = async (goal: GoalSummary) => {
|
|
233
|
+
const ticket = ++detailRequest
|
|
234
|
+
const id = currentSession(), location = id ? locationOf(id) : undefined
|
|
235
|
+
try {
|
|
236
|
+
const result = await rpc.details({ key: goalKey(goal) }, location ? { location } : undefined)
|
|
237
|
+
if (!disposed && ticket === detailRequest && selectedGoalKey() === goalKey(goal) && result?.goal) setSelectedDetails(result.goal)
|
|
238
|
+
} catch { /* Summary stays visible, with unknown detail fields unclaimed. */ }
|
|
239
|
+
}
|
|
240
|
+
const selectedGoal = () => {
|
|
241
|
+
const row = goalsList().find((g) => goalKey(g) === selectedGoalKey())
|
|
242
|
+
const detail = selectedDetails()
|
|
243
|
+
return row && detail && goalKey(detail) === goalKey(row) && detail.lock === row.lock && detail.sessionID === row.sessionID ? { ...detail, ...row, details: detail.details } : row
|
|
244
|
+
}
|
|
245
|
+
const manageSelected = async (row: ManagerAction) => {
|
|
246
|
+
const goal = selectedGoal(), sessionID = currentSession()
|
|
247
|
+
if (row.action === "back") { setSelectedGoalKey(undefined); setSelectedDetails(undefined); setManagerNotice(undefined); return }
|
|
248
|
+
if (!goal || !sessionID) { setManagerNotice({ message: "The selected goal or active session is unavailable. Reopen the manager with F9.", tone: "warning" }); return }
|
|
249
|
+
if (row.action === "jump") {
|
|
250
|
+
if (!goal.sessionID || goal.sessionState !== "present") { setManagerNotice({ message: "This goal's session is not available for navigation.", tone: "warning" }); return }
|
|
251
|
+
try {
|
|
252
|
+
// Focusing the already-current tab leaves its fullscreen manager
|
|
253
|
+
// covering the conversation. Explicit activation must return to it.
|
|
254
|
+
if (goal.sessionID === sessionID) { const panel = currentMountedPanel(); if (panel) panel.close(); else context.ui.panel.close() }
|
|
255
|
+
else openGoalSession(context.ui, goal.sessionID)
|
|
256
|
+
} catch (error) { setManagerNotice({ message: `Navigation unavailable: ${String(error)}`, tone: "warning" }) }
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const location = locationOf(sessionID)
|
|
260
|
+
try {
|
|
261
|
+
const result = await rpc.manage({ sessionID, action: row.action, target: targetOf(goal) }, location ? { location } : undefined)
|
|
262
|
+
// Native toast overlays can take the keyboard focus away from the
|
|
263
|
+
// selected action after a resize. Keep manager feedback in place;
|
|
264
|
+
// actual owner decisions still reveal their Composer form.
|
|
265
|
+
setManagerNotice({ message: String(result.message), tone: result.pending ? "muted" : result.ok ? "success" : "warning" })
|
|
266
|
+
void ensureGoals(true)
|
|
267
|
+
if (!result.pending) void fetchDetails(goal)
|
|
268
|
+
} catch (error) { setManagerNotice({ message: `Operation unavailable: ${String(error)}`, tone: "warning" }) }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const color = (tone: Tone) => {
|
|
272
|
+
const t = context.theme as any
|
|
273
|
+
if (tone === "base") return t.text.base
|
|
274
|
+
if (tone === "muted") return t.text.muted
|
|
275
|
+
return t.text.feedback?.[tone]?.base ?? t.text.base
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const disposers: Array<() => void> = []
|
|
279
|
+
|
|
280
|
+
disposers.push(
|
|
281
|
+
context.ui.slot({
|
|
282
|
+
append: "sidebar.content",
|
|
283
|
+
render: (input: { sessionID: string }) => {
|
|
284
|
+
ensure(input.sessionID)
|
|
285
|
+
refreshIfStale(input.sessionID) // T058: focus renders refresh a stale card
|
|
286
|
+
void ensureGoals()
|
|
287
|
+
const width = typeof (input as any).width === "number" ? (input as any).width : SIDEBAR_WIDTH
|
|
288
|
+
const model = () => sidebarCards(goalsList(), input.sessionID, width)
|
|
289
|
+
return (
|
|
290
|
+
<box id="goal-sidebar" flexDirection="column" maxHeight={12}>
|
|
291
|
+
<Show when={model().total}>
|
|
292
|
+
<text height={1} flexShrink={0} fg={color("base")} wrapMode="none" truncate>Goals ({model().total})</text>
|
|
293
|
+
<For each={model().cards.map((c) => c.key)}>
|
|
294
|
+
{(key) => {
|
|
295
|
+
const card = () => model().cards.find((c) => c.key === key)
|
|
296
|
+
const expand = () => { const current = card(); if (current) expandGoal(current.goal) }
|
|
297
|
+
return (
|
|
298
|
+
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
|
299
|
+
<select id={`goal-card:${key}`} height={2} flexShrink={0}
|
|
300
|
+
options={[{ name: card()?.title ?? "", description: card()?.description ?? "", value: key }]}
|
|
301
|
+
showDescription showSelectionIndicator={false}
|
|
302
|
+
textColor={color("base")} descriptionColor={color(card()?.tone ?? "muted")}
|
|
303
|
+
onSelect={expand} onMouseUp={expand} />
|
|
304
|
+
<text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{card()?.progress ?? ""}</text>
|
|
305
|
+
<Show when={card()?.handoffPointer}>{(pointer) => <text id={`goal-handoff-pointer:${key}`} height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{pointer()}</text>}</Show>
|
|
306
|
+
</box>
|
|
307
|
+
)
|
|
308
|
+
}}
|
|
309
|
+
</For>
|
|
310
|
+
</Show>
|
|
311
|
+
<select id="goal-sidebar-manager" height={1} flexShrink={0} showDescription={false}
|
|
312
|
+
options={[{ name: goalsState() === "error" ? "Retry goals — F9 manager" : model().pointer, description: "Open the project Goals manager", value: "manager" }]}
|
|
313
|
+
onSelect={() => { openPanel(); setTab("goals"); void ensureGoals(true) }}
|
|
314
|
+
onMouseUp={() => { openPanel(); setTab("goals"); void ensureGoals(true) }} />
|
|
315
|
+
</box>
|
|
316
|
+
)
|
|
317
|
+
},
|
|
318
|
+
} as any),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
disposers.push(
|
|
322
|
+
context.ui.slot({
|
|
323
|
+
append: "prompt.footer.status",
|
|
324
|
+
render: (input: { sessionID?: string }) => {
|
|
325
|
+
if (input.sessionID) ensure(input.sessionID)
|
|
326
|
+
return (
|
|
327
|
+
<Show when={input.sessionID ? views[input.sessionID] : undefined}>
|
|
328
|
+
{(view) => <text fg={color(view().status === "running" ? "success" : "muted")}>{pillText(view(), now())}</text>}
|
|
329
|
+
</Show>
|
|
330
|
+
)
|
|
331
|
+
},
|
|
332
|
+
} as any),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
disposers.push(
|
|
336
|
+
context.ui.slot({
|
|
337
|
+
append: "session.composer.top",
|
|
338
|
+
render: (input: { sessionID: string }) => {
|
|
339
|
+
ensure(input.sessionID)
|
|
340
|
+
// Slot render runs once on mount. Derive inside JSX accessors so
|
|
341
|
+
// the Solid store remains tracked across progress/status changes
|
|
342
|
+
// and, critically, replacement by a NEW run in this session.
|
|
343
|
+
const tracer = () => {
|
|
344
|
+
const view = views[input.sessionID]
|
|
345
|
+
return view ? tracerLines(view, now(), 80) : []
|
|
346
|
+
}
|
|
347
|
+
const banner = () => {
|
|
348
|
+
const view = views[input.sessionID]
|
|
349
|
+
return view ? bannerText(view) : undefined
|
|
350
|
+
}
|
|
351
|
+
return (
|
|
352
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
353
|
+
<Show when={launchStatusText(currentLaunch(input.sessionID), views[input.sessionID])}>
|
|
354
|
+
{(line) => <text id="goal-launch-admission" fg={color(currentLaunch(input.sessionID)?.status === "executing" ? "muted" : "warning")} wrapMode="none" truncate>{line()}</text>}
|
|
355
|
+
</Show>
|
|
356
|
+
<For each={tracer()}>
|
|
357
|
+
{(line) => (
|
|
358
|
+
<text fg={color(line.tone)} wrapMode="none" truncate>
|
|
359
|
+
{line.text}
|
|
360
|
+
</text>
|
|
361
|
+
)}
|
|
362
|
+
</For>
|
|
363
|
+
<Show when={banner()}>
|
|
364
|
+
{(b) => (
|
|
365
|
+
<text fg={color(b().tone)} wrapMode="none" truncate>
|
|
366
|
+
{b().text}
|
|
367
|
+
</text>
|
|
368
|
+
)}
|
|
369
|
+
</Show>
|
|
370
|
+
</box>
|
|
371
|
+
)
|
|
372
|
+
},
|
|
373
|
+
} as any),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
disposers.push(
|
|
377
|
+
context.ui.slot({
|
|
378
|
+
append: "session.panel",
|
|
379
|
+
render: (input: any) => {
|
|
380
|
+
if (input.name !== PANEL_NAME) return null
|
|
381
|
+
const mount = { close: () => { if (typeof input.close === "function") input.close(); else context.ui.panel.close() } }
|
|
382
|
+
mountedPanels.set(input.sessionID, mount)
|
|
383
|
+
onCleanup(() => { if (mountedPanels.get(input.sessionID) === mount) mountedPanels.delete(input.sessionID) })
|
|
384
|
+
ensure(input.sessionID)
|
|
385
|
+
void ensureGoals()
|
|
386
|
+
const view = () => views[input.sessionID]
|
|
387
|
+
if (!view() && !selectedGoalKey()) setTab("goals")
|
|
388
|
+
// PanelInput.width/focused are reactive host props; capturing a
|
|
389
|
+
// number once leaves every wrapped line stale after a resize.
|
|
390
|
+
const width = () => typeof input.width === "number" ? input.width : 46
|
|
391
|
+
// T074 sign-off fix: focus the panel once on open (the composer
|
|
392
|
+
// otherwise keeps swallowing tab/↑↓)
|
|
393
|
+
if (input.name === PANEL_NAME && panelFocusPending && typeof input.focus === "function") {
|
|
394
|
+
panelFocusPending = false
|
|
395
|
+
try {
|
|
396
|
+
input.focus()
|
|
397
|
+
} catch {
|
|
398
|
+
// focus is best-effort; the palette/palette-command still works
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// T074 sign-off fix: the Decisions tab is ALWAYS interactive —
|
|
402
|
+
// open decision rows first, then the always-applicable quick acts
|
|
403
|
+
const rows = () => view() && tab() === "decisions" ? [...decisionRows(view()!), ...quickActRows(view()!)] : []
|
|
404
|
+
const managerRows = () => orderedGoals(goalsList(), input.sessionID)
|
|
405
|
+
const actions = () => { const g = selectedGoal(); return g ? managerActions(g, input.sessionID, !!view() && !["complete", "failed", "aborted", "superseded"].includes(view()!.status)) : [] }
|
|
406
|
+
// The host's panel transition restores input ownership AFTER the
|
|
407
|
+
// slot renders. `focused` on a newly mounted Select alone loses
|
|
408
|
+
// that race to the composer. Focus the scope and concrete control
|
|
409
|
+
// once after mount; never reclaim focus on every progress tick.
|
|
410
|
+
const focusControl = (control: any) => {
|
|
411
|
+
const scheduled = setTimeout(() => {
|
|
412
|
+
try { input.focus?.(); control.focus() } catch { /* unavailable panel scope */ }
|
|
413
|
+
}, 0)
|
|
414
|
+
onCleanup(() => clearTimeout(scheduled))
|
|
415
|
+
}
|
|
416
|
+
let managerControl: any, actionsControl: any, detailsControl: any, closeControl: any, lastFocusedControl: any
|
|
417
|
+
let resizeFocusTimer: ReturnType<typeof setTimeout> | undefined
|
|
418
|
+
onCleanup(() => clearTimeout(resizeFocusTimer))
|
|
419
|
+
const rememberFocus = (control: any) => { control.on?.("focused", () => { lastFocusedControl = control }) }
|
|
420
|
+
const restoreAfterResize = () => {
|
|
421
|
+
if (tab() !== "goals" || mountedPanels.get(input.sessionID) !== mount || !lastFocusedControl) return
|
|
422
|
+
const route = context.ui.router.current()
|
|
423
|
+
if (route.type !== "session" || route.sessionID !== input.sessionID) return
|
|
424
|
+
clearTimeout(resizeFocusTimer)
|
|
425
|
+
resizeFocusTimer = setTimeout(() => {
|
|
426
|
+
if (mountedPanels.get(input.sessionID) !== mount || (context.data.session.form?.list(input.sessionID, locationOf(input.sessionID))?.length ?? 0) > 0) return
|
|
427
|
+
input.focus?.()
|
|
428
|
+
lastFocusedControl?.focus()
|
|
429
|
+
}, 0)
|
|
430
|
+
}
|
|
431
|
+
context.renderer?.on?.("resize", restoreAfterResize)
|
|
432
|
+
onCleanup(() => context.renderer?.off?.("resize", restoreAfterResize))
|
|
433
|
+
createEffect(() => {
|
|
434
|
+
if (input.focused === true && tab() === "goals") untrack(() => focusControl(selectedGoal() ? actionsControl : managerControl))
|
|
435
|
+
})
|
|
436
|
+
const cycleSection = () => {
|
|
437
|
+
const controls = [managerControl, detailsControl, actionsControl, closeControl].filter((c) => c && c.visible !== false)
|
|
438
|
+
if (!controls.length) return
|
|
439
|
+
const at = controls.findIndex((c) => c.focused)
|
|
440
|
+
input.focus?.()
|
|
441
|
+
controls[(at + 1) % controls.length].focus()
|
|
442
|
+
}
|
|
443
|
+
focusManagerSection = cycleSection
|
|
444
|
+
onCleanup(() => { if (focusManagerSection === cycleSection) focusManagerSection = undefined })
|
|
445
|
+
return (
|
|
446
|
+
<Show when={tab() !== "goals"} fallback={
|
|
447
|
+
<box id="goal-manager" flexDirection="column" paddingLeft={1} paddingRight={1} height="100%" minHeight={0} overflow="hidden">
|
|
448
|
+
<text height={1} flexShrink={0} fg={color("base")} wrapMode="none" truncate><b>Goals manager · this project</b></text>
|
|
449
|
+
<select id="goal-manager-close" ref={(control: any) => { closeControl = control; rememberFocus(control) }} height={1} flexShrink={0}
|
|
450
|
+
options={[{ name: "Close manager · F9: return to sidebar", description: "Return to this session's basic sidebar without changing the goal", value: "close" }]}
|
|
451
|
+
showDescription={false} onSelect={() => { if (typeof input.close === "function") input.close(); else context.ui.panel.close() }}
|
|
452
|
+
onMouseUp={() => { if (typeof input.close === "function") input.close(); else context.ui.panel.close() }} />
|
|
453
|
+
<text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{fitColumns("Tab: focus · ↑↓: select · Enter: activate", width() - 2)}</text>
|
|
454
|
+
<Show when={goalsState() === "loading"}><text fg={color("muted")}>Loading project goals…</text></Show>
|
|
455
|
+
<Show when={goalsState() === "error"}><text fg={color("error")}>Inventory unavailable; F9 retries.</text></Show>
|
|
456
|
+
<Show when={managerRows().length} fallback={<Show when={goalsState() === "ready"}><text fg={color("muted")}>No goals — /goal starts the interview.</text></Show>}>
|
|
457
|
+
<select id="goal-manager-list" ref={(control: any) => { managerControl = control; rememberFocus(control); if (!selectedGoal()) focusControl(control) }} height={selectedGoal() ? 2 : Math.min(8, Math.max(2, managerRows().length * 2))} flexShrink={0}
|
|
458
|
+
options={managerRows().map((g) => ({ name: fitColumns(`${g.title}${g.archived ? " [archived]" : ""}`, width() - 4), description: fitColumns(`${g.status}${g.launch?.status === "executing" ? " · starting" : g.launch?.status === "expired" ? " · review expired" : ""} · ${g.proven}/${g.total} proven`, width() - 4), value: goalKey(g) }))}
|
|
459
|
+
showDescription focused={!selectedGoal()}
|
|
460
|
+
onMouseDown={(event: any) => { event.stopPropagation(); event.preventDefault() }}
|
|
461
|
+
onMouseUp={(event: any) => { input.focus?.(); pointerSelect(managerControl, event, 2) }}
|
|
462
|
+
onSelect={(_index: number, option: any) => { const goal = managerRows().find((g) => goalKey(g) === option?.value); if (goal) { setSelectedGoalKey(goalKey(goal)); setSelectedDetails(goal); void fetchDetails(goal) } }} />
|
|
463
|
+
</Show>
|
|
464
|
+
<Show when={selectedGoal()}>
|
|
465
|
+
{(goal) => (
|
|
466
|
+
<box id="goal-manager-details" flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0} marginTop={1}>
|
|
467
|
+
<text height={1} flexShrink={0} fg={color("base")} wrapMode="none" truncate>{fitColumns(`${goal().status} · ${goal().proven}/${goal().total} host-proven · ${goal().agent ?? "agent unknown"}`, width() - 2)}</text>
|
|
468
|
+
<text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{fitColumns(`Session: ${goal().sessionTitle ?? goal().sessionID ?? "unassigned"}`, width() - 2)}</text>
|
|
469
|
+
<Show when={managerNotice()}>{(notice) => <text id="goal-manager-feedback" height={1} flexShrink={0} fg={color(notice().tone)} wrapMode="none" truncate>{fitColumns(notice().message, width() - 2)}</text>}</Show>
|
|
470
|
+
<Show when={goal().steps?.length}><text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{fitColumns(`Plan: ${goal().steps?.filter((s) => s.status === "done").length}/${goal().steps?.length} done · ${goal().steps?.find((s) => s.status === "active")?.title ?? "no active step"}`, width() - 2)}</text></Show>
|
|
471
|
+
<text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>Details · Tab to focus · wheel/PgUp/PgDn scroll</text>
|
|
472
|
+
<scrollbox id="goal-manager-detail-text" ref={(control: any) => { detailsControl = control; rememberFocus(control) }} flexGrow={1} flexShrink={1} minHeight={1}
|
|
473
|
+
scrollX={false} scrollY={true} viewportOptions={{ overflow: "hidden" }} contentOptions={{ flexDirection: "column" }} onMouseDown={() => { input.focus?.(); detailsControl?.focus() }}>
|
|
474
|
+
<text fg={color("base")}><b>Outcome</b></text>
|
|
475
|
+
<text fg={color("muted")} wrapMode="word">{`Goal: ${goal().slug}`}</text>
|
|
476
|
+
<text fg={color("base")} wrapMode="word">{goal().outcome ?? goal().title}</text>
|
|
477
|
+
<text fg={color("muted")} wrapMode="word">{`Session ID: ${goal().sessionID ?? "unassigned"}`}</text>
|
|
478
|
+
<Show when={launchStatusText(goal().launch, goal())}>{(line) => <text fg={color("warning")} wrapMode="word">{line()}</text>}</Show>
|
|
479
|
+
<Show when={goal().details?.criteria?.length}><text fg={color("base")}><b>Evidence</b></text></Show>
|
|
480
|
+
<For each={goal().details?.criteria ?? []}>{(criterion) => (
|
|
481
|
+
<text fg={color(criterion.status === "fail" ? "error" : "muted")} wrapMode="word">{`${criterion.id} [${criterion.status}] ${criterion.statement}${criterion.by ? ` (${criterion.by})` : ""}`}</text>
|
|
482
|
+
)}</For>
|
|
483
|
+
<For each={goal().issues ?? []}>{(issue) => <text fg={color("error")} wrapMode="word">{issue}</text>}</For>
|
|
484
|
+
</scrollbox>
|
|
485
|
+
<text height={1} flexShrink={0} fg={color("base")}><b>Actions · ↑↓ and Enter, or click</b></text>
|
|
486
|
+
<select id="goal-manager-actions" ref={(control: any) => { actionsControl = control; rememberFocus(control); focusControl(control) }} height={Math.min(8, actions().length)} flexShrink={0}
|
|
487
|
+
options={actions().map((r) => ({ name: r.label, description: r.description, value: r }))}
|
|
488
|
+
showDescription={false} focused
|
|
489
|
+
onMouseDown={(event: any) => { event.stopPropagation(); event.preventDefault() }}
|
|
490
|
+
onMouseUp={(event: any) => { input.focus?.(); pointerSelect(actionsControl, event, 1) }}
|
|
491
|
+
onSelect={(_index: number, option: any) => { if (option?.value) void manageSelected(option.value) }} />
|
|
492
|
+
</box>
|
|
493
|
+
)}
|
|
494
|
+
</Show>
|
|
495
|
+
</box>
|
|
496
|
+
}>
|
|
497
|
+
<Show when={view()}>
|
|
498
|
+
{(v) => (
|
|
499
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
500
|
+
<For each={dashboardLines(v(), tab(), now(), width(), goalsList() as any)}>
|
|
501
|
+
{(line) => (
|
|
502
|
+
<text fg={color(line.tone)} wrapMode="none" truncate>
|
|
503
|
+
{line.bold ? <b>{line.text}</b> : line.text}
|
|
504
|
+
</text>
|
|
505
|
+
)}
|
|
506
|
+
</For>
|
|
507
|
+
<Show when={rows().length}>
|
|
508
|
+
<select
|
|
509
|
+
options={rows().map((r) => ({ name: r.label, description: r.description, value: r }))}
|
|
510
|
+
showDescription
|
|
511
|
+
focused
|
|
512
|
+
onSelect={(_index: number, option: any) => {
|
|
513
|
+
const row = option?.value as { act?: string; arg?: string; label: string; description?: string } | undefined
|
|
514
|
+
if (row?.act) void act(row.act, row.arg)
|
|
515
|
+
else context.ui.toast.show({ title: "Goal", message: `Run: ${row?.description ?? row?.label ?? ""}`, variant: "info" })
|
|
516
|
+
}}
|
|
517
|
+
/>
|
|
518
|
+
</Show>
|
|
519
|
+
</box>
|
|
520
|
+
)}
|
|
521
|
+
</Show>
|
|
522
|
+
</Show>
|
|
523
|
+
)
|
|
524
|
+
},
|
|
525
|
+
} as any),
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
// Palette commands live in a keymap layer owned by the app slot's render
|
|
529
|
+
// component (createComponent gives it a stable owner; T050). T064: the
|
|
530
|
+
// layer MUST be mode:"global" — a layer without a mode defaults to
|
|
531
|
+
// "base", and base-mode layers are unreachable whenever a modal is open
|
|
532
|
+
// (the command palette itself registers mode:"modal",
|
|
533
|
+
// packages/tui/src/ui/dialog-select.tsx), so the palette listed zero
|
|
534
|
+
// Goal commands live. Proven by pty A/B: mode:"global" lists all 8,
|
|
535
|
+
// mode-less lists none. Matches the host's own palette layer (app.tsx
|
|
536
|
+
// Keymap.createLayer(() => ({ mode: "global", ... }))).
|
|
537
|
+
const currentSession = () => {
|
|
538
|
+
const route = context.ui.router.current()
|
|
539
|
+
return route.type === "session" ? route.sessionID : undefined
|
|
540
|
+
}
|
|
541
|
+
const act = async (action: string, arg?: string, decision?: { sessionID: string; decisionId: string }) => {
|
|
542
|
+
const sessionID = decision?.sessionID ?? currentSession()
|
|
543
|
+
if (!sessionID) return
|
|
544
|
+
const location = locationOf(sessionID)
|
|
545
|
+
try {
|
|
546
|
+
const result = await rpc.act({ sessionID, action, ...(arg ? { arg } : {}), ...(decision ? { decisionId: decision.decisionId } : {}) }, location ? { location } : undefined)
|
|
547
|
+
context.ui.toast.show({ title: "Goal", message: result.message, variant: result.ok ? "success" : "warning" })
|
|
548
|
+
} catch (error) {
|
|
549
|
+
context.ui.toast.show({ title: "Goal", message: `Goal plugin unavailable: ${String(error)}`, variant: "error" })
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const hasGoal = () => {
|
|
553
|
+
const id = currentSession()
|
|
554
|
+
return !!(id && views[id])
|
|
555
|
+
}
|
|
556
|
+
// Owner-approved T086: lifecycle choices belong only in native Composer ask forms.
|
|
557
|
+
// Preserve attention/result notifications, never open a duplicate dialog.
|
|
558
|
+
const openDecisionIdentity = new Map<string, string>()
|
|
559
|
+
const offDecision = rpc.events.on("decision", (event: any) => {
|
|
560
|
+
const data = event.data ?? {}
|
|
561
|
+
const choices = (data.choices ?? []) as Array<{ label: string; act?: string; arg?: string; run?: string }>
|
|
562
|
+
const route = context.ui.router.current()
|
|
563
|
+
// A full-screen Goals panel must not hide or retain Enter focus over
|
|
564
|
+
// the sole native Composer question. Close only OUR panel here; never
|
|
565
|
+
// dismiss an unrelated host dialog or jump to another session.
|
|
566
|
+
if (choices.some((c) => c.act) && route.type === "session" && route.sessionID === data.sessionID) {
|
|
567
|
+
const mounted = mountedPanels.get(data.sessionID)
|
|
568
|
+
if (mounted) mounted.close()
|
|
569
|
+
else if (context.ui.panel?.current?.()?.name === PANEL_NAME) context.ui.panel.close()
|
|
570
|
+
}
|
|
571
|
+
if (typeof data.sessionID === "string" && typeof data.decisionId === "string" && choices.some((c) => c.act)) {
|
|
572
|
+
openDecisions.set(data.sessionID, 1)
|
|
573
|
+
openDecisionIdentity.set(data.sessionID, data.decisionId)
|
|
574
|
+
}
|
|
575
|
+
context.ui.toast.show({ title: "Goal decision", message: choices.some((c) => c.act)
|
|
576
|
+
? "Owner decision pending in this session's Composer. No operation executed."
|
|
577
|
+
: String(data.message ?? "Goal guidance is available in the manager."), variant: "info", ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
// Only engine resolution closes pending review state. Never dismiss an
|
|
581
|
+
// unrelated host dialog or synthesize native answers from notifications.
|
|
582
|
+
const offResolved = rpc.events.on("decision.resolved", (event: any) => {
|
|
583
|
+
const data = event.data ?? {}
|
|
584
|
+
if (data.message) context.ui.toast.show({ title: "Goal decision", message: data.message, variant: "success" })
|
|
585
|
+
if (openDecisionIdentity.get(data.sessionID) !== data.decisionId) return
|
|
586
|
+
openDecisions.delete(data.sessionID)
|
|
587
|
+
openDecisionIdentity.delete(data.sessionID)
|
|
588
|
+
flushSummary(data.sessionID)
|
|
589
|
+
})
|
|
590
|
+
const offFailed = rpc.events.on("decision.failed", (event: any) => {
|
|
591
|
+
const data = event.data ?? {}
|
|
592
|
+
context.ui.toast.show({ title: "Goal decision", message: String(data.message ?? "Owner operation did not execute; inspect the goal and request a new review."), variant: "warning", duration: 8000 })
|
|
593
|
+
if (openDecisionIdentity.get(data.sessionID) === data.decisionId) {
|
|
594
|
+
openDecisions.delete(data.sessionID)
|
|
595
|
+
openDecisionIdentity.delete(data.sessionID)
|
|
596
|
+
flushSummary(data.sessionID)
|
|
597
|
+
}
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
// Navigation lives in the existing Goals manager: expand a row before
|
|
601
|
+
// deliberately attaching or jumping. No separate palette chooser.
|
|
602
|
+
disposers.push(
|
|
603
|
+
context.ui.slot({
|
|
604
|
+
append: "app",
|
|
605
|
+
render: () => {
|
|
606
|
+
context.keymap.layer(() => ({
|
|
607
|
+
mode: "global" as const,
|
|
608
|
+
commands: [
|
|
609
|
+
{
|
|
610
|
+
id: "goal.attach",
|
|
611
|
+
title: "Goal: Manage goals",
|
|
612
|
+
group: "Goal",
|
|
613
|
+
palette: true,
|
|
614
|
+
enabled: () => true,
|
|
615
|
+
run: () => { setTab("goals"); openPanel() },
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
id: "goal.panel",
|
|
619
|
+
title: "Goal: Toggle dashboard",
|
|
620
|
+
group: "Goal",
|
|
621
|
+
palette: true,
|
|
622
|
+
// T056/T074: the documented default was leader+g, but OpenCode
|
|
623
|
+
// 2.0.22 plugin keymap binds cannot join the leader chord
|
|
624
|
+
// (spike S28: leader+g / ctrl+x g / bare g never dispatch —
|
|
625
|
+
// the host's which-key table lists only host commands; a
|
|
626
|
+
// host tui.json bind for a plugin id did not dispatch
|
|
627
|
+
// either). F9 is the proven direct default (pty A/B); owners
|
|
628
|
+
// can rebind via their own tui.json on upgrades that fix the
|
|
629
|
+
// leader table.
|
|
630
|
+
bind: "f9",
|
|
631
|
+
enabled: () => true,
|
|
632
|
+
run: () => togglePanel(),
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
id: "goal.dashboard.tab",
|
|
636
|
+
title: "Goal: Next dashboard tab",
|
|
637
|
+
group: "Goal",
|
|
638
|
+
palette: true,
|
|
639
|
+
bind: "tab",
|
|
640
|
+
enabled: () => hasGoal() || !!currentMountedPanel() || context.ui.panel.current()?.name === PANEL_NAME,
|
|
641
|
+
run: () => {
|
|
642
|
+
if (tab() === "goals" && (currentMountedPanel() || context.ui.panel.current()?.name === PANEL_NAME) && focusManagerSection) { focusManagerSection(); return }
|
|
643
|
+
const order = DASHBOARD_TABS
|
|
644
|
+
setTab(order[(order.indexOf(tab()) + 1) % order.length]!)
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
id: "goal.status",
|
|
649
|
+
title: "Goal: Show status",
|
|
650
|
+
group: "Goal",
|
|
651
|
+
palette: true,
|
|
652
|
+
enabled: hasGoal,
|
|
653
|
+
run: () => {
|
|
654
|
+
const view = views[currentSession()!]
|
|
655
|
+
if (view) void context.ui.dialog.alert({ title: "Goal", message: statusText(view, Date.now()) })
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
{ id: "goal.pause", title: "Goal: Pause", group: "Goal", palette: true, enabled: hasGoal, run: () => act("pause") },
|
|
659
|
+
{ id: "goal.resume", title: "Goal: Resume", group: "Goal", palette: true, enabled: hasGoal, run: () => act("resume") },
|
|
660
|
+
{ id: "goal.verify", title: "Goal: Verify now", group: "Goal", palette: true, enabled: hasGoal, run: () => act("verify") },
|
|
661
|
+
{
|
|
662
|
+
id: "goal.approve",
|
|
663
|
+
title: "Goal: Review in Composer",
|
|
664
|
+
group: "Goal",
|
|
665
|
+
palette: true,
|
|
666
|
+
enabled: () => !!currentSession(),
|
|
667
|
+
run: async () => {
|
|
668
|
+
const sessionID = currentSession()
|
|
669
|
+
if (!sessionID) return
|
|
670
|
+
const location = locationOf(sessionID)
|
|
671
|
+
try {
|
|
672
|
+
const result = await rpc.review({ sessionID }, location ? { location } : undefined)
|
|
673
|
+
context.ui.toast.show({ title: "Goal review", message: result.message, variant: result.ok ? "info" : "warning" })
|
|
674
|
+
} catch (error) { context.ui.toast.show({ title: "Goal review", message: String(error), variant: "warning" }) }
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
id: "goal.abort",
|
|
679
|
+
title: "Goal: Abort",
|
|
680
|
+
group: "Goal",
|
|
681
|
+
palette: true,
|
|
682
|
+
enabled: hasGoal,
|
|
683
|
+
run: async () => {
|
|
684
|
+
const sessionID = currentSession()
|
|
685
|
+
if (!sessionID) return
|
|
686
|
+
const location = locationOf(sessionID)
|
|
687
|
+
try {
|
|
688
|
+
const listed = await rpc.list({}, location ? { location } : undefined)
|
|
689
|
+
const goal = (listed?.goals as GoalSummary[] | undefined)?.find((g) => g.sessionID === sessionID && g.runId === views[sessionID]?.runId && !g.archived)
|
|
690
|
+
if (!goal) { context.ui.toast.show({ title: "Goal", message: "Current target changed; inspect its details before aborting.", variant: "warning" }); return }
|
|
691
|
+
const result = await rpc.manage({ sessionID, action: "abort", target: targetOf(goal) }, location ? { location } : undefined)
|
|
692
|
+
context.ui.toast.show({ title: "Goal", message: result.message, variant: result.ok ? "info" : "warning" })
|
|
693
|
+
} catch (error) { context.ui.toast.show({ title: "Goal", message: String(error), variant: "warning" }) }
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
}))
|
|
698
|
+
return null
|
|
699
|
+
},
|
|
700
|
+
} as any),
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
return () => {
|
|
704
|
+
disposed = true
|
|
705
|
+
offHandoff()
|
|
706
|
+
clearInterval(timer)
|
|
707
|
+
clearInterval(ticker)
|
|
708
|
+
offUpdated?.()
|
|
709
|
+
offNotice?.()
|
|
710
|
+
offSummary?.()
|
|
711
|
+
offDecision?.()
|
|
712
|
+
offResolved?.()
|
|
713
|
+
offFailed?.()
|
|
714
|
+
offData?.()
|
|
715
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
}
|