parallel-codex-tui 0.2.10 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui/App.js CHANGED
@@ -5,6 +5,7 @@ import { Box, Text, useApp, useInput, useStdin } from "ink";
5
5
  import { Lexer } from "marked";
6
6
  import { copyTextToClipboard } from "../core/clipboard.js";
7
7
  import { readJson } from "../core/file-store.js";
8
+ import { CONFIGURABLE_ROLES, configuredRoleSelection } from "../core/role-configuration.js";
8
9
  import { WorkerStatusSchema } from "../domain/schemas.js";
9
10
  import { effectiveWorkerWatchdog, formatRoutePendingStatus, formatRoutePendingSummaryStatus, formatRouteStatus, formatRouteSummaryStatus, formatStatusLine, formatWorkerRuntimeStatus } from "./status-line.js";
10
11
  import { applyChatInputChunk, insertChatPaste } from "./chat-input.js";
@@ -19,7 +20,7 @@ import { TerminalOutput } from "./TerminalOutput.js";
19
20
  import { NativeTerminalScreen } from "./terminal-screen.js";
20
21
  import { WorkerOutputView } from "./WorkerOutputView.js";
21
22
  import { compactEndByDisplayWidth, displayWidth, wrapByDisplayWidth } from "./display-width.js";
22
- import { isAttachShortcut, isCopyShortcut, isDiagnosticsShortcut, isExitShortcut, isLogsShortcut, isNewConversationShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawChatScrollArrowDelta, rawHistoryDelta, rawPageScrollDelta, rawPlainArrowDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
23
+ import { isAttachShortcut, isCopyShortcut, isDiagnosticsShortcut, isExitShortcut, isLogsShortcut, isNewConversationShortcut, isRoleConfigurationShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawChatScrollArrowDelta, rawHistoryDelta, rawHorizontalArrowDelta, rawPageScrollDelta, rawPlainArrowDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
23
24
  import { createRawInputDecoder, tokenizeRawInput } from "./raw-input-decoder.js";
24
25
  import { decodeHtmlEntities } from "./markdown-text.js";
25
26
  import { configureTuiTheme, TUI_THEME } from "./theme.js";
@@ -35,6 +36,8 @@ import { latestTaskResultMessageIndex, parseTaskResultSummary } from "./task-res
35
36
  import { buildNativeAttachLaunch, buildNativeForkLaunch, startNativeAttachProcess } from "../workers/native-attach.js";
36
37
  import { assignableWorkerProviderIds, workerProviderLabel, workerProviders } from "../workers/provider.js";
37
38
  import { StatusDetailView } from "./StatusDetailView.js";
39
+ import { RoleConfigurationView } from "./RoleConfigurationView.js";
40
+ import { cycleRoleProvider, moveRoleConfigurationSelection, nextRoleConfigurationScope, roleConfigurationScopeHasOverride, roleConfigurationSelectionForScope, selectedConfigurableRole, updateRoleModel } from "./role-configuration-state.js";
38
41
  import { runtimeConfigChange } from "./runtime-config-state.js";
39
42
  export function routeDecisionChatMessage(route) {
40
43
  return `route · ${route.mode} · ${route.source ?? "router"}\n${route.reason.trim()}`;
@@ -77,6 +80,15 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
77
80
  const [canRetryTask, setCanRetryTask] = useState(initialCanRetryTask);
78
81
  const [attachError, setAttachError] = useState(null);
79
82
  const [configChange, setConfigChange] = useState(null);
83
+ const [roleConfigurationSnapshot, setRoleConfigurationSnapshot] = useState(null);
84
+ const [roleConfigurationDraft, setRoleConfigurationDraft] = useState(null);
85
+ const [roleConfigurationScope, setRoleConfigurationScope] = useState("next");
86
+ const [roleConfigurationSelectedIndex, setRoleConfigurationSelectedIndex] = useState(0);
87
+ const [roleConfigurationLoading, setRoleConfigurationLoading] = useState(false);
88
+ const [roleConfigurationSaving, setRoleConfigurationSaving] = useState(false);
89
+ const [roleConfigurationNotice, setRoleConfigurationNotice] = useState(null);
90
+ const [roleConfigurationError, setRoleConfigurationError] = useState(null);
91
+ const [roleModelEdit, setRoleModelEdit] = useState(null);
80
92
  const [clipboardNotice, setClipboardNotice] = useState(null);
81
93
  const [nativeInput, setNativeInput] = useState("");
82
94
  const [workerScrollOffset, setWorkerScrollOffset] = useState(0);
@@ -169,6 +181,13 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
169
181
  const featureBoardSelectedIndexRef = useRef(0);
170
182
  const featureCancelPromptRef = useRef(null);
171
183
  const featureAssignmentPromptRef = useRef(null);
184
+ const roleConfigurationSnapshotRef = useRef(null);
185
+ const roleConfigurationDraftRef = useRef(null);
186
+ const roleConfigurationScopeRef = useRef("next");
187
+ const roleConfigurationSelectedIndexRef = useRef(0);
188
+ const roleConfigurationLoadingRef = useRef(false);
189
+ const roleConfigurationSavingRef = useRef(false);
190
+ const roleModelEditRef = useRef(null);
172
191
  const collaborationFeatureIndexRef = useRef(-1);
173
192
  const collaborationSelectedEventIdRef = useRef(null);
174
193
  const collaborationDetailOpenRef = useRef(false);
@@ -188,6 +207,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
188
207
  const confirmFeatureCancellationRef = useRef(confirmFeatureCancellation);
189
208
  const reassignSelectedFeatureRef = useRef(reassignSelectedFeature);
190
209
  const openCollaborationTimelineRef = useRef(openCollaborationTimeline);
210
+ const openRoleConfigurationRef = useRef(openRoleConfiguration);
211
+ const applyRoleConfigurationRef = useRef(applyRoleConfiguration);
212
+ const clearRoleConfigurationRef = useRef(clearRoleConfiguration);
191
213
  const refreshCollaborationTimelineRef = useRef(refreshCollaborationTimeline);
192
214
  const activateSelectedTaskSessionRef = useRef(activateSelectedTaskSession);
193
215
  const refreshTaskSessionsRef = useRef(refreshTaskSessions);
@@ -213,6 +235,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
213
235
  const taskSessionsReturnViewRef = useRef("chat");
214
236
  const collaborationReturnViewRef = useRef("workers");
215
237
  const statusReturnViewRef = useRef("chat");
238
+ const roleConfigurationReturnViewRef = useRef("chat");
239
+ const roleConfigurationLoadSequenceRef = useRef(0);
216
240
  const collaborationLoadSequenceRef = useRef(0);
217
241
  const routerLoadSequenceRef = useRef(0);
218
242
  const taskSessionsLoadSequenceRef = useRef(0);
@@ -240,7 +264,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
240
264
  && (config.workers[selectedTaskSessionDetailWorker.engine]?.interactive.forkArgs.length ?? 0) > 0);
241
265
  const featureCanCancel = busy && (selectedBoardFeature?.state === "actor_running"
242
266
  || selectedBoardFeature?.state === "critic_running");
243
- const featureCanReassign = assignableProviderIds.length > 1
267
+ const featureCanReassign = assignableProviderIds.length > 0
244
268
  && !busy
245
269
  && canRetryTask
246
270
  && selectedBoardFeature?.state !== "approved";
@@ -256,6 +280,14 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
256
280
  const taskResultMessageIndex = useMemo(() => activeTaskId ? latestTaskResultMessageIndex(messages, activeTaskId) : -1, [activeTaskId, messages]);
257
281
  const visibleChatMessages = useMemo(() => routeAnnouncement ? [...messages, routeAnnouncement] : messages, [messages, routeAnnouncement]);
258
282
  const hasTaskResult = taskResultMessageIndex >= 0;
283
+ const activeRoleSelection = roleConfigurationSnapshot
284
+ ? activeTaskId
285
+ ? roleConfigurationSnapshot.activeTurn
286
+ ?? roleConfigurationSnapshot.task
287
+ ?? roleConfigurationSnapshot.future
288
+ : roleConfigurationSnapshot.future
289
+ : configuredRoleSelection(config);
290
+ const roleConfigurationHasOverride = roleConfigurationScopeHasOverride(roleConfigurationSnapshot, roleConfigurationScope);
259
291
  useEffect(() => {
260
292
  inputRef.current = input;
261
293
  }, [input]);
@@ -325,6 +357,54 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
325
357
  useEffect(() => {
326
358
  canRetryTaskRef.current = canRetryTask;
327
359
  }, [canRetryTask]);
360
+ useEffect(() => {
361
+ roleConfigurationSnapshotRef.current = roleConfigurationSnapshot;
362
+ }, [roleConfigurationSnapshot]);
363
+ useEffect(() => {
364
+ roleConfigurationDraftRef.current = roleConfigurationDraft;
365
+ }, [roleConfigurationDraft]);
366
+ useEffect(() => {
367
+ roleConfigurationScopeRef.current = roleConfigurationScope;
368
+ }, [roleConfigurationScope]);
369
+ useEffect(() => {
370
+ roleConfigurationSelectedIndexRef.current = roleConfigurationSelectedIndex;
371
+ }, [roleConfigurationSelectedIndex]);
372
+ useEffect(() => {
373
+ roleConfigurationLoadingRef.current = roleConfigurationLoading;
374
+ }, [roleConfigurationLoading]);
375
+ useEffect(() => {
376
+ roleConfigurationSavingRef.current = roleConfigurationSaving;
377
+ }, [roleConfigurationSaving]);
378
+ useEffect(() => {
379
+ roleModelEditRef.current = roleModelEdit;
380
+ }, [roleModelEdit]);
381
+ useEffect(() => {
382
+ let active = true;
383
+ const sequence = roleConfigurationLoadSequenceRef.current + 1;
384
+ roleConfigurationLoadSequenceRef.current = sequence;
385
+ const snapshotPromise = typeof orchestrator.roleConfigurationSnapshot === "function"
386
+ ? orchestrator.roleConfigurationSnapshot(activeTaskId)
387
+ : Promise.resolve(fallbackRoleConfigurationSnapshot(config));
388
+ void snapshotPromise.then((snapshot) => {
389
+ if (!active || roleConfigurationLoadSequenceRef.current !== sequence) {
390
+ return;
391
+ }
392
+ roleConfigurationSnapshotRef.current = snapshot;
393
+ setRoleConfigurationSnapshot(snapshot);
394
+ if (viewRef.current === "roles") {
395
+ const draft = roleConfigurationSelectionForScope(snapshot, roleConfigurationScopeRef.current);
396
+ roleConfigurationDraftRef.current = draft;
397
+ setRoleConfigurationDraft(draft);
398
+ }
399
+ }).catch((error) => {
400
+ if (active && roleConfigurationLoadSequenceRef.current === sequence) {
401
+ setRoleConfigurationError(error instanceof Error ? error.message : String(error));
402
+ }
403
+ });
404
+ return () => {
405
+ active = false;
406
+ };
407
+ }, [activeTaskId, orchestrator]);
328
408
  useEffect(() => {
329
409
  taskResultExpandedRef.current = taskResultExpanded;
330
410
  }, [taskResultExpanded]);
@@ -433,6 +513,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
433
513
  openWorkerOverviewRef.current = openWorkerOverview;
434
514
  openTaskSessionsRef.current = openTaskSessions;
435
515
  openFeatureBoardRef.current = openFeatureBoard;
516
+ openRoleConfigurationRef.current = openRoleConfiguration;
517
+ applyRoleConfigurationRef.current = applyRoleConfiguration;
518
+ clearRoleConfigurationRef.current = clearRoleConfiguration;
436
519
  confirmFeatureCancellationRef.current = confirmFeatureCancellation;
437
520
  openCollaborationTimelineRef.current = openCollaborationTimeline;
438
521
  refreshCollaborationTimelineRef.current = refreshCollaborationTimeline;
@@ -828,6 +911,137 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
828
911
  if (currentView === "workspace") {
829
912
  return;
830
913
  }
914
+ if (currentView !== "native"
915
+ && tokenizeRawInput(chunk).some((inputChunk) => isRoleConfigurationShortcut(inputChunk, {}))
916
+ && !busyRef.current) {
917
+ if (currentView === "roles") {
918
+ roleModelEditRef.current = null;
919
+ setRoleModelEdit(null);
920
+ viewRef.current = roleConfigurationReturnViewRef.current;
921
+ setView(roleConfigurationReturnViewRef.current);
922
+ }
923
+ else {
924
+ void openRoleConfigurationRef.current();
925
+ }
926
+ return;
927
+ }
928
+ if (currentView === "roles") {
929
+ const roleChunks = tokenizeRawInput(chunk);
930
+ if (roleChunks.some((roleChunk) => isExitShortcut(roleChunk, {}))) {
931
+ activeRunControllerRef.current?.abort();
932
+ exitRef.current();
933
+ return;
934
+ }
935
+ if (roleChunks.some((roleChunk) => isStatusDetailsShortcut(roleChunk, {}))) {
936
+ statusReturnViewRef.current = "roles";
937
+ viewRef.current = "status";
938
+ setView("status");
939
+ return;
940
+ }
941
+ for (const roleChunk of roleChunks) {
942
+ const edit = roleModelEditRef.current;
943
+ if (edit) {
944
+ if (roleChunk === "\x1b") {
945
+ roleModelEditRef.current = null;
946
+ setRoleModelEdit(null);
947
+ continue;
948
+ }
949
+ const update = applyChatInputChunk(edit.value, roleChunk, edit.cursor);
950
+ if (update.submit !== null) {
951
+ const draft = roleConfigurationDraftRef.current;
952
+ if (draft) {
953
+ const nextDraft = updateRoleModel(draft, edit.role, update.submit);
954
+ roleConfigurationDraftRef.current = nextDraft;
955
+ setRoleConfigurationDraft(nextDraft);
956
+ }
957
+ roleModelEditRef.current = null;
958
+ setRoleModelEdit(null);
959
+ }
960
+ else {
961
+ const nextEdit = { ...edit, value: update.value, cursor: update.cursor };
962
+ roleModelEditRef.current = nextEdit;
963
+ setRoleModelEdit(nextEdit);
964
+ }
965
+ continue;
966
+ }
967
+ if (roleChunk === "\x1b") {
968
+ viewRef.current = roleConfigurationReturnViewRef.current;
969
+ setView(roleConfigurationReturnViewRef.current);
970
+ return;
971
+ }
972
+ if (roleConfigurationLoadingRef.current || roleConfigurationSavingRef.current) {
973
+ continue;
974
+ }
975
+ if (roleChunk === "\t") {
976
+ const nextScope = nextRoleConfigurationScope(roleConfigurationScopeRef.current, 1, Boolean(activeTaskIdRef.current));
977
+ roleConfigurationScopeRef.current = nextScope;
978
+ setRoleConfigurationScope(nextScope);
979
+ const snapshot = roleConfigurationSnapshotRef.current;
980
+ if (snapshot) {
981
+ const draft = roleConfigurationSelectionForScope(snapshot, nextScope);
982
+ roleConfigurationDraftRef.current = draft;
983
+ setRoleConfigurationDraft(draft);
984
+ }
985
+ setRoleConfigurationNotice(null);
986
+ setRoleConfigurationError(null);
987
+ continue;
988
+ }
989
+ const selectionDelta = -(rawHistoryDelta(roleChunk)
990
+ + rawPageScrollDelta(roleChunk, CONFIGURABLE_ROLES.length)
991
+ + mouseScrollDelta(roleChunk, 1));
992
+ if (selectionDelta !== 0) {
993
+ const nextIndex = moveRoleConfigurationSelection(roleConfigurationSelectedIndexRef.current, selectionDelta);
994
+ roleConfigurationSelectedIndexRef.current = nextIndex;
995
+ setRoleConfigurationSelectedIndex(nextIndex);
996
+ continue;
997
+ }
998
+ const providerDelta = rawHorizontalArrowDelta(roleChunk)
999
+ || (roleChunk === "]" ? 1 : roleChunk === "[" ? -1 : 0);
1000
+ if (providerDelta !== 0) {
1001
+ const snapshot = roleConfigurationSnapshotRef.current;
1002
+ const draft = roleConfigurationDraftRef.current;
1003
+ if (snapshot && draft) {
1004
+ const nextDraft = cycleRoleProvider(draft, selectedConfigurableRole(roleConfigurationSelectedIndexRef.current), snapshot.providers, providerDelta);
1005
+ roleConfigurationDraftRef.current = nextDraft;
1006
+ setRoleConfigurationDraft(nextDraft);
1007
+ setRoleConfigurationNotice(null);
1008
+ setRoleConfigurationError(null);
1009
+ }
1010
+ continue;
1011
+ }
1012
+ if (roleChunk === "m" || roleChunk === "M") {
1013
+ const role = selectedConfigurableRole(roleConfigurationSelectedIndexRef.current);
1014
+ const value = roleConfigurationDraftRef.current?.[role].model ?? "";
1015
+ const editState = {
1016
+ role,
1017
+ value,
1018
+ cursor: Array.from(value).length
1019
+ };
1020
+ roleModelEditRef.current = editState;
1021
+ setRoleModelEdit(editState);
1022
+ continue;
1023
+ }
1024
+ if (roleChunk === "\r" || roleChunk === "\n") {
1025
+ void applyRoleConfigurationRef.current();
1026
+ return;
1027
+ }
1028
+ if (roleChunk === "x" || roleChunk === "X") {
1029
+ void clearRoleConfigurationRef.current();
1030
+ return;
1031
+ }
1032
+ if (roleChunk === "r" || roleChunk === "R") {
1033
+ const snapshot = roleConfigurationSnapshotRef.current;
1034
+ if (snapshot) {
1035
+ const draft = roleConfigurationSelectionForScope(snapshot, roleConfigurationScopeRef.current);
1036
+ roleConfigurationDraftRef.current = draft;
1037
+ setRoleConfigurationDraft(draft);
1038
+ setRoleConfigurationNotice("Draft restored from saved configuration");
1039
+ setRoleConfigurationError(null);
1040
+ }
1041
+ }
1042
+ }
1043
+ return;
1044
+ }
831
1045
  if (currentView === "status") {
832
1046
  if (isExitShortcut(chunk, {})) {
833
1047
  activeRunControllerRef.current?.abort();
@@ -1182,6 +1396,24 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1182
1396
  const pendingAssignment = featureAssignmentPromptRef.current;
1183
1397
  if (pendingAssignment) {
1184
1398
  for (const featureChunk of featureChunks) {
1399
+ const modelEdit = featureAssignmentPromptRef.current?.modelEdit;
1400
+ if (modelEdit) {
1401
+ if (featureChunk === "\x1b") {
1402
+ updateFeatureAssignmentPrompt({ ...pendingAssignment, modelEdit: undefined });
1403
+ continue;
1404
+ }
1405
+ const update = applyChatInputChunk(modelEdit.value, featureChunk, modelEdit.cursor);
1406
+ if (update.submit !== null) {
1407
+ void reassignSelectedFeatureRef.current(pendingAssignment, modelEdit.role, update.submit);
1408
+ }
1409
+ else {
1410
+ updateFeatureAssignmentPrompt({
1411
+ ...pendingAssignment,
1412
+ modelEdit: { ...modelEdit, value: update.value, cursor: update.cursor }
1413
+ });
1414
+ }
1415
+ continue;
1416
+ }
1185
1417
  if (featureChunk === "\x1b" || featureChunk === "m" || featureChunk === "M") {
1186
1418
  updateFeatureAssignmentPrompt(null);
1187
1419
  setFeatureBoardNotice(null);
@@ -1195,6 +1427,16 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1195
1427
  void reassignSelectedFeatureRef.current(pendingAssignment, "critic");
1196
1428
  return;
1197
1429
  }
1430
+ if (featureChunk === "1" || featureChunk === "2") {
1431
+ const feature = collaborationTimelineRef.current?.features.find((item) => item.id === pendingAssignment.featureId);
1432
+ const role = featureChunk === "1" ? "actor" : "critic";
1433
+ const value = role === "actor" ? feature?.actorModel ?? "" : feature?.criticModel ?? "";
1434
+ updateFeatureAssignmentPrompt({
1435
+ ...pendingAssignment,
1436
+ modelEdit: { role, value, cursor: Array.from(value).length }
1437
+ });
1438
+ return;
1439
+ }
1198
1440
  }
1199
1441
  return;
1200
1442
  }
@@ -1254,7 +1496,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1254
1496
  featureId: feature.id,
1255
1497
  title: feature.title
1256
1498
  });
1257
- setFeatureBoardNotice(`Reassign ${feature.title} · A Actor (${feature.actorEngine ?? config.pairing.actor}) · C Critic (${feature.criticEngine ?? config.pairing.critic})`);
1499
+ setFeatureBoardNotice(`Reassign ${feature.title} · A Actor (${featureTargetDisplay(config, feature.actorEngine ?? config.pairing.actor, feature.actorModel)}) · C Critic (${featureTargetDisplay(config, feature.criticEngine ?? config.pairing.critic, feature.criticModel)})`);
1258
1500
  return;
1259
1501
  }
1260
1502
  if (featureChunk === "r" || featureChunk === "R") {
@@ -1841,6 +2083,15 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1841
2083
  .join("\n")
1842
2084
  };
1843
2085
  }
2086
+ if (currentView === "roles") {
2087
+ const roles = roleConfigurationDraftRef.current;
2088
+ return {
2089
+ label: "role configuration",
2090
+ text: roles
2091
+ ? CONFIGURABLE_ROLES.map((role) => `${role} · ${roles[role].engine} · ${roles[role].model || "default"}`).join("\n")
2092
+ : ""
2093
+ };
2094
+ }
1844
2095
  if (currentView === "workers") {
1845
2096
  return {
1846
2097
  label: "workers",
@@ -2187,6 +2438,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2187
2438
  cwd,
2188
2439
  taskId: target.taskId,
2189
2440
  route: followUpRoute?.route,
2441
+ roleSelection: followUpRoute?.roleSelection,
2190
2442
  ...callbacks
2191
2443
  })
2192
2444
  : target.kind === "task-question"
@@ -2195,6 +2447,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2195
2447
  cwd,
2196
2448
  taskId: target.taskId,
2197
2449
  route: followUpRoute?.route,
2450
+ roleSelection: followUpRoute?.roleSelection,
2198
2451
  ...callbacks
2199
2452
  })
2200
2453
  : await orchestrator.handleRequest({
@@ -2209,6 +2462,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2209
2462
  setCanRetryTask(nextMemory.activeTaskId
2210
2463
  ? await orchestrator.canRetryTask(nextMemory.activeTaskId)
2211
2464
  : false);
2465
+ await refreshRoleConfigurationState(nextMemory.activeTaskId);
2212
2466
  setRouteAnnouncement(null);
2213
2467
  await appendVisibleMessage({ from: "system", text: result.summary }, nextMemory.activeTaskId ?? undefined);
2214
2468
  if (result.mode === "complex" && parseTaskResultSummary(result.summary)) {
@@ -2221,6 +2475,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2221
2475
  if (retryTaskId) {
2222
2476
  setCanRetryTask(await orchestrator.canRetryTask(retryTaskId));
2223
2477
  }
2478
+ await refreshRoleConfigurationState(retryTaskId);
2224
2479
  await appendVisibleMessage({
2225
2480
  from: "system",
2226
2481
  text: isAbortError(error) ? "cancelled · request stopped" : error instanceof Error ? error.message : String(error)
@@ -2268,6 +2523,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2268
2523
  setActiveTaskId(taskId);
2269
2524
  setActiveMode("complex");
2270
2525
  setCanRetryTask(false);
2526
+ await refreshRoleConfigurationState(taskId);
2271
2527
  setRouteAnnouncement(null);
2272
2528
  await appendVisibleMessage({ from: "system", text: result.summary }, taskId);
2273
2529
  if (parseTaskResultSummary(result.summary)) {
@@ -2277,6 +2533,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2277
2533
  catch (error) {
2278
2534
  setRouteAnnouncement(null);
2279
2535
  setCanRetryTask(await orchestrator.canRetryTask(taskId));
2536
+ await refreshRoleConfigurationState(taskId);
2280
2537
  await appendVisibleMessage({
2281
2538
  from: "system",
2282
2539
  text: isAbortError(error) ? "cancelled · retry stopped" : error instanceof Error ? error.message : String(error)
@@ -2305,6 +2562,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2305
2562
  return;
2306
2563
  }
2307
2564
  resetActiveTaskUiForMainConversation();
2565
+ await refreshRoleConfigurationState(null);
2308
2566
  await appendVisibleMessage({ from: "system", text: "new conversation · ready" });
2309
2567
  }
2310
2568
  function resetActiveTaskUiForMainConversation() {
@@ -2857,7 +3115,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2857
3115
  featureAssignmentPromptRef.current = next;
2858
3116
  setFeatureAssignmentPrompt(next);
2859
3117
  }
2860
- async function reassignSelectedFeature(prompt, role) {
3118
+ async function reassignSelectedFeature(prompt, role, model) {
2861
3119
  const feature = collaborationTimelineRef.current?.features.find((item) => item.id === prompt.featureId);
2862
3120
  if (!feature) {
2863
3121
  setFeatureBoardNotice(`Feature no longer exists: ${prompt.featureId}`);
@@ -2867,15 +3125,22 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2867
3125
  const current = role === "actor"
2868
3126
  ? feature.actorEngine ?? config.pairing.actor
2869
3127
  : feature.criticEngine ?? config.pairing.critic;
2870
- const engine = nextAssignableFeatureEngine(current, assignableProviderIds);
3128
+ const engine = model === undefined
3129
+ ? nextAssignableFeatureEngine(current, assignableProviderIds)
3130
+ : current;
2871
3131
  try {
2872
- await orchestrator.reassignFeature({
3132
+ const result = await orchestrator.reassignFeature({
2873
3133
  taskId: prompt.taskId,
2874
3134
  featureId: prompt.featureId,
2875
3135
  role,
2876
- engine
3136
+ engine,
3137
+ ...(model === undefined ? {} : { model })
2877
3138
  });
2878
- setFeatureBoardNotice(`${role === "actor" ? "Actor" : "Critic"} reassigned to ${featureEngineDisplay(config, engine)} · Ctrl+R resumes the task.`);
3139
+ updateFeatureAssignmentPrompt({ ...prompt, modelEdit: undefined });
3140
+ const assignedModel = role === "actor"
3141
+ ? result.assignment.actor_model
3142
+ : result.assignment.critic_model;
3143
+ setFeatureBoardNotice(`${role === "actor" ? "Actor" : "Critic"} reassigned to ${featureTargetDisplay(config, engine, assignedModel)} · Ctrl+R resumes the task.`);
2879
3144
  await refreshCollaborationTimeline(false);
2880
3145
  }
2881
3146
  catch (error) {
@@ -3070,6 +3335,159 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3070
3335
  setAttachError(null);
3071
3336
  setView("workers");
3072
3337
  }
3338
+ async function refreshRoleConfigurationState(taskId) {
3339
+ try {
3340
+ const snapshot = typeof orchestrator.roleConfigurationSnapshot === "function"
3341
+ ? await orchestrator.roleConfigurationSnapshot(taskId)
3342
+ : fallbackRoleConfigurationSnapshot(config);
3343
+ roleConfigurationSnapshotRef.current = snapshot;
3344
+ setRoleConfigurationSnapshot(snapshot);
3345
+ if (viewRef.current === "roles") {
3346
+ const draft = roleConfigurationSelectionForScope(snapshot, roleConfigurationScopeRef.current);
3347
+ roleConfigurationDraftRef.current = draft;
3348
+ setRoleConfigurationDraft(draft);
3349
+ }
3350
+ }
3351
+ catch (error) {
3352
+ setRoleConfigurationError(error instanceof Error ? error.message : String(error));
3353
+ }
3354
+ }
3355
+ async function openRoleConfiguration() {
3356
+ const currentView = viewRef.current;
3357
+ if (busyRef.current || currentView === "native" || currentView === "workspace") {
3358
+ return;
3359
+ }
3360
+ const returnView = currentView === "roles" ? roleConfigurationReturnViewRef.current : currentView;
3361
+ roleConfigurationReturnViewRef.current = returnView;
3362
+ const scope = roleConfigurationScopeRef.current === "task" && !activeTaskIdRef.current
3363
+ ? "next"
3364
+ : roleConfigurationScopeRef.current;
3365
+ roleConfigurationScopeRef.current = scope;
3366
+ setRoleConfigurationScope(scope);
3367
+ roleModelEditRef.current = null;
3368
+ setRoleModelEdit(null);
3369
+ setRoleConfigurationNotice(null);
3370
+ setRoleConfigurationError(null);
3371
+ roleConfigurationLoadingRef.current = true;
3372
+ setRoleConfigurationLoading(true);
3373
+ viewRef.current = "roles";
3374
+ setView("roles");
3375
+ const sequence = roleConfigurationLoadSequenceRef.current + 1;
3376
+ roleConfigurationLoadSequenceRef.current = sequence;
3377
+ try {
3378
+ const snapshot = typeof orchestrator.roleConfigurationSnapshot === "function"
3379
+ ? await orchestrator.roleConfigurationSnapshot(activeTaskIdRef.current)
3380
+ : fallbackRoleConfigurationSnapshot(config);
3381
+ if (!mountedRef.current || roleConfigurationLoadSequenceRef.current !== sequence) {
3382
+ return;
3383
+ }
3384
+ roleConfigurationSnapshotRef.current = snapshot;
3385
+ setRoleConfigurationSnapshot(snapshot);
3386
+ const draft = roleConfigurationSelectionForScope(snapshot, scope);
3387
+ roleConfigurationDraftRef.current = draft;
3388
+ setRoleConfigurationDraft(draft);
3389
+ }
3390
+ catch (error) {
3391
+ if (mountedRef.current && roleConfigurationLoadSequenceRef.current === sequence) {
3392
+ setRoleConfigurationError(error instanceof Error ? error.message : String(error));
3393
+ }
3394
+ }
3395
+ finally {
3396
+ if (mountedRef.current && roleConfigurationLoadSequenceRef.current === sequence) {
3397
+ roleConfigurationLoadingRef.current = false;
3398
+ setRoleConfigurationLoading(false);
3399
+ }
3400
+ }
3401
+ }
3402
+ async function applyRoleConfiguration() {
3403
+ const draft = roleConfigurationDraftRef.current;
3404
+ if (!draft || roleConfigurationSavingRef.current || roleConfigurationLoadingRef.current) {
3405
+ return;
3406
+ }
3407
+ const scope = roleConfigurationScopeRef.current;
3408
+ const taskId = activeTaskIdRef.current;
3409
+ if (scope === "task" && !taskId) {
3410
+ setRoleConfigurationError("No active Task · choose next request or future requests");
3411
+ return;
3412
+ }
3413
+ roleConfigurationSavingRef.current = true;
3414
+ setRoleConfigurationSaving(true);
3415
+ setRoleConfigurationNotice(null);
3416
+ setRoleConfigurationError(null);
3417
+ try {
3418
+ if (typeof orchestrator.updateRoleConfiguration !== "function") {
3419
+ throw new Error("Role configuration is unavailable in this runtime");
3420
+ }
3421
+ if (typeof orchestrator.validateRoleConfiguration === "function") {
3422
+ const validation = await orchestrator.validateRoleConfiguration(draft);
3423
+ if (!validation.ok) {
3424
+ throw new Error(formatRoleConfigurationPreflightFailure(validation.lines));
3425
+ }
3426
+ }
3427
+ const snapshot = await orchestrator.updateRoleConfiguration({
3428
+ scope,
3429
+ roles: draft,
3430
+ taskId
3431
+ });
3432
+ roleConfigurationSnapshotRef.current = snapshot;
3433
+ setRoleConfigurationSnapshot(snapshot);
3434
+ const saved = roleConfigurationSelectionForScope(snapshot, scope);
3435
+ roleConfigurationDraftRef.current = saved;
3436
+ setRoleConfigurationDraft(saved);
3437
+ setRoleConfigurationNotice(scope === "next"
3438
+ ? "Saved · the next request will consume this matrix once"
3439
+ : scope === "task"
3440
+ ? "Saved · current Task retries and follow-ups will use this matrix"
3441
+ : "Saved · future requests now use this default matrix");
3442
+ if (scope === "task" && taskId) {
3443
+ void refreshCollaborationTimelineRef.current(false);
3444
+ }
3445
+ }
3446
+ catch (error) {
3447
+ setRoleConfigurationError(error instanceof Error ? error.message : String(error));
3448
+ }
3449
+ finally {
3450
+ roleConfigurationSavingRef.current = false;
3451
+ setRoleConfigurationSaving(false);
3452
+ }
3453
+ }
3454
+ async function clearRoleConfiguration() {
3455
+ if (roleConfigurationSavingRef.current || roleConfigurationLoadingRef.current) {
3456
+ return;
3457
+ }
3458
+ const scope = roleConfigurationScopeRef.current;
3459
+ const taskId = activeTaskIdRef.current;
3460
+ if (scope === "task" && !taskId) {
3461
+ setRoleConfigurationError("No active Task · choose next request or future requests");
3462
+ return;
3463
+ }
3464
+ roleConfigurationSavingRef.current = true;
3465
+ setRoleConfigurationSaving(true);
3466
+ setRoleConfigurationNotice(null);
3467
+ setRoleConfigurationError(null);
3468
+ try {
3469
+ if (typeof orchestrator.clearRoleConfiguration !== "function") {
3470
+ throw new Error("Role configuration is unavailable in this runtime");
3471
+ }
3472
+ const snapshot = await orchestrator.clearRoleConfiguration(scope, taskId);
3473
+ roleConfigurationSnapshotRef.current = snapshot;
3474
+ setRoleConfigurationSnapshot(snapshot);
3475
+ const inherited = roleConfigurationSelectionForScope(snapshot, scope);
3476
+ roleConfigurationDraftRef.current = inherited;
3477
+ setRoleConfigurationDraft(inherited);
3478
+ setRoleConfigurationNotice(scope === "future" ? "Reset · future requests inherit config.toml" : "Reset · scope now inherits future defaults");
3479
+ if (scope === "task" && taskId) {
3480
+ void refreshCollaborationTimelineRef.current(false);
3481
+ }
3482
+ }
3483
+ catch (error) {
3484
+ setRoleConfigurationError(error instanceof Error ? error.message : String(error));
3485
+ }
3486
+ finally {
3487
+ roleConfigurationSavingRef.current = false;
3488
+ setRoleConfigurationSaving(false);
3489
+ }
3490
+ }
3073
3491
  function openWorkspacePicker() {
3074
3492
  const currentView = viewRef.current;
3075
3493
  if (busyRef.current || !switchWorkspace || currentView === "native" || currentView === "workspace") {
@@ -3101,7 +3519,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3101
3519
  if (view === "workspace") {
3102
3520
  return (_jsx(Box, { flexDirection: "column", height: terminalSize.rows, children: _jsx(WorkspacePicker, { cwd: cwd, choices: workspaceChoices, terminalHeight: terminalSize.rows, terminalWidth: terminalWidth, onCancel: closeWorkspacePicker, onSelect: (workspace) => void selectWorkspace(workspace) }) }));
3103
3521
  }
3104
- return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary, configChange?.compact].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), taskSessionAction: sessionCenterMode === "conversations"
3522
+ return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary, configChange?.compact].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), featureEditingModel: featureAssignmentPrompt?.modelEdit ?? null, taskSessionAction: sessionCenterMode === "conversations"
3105
3523
  ? mainConversationAction?.type === "rename"
3106
3524
  ? { type: "rename", value: mainConversationAction.value, cursor: mainConversationAction.cursor }
3107
3525
  : mainConversationAction?.type === "delete"
@@ -3113,11 +3531,11 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3113
3531
  ? { type: "delete", title: taskSessionAction.title }
3114
3532
  : null, taskSessionsIncludeArchived: sessionCenterMode === "conversations"
3115
3533
  ? mainConversationsIncludeArchived
3116
- : taskSessionsIncludeArchived, mainConversationSessions: sessionCenterMode === "conversations" && !taskSessionDetails, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, value: workerSearch.open && view === "worker"
3534
+ : taskSessionsIncludeArchived, mainConversationSessions: sessionCenterMode === "conversations" && !taskSessionDetails, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, roleScope: roleConfigurationScope, roleEditingModel: roleModelEdit, roleCanApply: roleConfigurationScope !== "task" || Boolean(activeTaskId), roleSaving: roleConfigurationSaving, roleHasOverride: roleConfigurationHasOverride, value: workerSearch.open && view === "worker"
3117
3535
  ? workerSearch.query
3118
- : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
3536
+ : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" || view === "roles" ? "" : input, cursor: workerSearch.open && view === "worker"
3119
3537
  ? workerSearch.cursor
3120
- : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : sessionCenterMode === "conversations" ? (_jsx(MainConversationsView, { conversations: mainConversations, selectedIndex: selectedMainConversationIndex, includeArchived: mainConversationsIncludeArchived, notice: taskSessionsNotice, action: mainConversationAction?.type === "rename"
3538
+ : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "roles" ? (_jsx(RoleConfigurationView, { snapshot: roleConfigurationSnapshot, draft: roleConfigurationDraft, scope: roleConfigurationScope, selectedRoleIndex: roleConfigurationSelectedIndex, loading: roleConfigurationLoading, saving: roleConfigurationSaving, notice: roleConfigurationNotice, error: roleConfigurationError, hasActiveTask: Boolean(activeTaskId), height: contentHeight, terminalWidth: terminalWidth })) : view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, roleSelection: activeRoleSelection, roleConfigurationSnapshot: roleConfigurationSnapshot, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : sessionCenterMode === "conversations" ? (_jsx(MainConversationsView, { conversations: mainConversations, selectedIndex: selectedMainConversationIndex, includeArchived: mainConversationsIncludeArchived, notice: taskSessionsNotice, action: mainConversationAction?.type === "rename"
3121
3539
  ? { type: "rename", title: mainConversationAction.title }
3122
3540
  : mainConversationAction?.type === "delete"
3123
3541
  ? { type: "delete", title: mainConversationAction.title }
@@ -3154,6 +3572,22 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3154
3572
  }
3155
3573
  } })) }));
3156
3574
  }
3575
+ function fallbackRoleConfigurationSnapshot(config) {
3576
+ const roles = configuredRoleSelection(config);
3577
+ return {
3578
+ baseline: roles,
3579
+ future: configuredRoleSelection(config),
3580
+ next: null,
3581
+ task: null,
3582
+ activeTurn: null,
3583
+ providers: workerProviders(config).map(({ id, config: provider }) => ({
3584
+ id,
3585
+ model: provider.model.name,
3586
+ modelProvider: provider.model.provider,
3587
+ assignable: provider.assignable
3588
+ }))
3589
+ };
3590
+ }
3157
3591
  function readTerminalSize() {
3158
3592
  return {
3159
3593
  columns: Math.max(1, Math.trunc(process.stdout.columns || 120)),
@@ -3963,6 +4397,10 @@ function sessionDetailWorkerLabel(worker) {
3963
4397
  function featureEngineDisplay(config, engine) {
3964
4398
  return workerProviderLabel(config, engine);
3965
4399
  }
4400
+ function featureTargetDisplay(config, engine, model) {
4401
+ const effectiveModel = model?.trim() || config.workers[engine]?.model.name || "default";
4402
+ return `${featureEngineDisplay(config, engine)}/${effectiveModel}`;
4403
+ }
3966
4404
  function compactChatText(text, maxLength) {
3967
4405
  if (maxLength <= 5) {
3968
4406
  return takeChatTextStartByDisplayWidth(text, maxLength);
@@ -4352,6 +4790,15 @@ function compactNativeAttachRole(label) {
4352
4790
  function compactNativeSessionForTitle(sessionId, maxLength) {
4353
4791
  return compactEndByDisplayWidth(sessionId, Math.min(maxLength, 16));
4354
4792
  }
4793
+ export function formatRoleConfigurationPreflightFailure(lines) {
4794
+ const clean = lines
4795
+ .map((line) => line.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim())
4796
+ .filter(Boolean);
4797
+ const actionable = clean.filter((line) => (/\b(?:denied|error|failed|invalid|missing|unavailable|unsupported)\b/i.test(line)
4798
+ || /\bnot (?:found|supported|available)\b/i.test(line)));
4799
+ const detail = (actionable.length > 0 ? actionable : clean).slice(0, 4);
4800
+ return ["Provider preflight failed", ...detail].join(" · ");
4801
+ }
4355
4802
  function isAbortError(error) {
4356
4803
  return error instanceof Error && error.name === "AbortError";
4357
4804
  }