@bastani/atomic 0.8.6-0 → 0.8.7-0

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/package.json +1 -1
  4. package/dist/builtin/subagents/package.json +1 -1
  5. package/dist/builtin/web-access/package.json +1 -1
  6. package/dist/builtin/workflows/builtin/ralph.ts +368 -52
  7. package/dist/builtin/workflows/package.json +1 -1
  8. package/dist/builtin/workflows/src/extension/index.ts +30 -2
  9. package/dist/builtin/workflows/src/runs/background/status.ts +6 -0
  10. package/dist/builtin/workflows/src/runs/foreground/executor.ts +2 -4
  11. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +5 -5
  12. package/dist/builtin/workflows/src/shared/store-types.ts +8 -0
  13. package/dist/builtin/workflows/src/shared/store.ts +39 -4
  14. package/dist/builtin/workflows/src/shared/timing.ts +48 -0
  15. package/dist/builtin/workflows/src/tui/chat-surface-message.ts +21 -2
  16. package/dist/builtin/workflows/src/tui/graph-view.ts +17 -18
  17. package/dist/builtin/workflows/src/tui/inline-form-card.ts +2 -2
  18. package/dist/builtin/workflows/src/tui/inline-form-editor.ts +18 -15
  19. package/dist/builtin/workflows/src/tui/inputs-picker.ts +24 -22
  20. package/dist/builtin/workflows/src/tui/node-card.ts +3 -5
  21. package/dist/builtin/workflows/src/tui/prompt-card.ts +11 -11
  22. package/dist/builtin/workflows/src/tui/run-detail.ts +4 -6
  23. package/dist/builtin/workflows/src/tui/session-confirm.ts +93 -8
  24. package/dist/builtin/workflows/src/tui/session-picker.ts +10 -15
  25. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +93 -22
  26. package/dist/builtin/workflows/src/tui/status-list.ts +4 -6
  27. package/dist/builtin/workflows/src/tui/text-helpers.ts +7 -1
  28. package/dist/builtin/workflows/src/tui/widget.ts +2 -1
  29. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +2 -1
  30. package/package.json +1 -1
@@ -1173,10 +1173,19 @@ function factory(pi: ExtensionAPI): void {
1173
1173
  // noopOverlay returned when pi.ui?.custom is absent (degraded runtime).
1174
1174
  const overlay: GraphOverlayPort = buildGraphOverlayAdapter(pi, store, {
1175
1175
  onKillRun: (runId) => {
1176
- destroyRun(runId, {
1176
+ const run = store.runs().find((r) => r.id === runId);
1177
+ const result = destroyRun(runId, {
1177
1178
  cancellation: cancellationRegistry,
1178
1179
  persistence: persistenceRef.current,
1179
1180
  });
1181
+ if (run && result.ok) {
1182
+ emitChatSurface(pi, {
1183
+ kind: "killed",
1184
+ run,
1185
+ previousStatus: result.previousStatus,
1186
+ wasInFlight: result.wasInFlight,
1187
+ });
1188
+ }
1180
1189
  },
1181
1190
  });
1182
1191
 
@@ -1453,6 +1462,14 @@ function factory(pi: ExtensionAPI): void {
1453
1462
  cancellation: cancellationRegistry,
1454
1463
  persistence: persistenceRef.current,
1455
1464
  });
1465
+ if (killed.ok) {
1466
+ emitChatSurface(pi, {
1467
+ kind: "killed",
1468
+ run,
1469
+ previousStatus: killed.previousStatus,
1470
+ wasInFlight: killed.wasInFlight,
1471
+ });
1472
+ }
1456
1473
  print(
1457
1474
  killed.ok
1458
1475
  ? `Run ${killed.runId.slice(0, 8)} killed and removed.`
@@ -1632,6 +1649,14 @@ function factory(pi: ExtensionAPI): void {
1632
1649
  persistence: persistenceRef.current,
1633
1650
  });
1634
1651
  if (result.ok) {
1652
+ if (run) {
1653
+ emitChatSurface(pi, {
1654
+ kind: "killed",
1655
+ run,
1656
+ previousStatus: result.previousStatus,
1657
+ wasInFlight: result.wasInFlight,
1658
+ });
1659
+ }
1635
1660
  print(
1636
1661
  `Run ${result.runId.slice(0, 8)} killed and removed (was ${result.previousStatus}).`,
1637
1662
  );
@@ -1696,9 +1721,12 @@ function factory(pi: ExtensionAPI): void {
1696
1721
  stageId = byName.id;
1697
1722
  }
1698
1723
  overlay.open(runId, overlaySurfaceFromContext(ctx), stageId);
1724
+ const attachedStage = stageId ? run?.stages.find((s) => s.id === stageId) : undefined;
1699
1725
  print(
1700
1726
  stageId
1701
- ? `Attached to ${runId.slice(0, 8)} stage ${stageId.slice(0, 8)}. ctrl+d return to graph · esc close.`
1727
+ ? attachedStage?.status === "paused"
1728
+ ? `Attached to ${runId.slice(0, 8)} stage ${stageId.slice(0, 8)}. ctrl+d close · esc close.`
1729
+ : `Attached to ${runId.slice(0, 8)} stage ${stageId.slice(0, 8)}. ctrl+d return to graph · esc close.`
1702
1730
  : `Attached to ${runId.slice(0, 8)}. ↵ chat · ctrl+d detach.`,
1703
1731
  );
1704
1732
  return true;
@@ -70,6 +70,9 @@ export interface RunDetail {
70
70
  readonly startedAt: number;
71
71
  readonly endedAt?: number;
72
72
  readonly durationMs?: number;
73
+ readonly pausedDurationMs?: number;
74
+ readonly pausedAt?: number;
75
+ readonly resumedAt?: number;
73
76
  readonly inputs: Readonly<Record<string, unknown>>;
74
77
  readonly stages: readonly RunSnapshot["stages"][number][];
75
78
  readonly result?: Record<string, unknown>;
@@ -433,6 +436,9 @@ export function inspectRun(
433
436
  startedAt: copy.startedAt,
434
437
  endedAt: copy.endedAt,
435
438
  durationMs: copy.durationMs,
439
+ pausedDurationMs: copy.pausedDurationMs,
440
+ pausedAt: copy.pausedAt,
441
+ resumedAt: copy.resumedAt,
436
442
  inputs: copy.inputs,
437
443
  stages: copy.stages,
438
444
  result: copy.result,
@@ -51,6 +51,7 @@ import {
51
51
  type WorktreeSetup,
52
52
  } from "../shared/worktree.js";
53
53
  import { store as defaultStore } from "../../shared/store.js";
54
+ import { elapsedStageMs } from "../../shared/timing.js";
54
55
  import {
55
56
  appendRunStart,
56
57
  appendStageStart,
@@ -1520,10 +1521,7 @@ export async function run<TInputs extends Record<string, unknown>>(
1520
1521
  throw err;
1521
1522
  } finally {
1522
1523
  stageSnapshot.endedAt = Date.now();
1523
- stageSnapshot.durationMs =
1524
- stageSnapshot.startedAt !== undefined
1525
- ? stageSnapshot.endedAt - stageSnapshot.startedAt
1526
- : undefined;
1524
+ stageSnapshot.durationMs = elapsedStageMs(stageSnapshot, stageSnapshot.endedAt);
1527
1525
 
1528
1526
  const finalModelMeta = innerCtx.__modelFallbackMeta();
1529
1527
  if (finalModelMeta.model !== undefined) stageSnapshot.model = finalModelMeta.model;
@@ -174,7 +174,7 @@ type MessageWithTextContent = {
174
174
  readonly content?: string | readonly TextLikeContent[];
175
175
  };
176
176
 
177
- function extractAssistantText(message: AgentSession["messages"][number]): string {
177
+ function extractMessageText(message: AgentSession["messages"][number]): string {
178
178
  const { content } = message as MessageWithTextContent;
179
179
  if (typeof content === "string") return content;
180
180
  if (Array.isArray(content)) {
@@ -186,11 +186,11 @@ function extractAssistantText(message: AgentSession["messages"][number]): string
186
186
  return "";
187
187
  }
188
188
 
189
- function lastAssistantTextFromMessages(messages: AgentSession["messages"]): string | undefined {
189
+ function lastOutputTextFromMessages(messages: AgentSession["messages"]): string | undefined {
190
190
  for (let index = messages.length - 1; index >= 0; index -= 1) {
191
191
  const message = messages[index];
192
- if (!message || message.role !== "assistant") continue;
193
- const text = extractAssistantText(message).trim();
192
+ if (!message || (message.role !== "assistant" && message.role !== "toolResult")) continue;
193
+ const text = extractMessageText(message).trim();
194
194
  if (text) return text;
195
195
  }
196
196
  return undefined;
@@ -217,7 +217,7 @@ function lastAssistantTextFromSession(
217
217
  if (!activeSession) return fallback;
218
218
  const direct = activeSession.getLastAssistantText?.();
219
219
  if (direct !== undefined && direct.trim()) return direct;
220
- return lastAssistantTextFromMessages(activeSession.messages) ?? direct ?? fallback;
220
+ return lastOutputTextFromMessages(activeSession.messages) ?? direct ?? fallback;
221
221
  }
222
222
 
223
223
  const DEFAULT_MAX_OUTPUT_BYTES = 200 * 1024;
@@ -101,6 +101,8 @@ export interface StageSnapshot {
101
101
  attachable?: boolean;
102
102
  /** True while a user pane is actively attached to this stage. */
103
103
  attached?: boolean;
104
+ /** Milliseconds spent paused across completed pause intervals. */
105
+ pausedDurationMs?: number;
104
106
  /** Timestamp set when a controlled pause begins; cleared on resume. */
105
107
  pausedAt?: number;
106
108
  /** Timestamp recorded on the most recent resume from a paused state. */
@@ -116,6 +118,12 @@ export interface RunSnapshot {
116
118
  startedAt: number;
117
119
  endedAt?: number;
118
120
  durationMs?: number;
121
+ /** Milliseconds spent paused across completed pause intervals. */
122
+ pausedDurationMs?: number;
123
+ /** Timestamp set when a controlled pause begins; cleared on resume. */
124
+ pausedAt?: number;
125
+ /** Timestamp recorded on the most recent resume from a paused state. */
126
+ resumedAt?: number;
119
127
  result?: Record<string, unknown>;
120
128
  error?: string;
121
129
  /**
@@ -13,6 +13,7 @@ import type {
13
13
  RunStatus,
14
14
  WorkflowNotice,
15
15
  } from "./store-types.js";
16
+ import { accumulatePausedDurationMs, elapsedRunMs } from "./timing.js";
16
17
 
17
18
  /** Statuses that represent a terminal run state — cannot be overwritten. */
18
19
  const TERMINAL_STATUSES: ReadonlySet<RunStatus> = new Set(["completed", "failed", "killed"]);
@@ -263,6 +264,14 @@ export function createStore(): Store {
263
264
  if (!existing) return;
264
265
  existing.status = stage.status;
265
266
  existing.endedAt = stage.endedAt;
267
+ if (existing.endedAt !== undefined && existing.pausedAt !== undefined) {
268
+ existing.pausedDurationMs = accumulatePausedDurationMs(
269
+ existing.pausedDurationMs,
270
+ existing.pausedAt,
271
+ existing.endedAt,
272
+ );
273
+ existing.pausedAt = undefined;
274
+ }
266
275
  existing.durationMs = stage.durationMs;
267
276
  existing.result = stage.result;
268
277
  existing.error = stage.error;
@@ -283,7 +292,15 @@ export function createStore(): Store {
283
292
  if (TERMINAL_STATUSES.has(run.status)) return false;
284
293
  run.status = status;
285
294
  run.endedAt = Date.now();
286
- run.durationMs = run.endedAt - run.startedAt;
295
+ if (run.pausedAt !== undefined) {
296
+ run.pausedDurationMs = accumulatePausedDurationMs(
297
+ run.pausedDurationMs,
298
+ run.pausedAt,
299
+ run.endedAt,
300
+ );
301
+ run.pausedAt = undefined;
302
+ }
303
+ run.durationMs = elapsedRunMs(run, run.endedAt);
287
304
  if (status === "completed" && result !== undefined) {
288
305
  run.result = result;
289
306
  }
@@ -547,8 +564,16 @@ export function createStore(): Store {
547
564
  const stage = findStage(run, stageId);
548
565
  if (!stage) return false;
549
566
  if (stage.status !== "paused" && stage.status !== "blocked") return false;
567
+ const resumedTs = resumedAt ?? Date.now();
550
568
  stage.status = "running";
551
- stage.resumedAt = resumedAt ?? Date.now();
569
+ if (stage.startedAt !== undefined) {
570
+ stage.pausedDurationMs = accumulatePausedDurationMs(
571
+ stage.pausedDurationMs,
572
+ stage.pausedAt,
573
+ resumedTs,
574
+ );
575
+ }
576
+ stage.resumedAt = resumedTs;
552
577
  stage.pausedAt = undefined;
553
578
  delete stage.blockedByStageId;
554
579
  delete stage.awaitingInputSince;
@@ -557,23 +582,33 @@ export function createStore(): Store {
557
582
  return true;
558
583
  },
559
584
 
560
- recordRunPaused(runId: string, _pausedAt?: number): boolean {
585
+ recordRunPaused(runId: string, pausedAt?: number): boolean {
561
586
  const run = findRun(runId);
562
587
  if (!run) return false;
563
588
  if (TERMINAL_STATUSES.has(run.status)) return false;
564
589
  if (run.status === "paused") return false;
565
590
  run.status = "paused";
591
+ run.pausedAt = pausedAt ?? Date.now();
592
+ run.resumedAt = undefined;
566
593
  _version++;
567
594
  notify();
568
595
  return true;
569
596
  },
570
597
 
571
- recordRunResumed(runId: string, _resumedAt?: number): boolean {
598
+ recordRunResumed(runId: string, resumedAt?: number): boolean {
572
599
  const run = findRun(runId);
573
600
  if (!run) return false;
574
601
  if (TERMINAL_STATUSES.has(run.status)) return false;
575
602
  if (run.status !== "paused") return false;
603
+ const resumedTs = resumedAt ?? Date.now();
576
604
  run.status = "running";
605
+ run.pausedDurationMs = accumulatePausedDurationMs(
606
+ run.pausedDurationMs,
607
+ run.pausedAt,
608
+ resumedTs,
609
+ );
610
+ run.resumedAt = resumedTs;
611
+ run.pausedAt = undefined;
577
612
  _version++;
578
613
  notify();
579
614
  return true;
@@ -0,0 +1,48 @@
1
+ interface StageTimerSnapshot {
2
+ readonly startedAt?: number;
3
+ readonly durationMs?: number;
4
+ readonly pausedDurationMs?: number;
5
+ readonly pausedAt?: number;
6
+ }
7
+
8
+ interface RunTimerSnapshot {
9
+ readonly startedAt: number;
10
+ readonly durationMs?: number;
11
+ readonly pausedDurationMs?: number;
12
+ readonly pausedAt?: number;
13
+ }
14
+
15
+ function nonNegative(ms: number): number {
16
+ return Math.max(0, ms);
17
+ }
18
+
19
+ function elapsedFromStart(
20
+ startedAt: number,
21
+ now: number,
22
+ pausedDurationMs: number | undefined,
23
+ pausedAt: number | undefined,
24
+ ): number {
25
+ const completedPausedSegment = pausedDurationMs ?? 0;
26
+ const activePausedSegment = pausedAt === undefined ? 0 : nonNegative(now - pausedAt);
27
+ return nonNegative(now - startedAt - completedPausedSegment - activePausedSegment);
28
+ }
29
+
30
+ export function accumulatePausedDurationMs(
31
+ pausedDurationMs: number | undefined,
32
+ pausedAt: number | undefined,
33
+ resumedAt: number,
34
+ ): number {
35
+ if (pausedAt === undefined) return pausedDurationMs ?? 0;
36
+ return (pausedDurationMs ?? 0) + nonNegative(resumedAt - pausedAt);
37
+ }
38
+
39
+ export function elapsedStageMs(stage: StageTimerSnapshot, now = Date.now()): number | undefined {
40
+ if (stage.durationMs !== undefined) return nonNegative(stage.durationMs);
41
+ if (stage.startedAt === undefined) return undefined;
42
+ return elapsedFromStart(stage.startedAt, now, stage.pausedDurationMs, stage.pausedAt);
43
+ }
44
+
45
+ export function elapsedRunMs(run: RunTimerSnapshot, now = Date.now()): number {
46
+ if (run.durationMs !== undefined) return nonNegative(run.durationMs);
47
+ return elapsedFromStart(run.startedAt, now, run.pausedDurationMs, run.pausedAt);
48
+ }
@@ -27,13 +27,14 @@
27
27
 
28
28
  import type { ExtensionAPI } from "../extension/index.js";
29
29
  import type { RunDetail } from "../runs/background/status.js";
30
- import type { RunSnapshot } from "../shared/store-types.js";
30
+ import type { RunSnapshot, RunStatus } from "../shared/store-types.js";
31
31
  import type { GraphTheme } from "./graph-theme.js";
32
32
  import type { WorkflowListEntry } from "./workflow-list.js";
33
33
  import { renderDispatchConfirm } from "./dispatch-confirm.js";
34
34
  import { renderRunDetail } from "./run-detail.js";
35
35
  import { renderStatusList } from "./status-list.js";
36
36
  import { renderWorkflowList } from "./workflow-list.js";
37
+ import { renderWorkflowKilledNotice } from "./session-confirm.js";
37
38
 
38
39
  /** Custom message type wired to {@link registerChatSurfaceRenderer}. */
39
40
  export const CHAT_SURFACE_CUSTOM_TYPE = "workflows:chat-surface";
@@ -78,11 +79,20 @@ export interface DetailPayload {
78
79
  detail: RunDetail;
79
80
  }
80
81
 
82
+ /** Inline notice after a workflow run is destructively killed and removed. */
83
+ export interface KilledPayload {
84
+ kind: "killed";
85
+ run: RunSnapshot;
86
+ previousStatus: RunStatus;
87
+ wasInFlight: boolean;
88
+ }
89
+
81
90
  export type ChatSurfacePayload =
82
91
  | DispatchPayload
83
92
  | StatusPayload
84
93
  | ListPayload
85
- | DetailPayload;
94
+ | DetailPayload
95
+ | KilledPayload;
86
96
 
87
97
  // ---------------------------------------------------------------------------
88
98
  // Renderer registration
@@ -177,6 +187,7 @@ function describePayload(payload: ChatSurfacePayload): string {
177
187
  case "status": return `status · ${payload.runs.length} run${payload.runs.length === 1 ? "" : "s"}`;
178
188
  case "list": return `workflows · ${payload.entries.length} registered`;
179
189
  case "detail": return `run detail · ${payload.detail.runId.slice(0, 8)}`;
190
+ case "killed": return `workflow killed · ${payload.run.id.slice(0, 8)}`;
180
191
  }
181
192
  }
182
193
 
@@ -220,5 +231,13 @@ function renderPayload(
220
231
  // hint is unused — its surface mirrors the orchestrator panel
221
232
  // (outline-pill chrome), not the flex chat band.
222
233
  return renderRunDetail(payload.detail, { theme });
234
+ case "killed":
235
+ return renderWorkflowKilledNotice({
236
+ width,
237
+ theme,
238
+ run: payload.run,
239
+ previousStatus: payload.previousStatus,
240
+ wasInFlight: payload.wasInFlight,
241
+ }).join("\n");
223
242
  }
224
243
  }
@@ -30,6 +30,7 @@ import type {
30
30
  StoreSnapshot,
31
31
  RunSnapshot,
32
32
  } from "../shared/store-types.js";
33
+ import { elapsedStageMs } from "../shared/timing.js";
33
34
  import type { GraphTheme } from "./graph-theme.js";
34
35
  import type { SwitcherState } from "./switcher.js";
35
36
  import type { LayoutNode } from "./layout.js";
@@ -146,8 +147,8 @@ const OVERLAY_VERTICAL_MARGIN_ROWS = 1;
146
147
 
147
148
  /**
148
149
  * Animation tick period. Overlay re-renders fire on this cadence so
149
- * duration counters (rendered from `Date.now() - stage.startedAt`)
150
- * tick and the running-stage border lerps between `borderDim` and
150
+ * duration counters tick from active elapsed time (freezing while paused)
151
+ * and the running-stage border lerps between `borderDim` and
151
152
  * `warning` without a key press. The host-supplied `requestRender`
152
153
  * gate prevents work while the overlay is hidden or unfocused.
153
154
  */
@@ -964,10 +965,8 @@ export class GraphView implements Component {
964
965
  }
965
966
 
966
967
  private _duration(stage: StageSnapshot): string {
967
- if (stage.durationMs != null) return fmtDuration(stage.durationMs);
968
- if (stage.startedAt != null)
969
- return fmtDuration(Date.now() - stage.startedAt);
970
- return "";
968
+ const elapsed = elapsedStageMs(stage);
969
+ return elapsed === undefined ? "" : fmtDuration(elapsed);
971
970
  }
972
971
 
973
972
  private _counts(run: RunSnapshot): {
@@ -1051,13 +1050,13 @@ export class GraphView implements Component {
1051
1050
  // Vertical-graph navigation: up/down step between depth levels
1052
1051
  // (col), left/right step between siblings at the same depth (row).
1053
1052
  // j/k preserved as a flat-order fallback for muscle memory.
1054
- if (matchesKey(data, "down") || data === "\x1b[B")
1053
+ if (matchesKey(data, "down"))
1055
1054
  return this._moveByDepth(+1);
1056
- if (matchesKey(data, "up") || data === "\x1b[A")
1055
+ if (matchesKey(data, "up"))
1057
1056
  return this._moveByDepth(-1);
1058
- if (matchesKey(data, "right") || data === "\x1b[C")
1057
+ if (matchesKey(data, "right"))
1059
1058
  return this._moveBySibling(+1);
1060
- if (matchesKey(data, "left") || data === "\x1b[D")
1059
+ if (matchesKey(data, "left"))
1061
1060
  return this._moveBySibling(-1);
1062
1061
  if (matchesKey(data, "j")) {
1063
1062
  this._setFocusedIndex(Math.min(this.focusedIndex + 1, stageCount - 1));
@@ -1082,7 +1081,7 @@ export class GraphView implements Component {
1082
1081
  this.switcherState = { query: "", selectedIndex: 0 };
1083
1082
  return true;
1084
1083
  }
1085
- if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
1084
+ if (matchesKey(data, "enter")) {
1086
1085
  // Enter attaches the popup interior to the focused stage. The
1087
1086
  // attach shell swaps in the stage-chat view without remounting
1088
1087
  // the overlay; without a callback, fall back to the legacy
@@ -1093,7 +1092,7 @@ export class GraphView implements Component {
1093
1092
  }
1094
1093
  // `ctrl+d` detaches the whole popup (host hides the overlay). This
1095
1094
  // is the graph-mode counterpart of the in-chat back affordance.
1096
- if (data === "\x04") {
1095
+ if (matchesKey(data, "ctrl+d")) {
1097
1096
  if (this.onDetach) {
1098
1097
  this.onDetach();
1099
1098
  } else if (this.onHide) {
@@ -1115,7 +1114,7 @@ export class GraphView implements Component {
1115
1114
  this.onHide();
1116
1115
  return true;
1117
1116
  }
1118
- if (matchesKey(data, "escape") || data === "\x1b" || data === "\x03") {
1117
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
1119
1118
  this.onClose?.();
1120
1119
  return true;
1121
1120
  }
@@ -1126,11 +1125,11 @@ export class GraphView implements Component {
1126
1125
  const run = this._getCurrentRun();
1127
1126
  const stages = run?.stages ?? [];
1128
1127
 
1129
- if (matchesKey(data, "escape") || data === "\x1b") {
1128
+ if (matchesKey(data, "escape")) {
1130
1129
  this.switcherOpen = false;
1131
1130
  return true;
1132
1131
  }
1133
- if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
1132
+ if (matchesKey(data, "enter")) {
1134
1133
  const filtered = filterStages(stages, this.switcherState.query);
1135
1134
  const selected = filtered[this.switcherState.selectedIndex];
1136
1135
  if (selected) {
@@ -1149,7 +1148,7 @@ export class GraphView implements Component {
1149
1148
  this.switcherOpen = false;
1150
1149
  return true;
1151
1150
  }
1152
- if (matchesKey(data, "down") || data === "\x1b[B") {
1151
+ if (matchesKey(data, "down")) {
1153
1152
  const filtered = filterStages(stages, this.switcherState.query);
1154
1153
  this.switcherState = {
1155
1154
  ...this.switcherState,
@@ -1160,14 +1159,14 @@ export class GraphView implements Component {
1160
1159
  };
1161
1160
  return true;
1162
1161
  }
1163
- if (matchesKey(data, "up") || data === "\x1b[A") {
1162
+ if (matchesKey(data, "up")) {
1164
1163
  this.switcherState = {
1165
1164
  ...this.switcherState,
1166
1165
  selectedIndex: Math.max(this.switcherState.selectedIndex - 1, 0),
1167
1166
  };
1168
1167
  return true;
1169
1168
  }
1170
- if (data === "\x7f" || data === "\b") {
1169
+ if (matchesKey(data, "backspace")) {
1171
1170
  this.switcherState = {
1172
1171
  query: this.switcherState.query.slice(0, -1),
1173
1172
  selectedIndex: 0,
@@ -25,7 +25,7 @@
25
25
  * ╰──────────────────────────────────────────────────────────────────╯
26
26
  * integer · optional · loop count
27
27
  *
28
- * ╭ EDIT ╮ tab next · shift+tab prev · ctrl+enter run · esc cancel
28
+ * ╭ EDIT ╮ tab next · shift+tab prev · ctrl+x run · esc cancel
29
29
  * │ EDIT │
30
30
  * ╰──────╯
31
31
  *
@@ -193,7 +193,7 @@ function renderFooterBand(theme: GraphTheme, width: number): string[] {
193
193
  const hints: Array<{ key: string; label: string }> = [
194
194
  { key: "tab", label: "Next" },
195
195
  { key: "shift+tab", label: "Prev" },
196
- { key: "ctrl+enter", label: "Run" },
196
+ { key: "ctrl+x", label: "Run" },
197
197
  { key: "esc", label: "Cancel" },
198
198
  ];
199
199
  const sep = `${chromeBg} ${dim}·${RESET}${chromeBg} `;
@@ -16,13 +16,13 @@
16
16
  * space — boolean toggle
17
17
  * enter — newline (text) | otherwise next field
18
18
  * printable ASCII — insert at caret (text/string/number)
19
- * ctrl+enter — submit form (if valid)
19
+ * ctrl+x — submit form (if valid)
20
20
  * esc / ctrl+c — cancel form
21
21
  *
22
22
  * Editor-mode keys (cursor movement, word jumps, deletions) route through
23
23
  * the Pi `KeybindingsManager` injected by the host at factory time, so any
24
24
  * user-configured keybinding overrides surfaces here as well. Form-level
25
- * keys (tab/shift+tab/ctrl+enter/esc/ctrl+c) stay as raw byte checks because
25
+ * keys (tab/shift+tab/ctrl+x/esc/ctrl+c) stay as raw byte checks because
26
26
  * they are workflow form contract, not Pi-configurable actions.
27
27
  *
28
28
  * On submit/cancel the editor calls back to the orchestrator which:
@@ -57,14 +57,14 @@ import {
57
57
  wordLeft,
58
58
  wordRight,
59
59
  } from "./keybindings-adapter.js";
60
- import { matchesKey, visibleWidth } from "./text-helpers.js";
60
+ import { decodePrintableKey, matchesKey, visibleWidth } from "./text-helpers.js";
61
61
 
62
62
  export type FormEditorOutcome = "submit" | "cancel";
63
63
 
64
64
  export interface InlineFormEditorOpts {
65
65
  formId: string;
66
66
  theme: GraphTheme;
67
- /** Called when Ctrl+Enter passes validation or cancel fires. */
67
+ /** Called when Ctrl+X passes validation or cancel fires. */
68
68
  onExit: (outcome: FormEditorOutcome) => void;
69
69
  /**
70
70
  * Pi's `KeybindingsManager` injected as the third arg of the editor
@@ -433,14 +433,14 @@ export class InlineFormEditor implements PiEditorComponent {
433
433
  // editor actions, so they stay as raw byte checks:
434
434
  // esc (\x1b) — cancel form
435
435
  // ctrl+c (\x03) — cancel form
436
- // ctrl+enter — submit form
436
+ // ctrl+x — submit form
437
437
  // tab (\t) — focus next field
438
438
  // shift+tab (\x1b[Z) — focus previous field
439
- if (data === "\x03" || matchesKey(data, "escape")) {
439
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
440
440
  this.opts.onExit("cancel");
441
441
  return true;
442
442
  }
443
- if (matchesKey(data, "ctrl+enter")) {
443
+ if (matchesKey(data, "ctrl+x")) {
444
444
  if (this.allValid(state)) this.opts.onExit("submit");
445
445
  else this.focusFirstInvalid(state);
446
446
  return true;
@@ -475,7 +475,7 @@ export class InlineFormEditor implements PiEditorComponent {
475
475
  state.rawText[field.name] = choices[(i - 1 + choices.length) % choices.length]!;
476
476
  return true;
477
477
  }
478
- if (matchesAction(this.kb, data, "tui.editor.cursorRight") || data === " ") {
478
+ if (matchesAction(this.kb, data, "tui.editor.cursorRight") || matchesKey(data, "space")) {
479
479
  state.rawText[field.name] = choices[(i + 1) % choices.length]!;
480
480
  return true;
481
481
  }
@@ -499,7 +499,7 @@ export class InlineFormEditor implements PiEditorComponent {
499
499
  state: InlineFormState,
500
500
  ): boolean {
501
501
  if (
502
- data === " " ||
502
+ matchesKey(data, "space") ||
503
503
  matchesAction(this.kb, data, "tui.editor.cursorLeft") ||
504
504
  matchesAction(this.kb, data, "tui.editor.cursorRight")
505
505
  ) {
@@ -654,12 +654,15 @@ export class InlineFormEditor implements PiEditorComponent {
654
654
  return true;
655
655
  }
656
656
 
657
- // Printable insertion — no Pi action, raw grapheme check. Numeric
658
- // fields accept the same printable range as text; per-field validation
659
- // catches non-numeric content at submit time.
660
- if (isPrintableGrapheme(data)) {
661
- state.rawText[name] = cur.slice(0, caret) + data + cur.slice(caret);
662
- state.caret = caret + data.length;
657
+ // Printable insertion — accept raw graphemes and terminal-encoded
658
+ // printable keys (CSI-u / Kitty). VSCode's integrated terminal can emit
659
+ // printable keys as escape sequences when modifyOtherKeys is active.
660
+ // Numeric fields accept the same printable range as text; per-field
661
+ // validation catches non-numeric content at submit time.
662
+ const printable = decodePrintableKey(data) ?? data;
663
+ if (isPrintableGrapheme(printable)) {
664
+ state.rawText[name] = cur.slice(0, caret) + printable + cur.slice(caret);
665
+ state.caret = caret + printable.length;
663
666
  return true;
664
667
  }
665
668
  return false;