@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
@@ -22,7 +22,7 @@
22
22
  * ╰──────────────────────────────────────────╯
23
23
  * select · required · How aggressively to scope the work.
24
24
  *
25
- * tab next · shift+tab prev · ctrl+enter run · esc cancel
25
+ * tab next · shift+tab prev · ctrl+x run · esc cancel
26
26
  *
27
27
  * Field-type renderers:
28
28
  * - string / number : single-row text input with blinking cursor
@@ -40,7 +40,7 @@
40
40
  import type { WorkflowInputEntry } from "../extension/render-result.js";
41
41
  import type { GraphTheme } from "./graph-theme.js";
42
42
  import { paint } from "./color-utils.js";
43
- import { matchesKey, truncateToWidth, visibleWidth } from "./text-helpers.js";
43
+ import { decodePrintableKey, matchesKey, truncateToWidth, visibleWidth } from "./text-helpers.js";
44
44
  import {
45
45
  type KeybindingsLike,
46
46
  deleteRange,
@@ -718,10 +718,10 @@ export function renderInputsPicker(opts: InputsPickerRenderOpts): string[] {
718
718
  /**
719
719
  * Footer hint row, tier-degraded so it never wraps on resize. Tiers:
720
720
  *
721
- * wide (≥ widest): tab next · shift+tab prev · ctrl+enter run · esc cancel
722
- * medium (≥ keys): tab · shift+tab · ctrl+enter · esc
723
- * tight (≥ short): tab · ⇧tab · ⌃↵ · esc
724
- * narrow (else): ⌃↵ · esc
721
+ * wide (≥ widest): tab next · shift+tab prev · ctrl+x run · esc cancel
722
+ * medium (≥ keys): tab · shift+tab · ctrl+x · esc
723
+ * tight (≥ short): tab · ⇧tab · ctrl+x · esc
724
+ * narrow (else): ctrl+x · esc
725
725
  */
726
726
  function renderFooterHints(width: number, theme: GraphTheme, submitDisabled: boolean): string {
727
727
  const sep = dimSep(theme);
@@ -735,23 +735,23 @@ function renderFooterHints(width: number, theme: GraphTheme, submitDisabled: boo
735
735
  const wide = [
736
736
  { width: 8, render: () => hint("tab", "Next") },
737
737
  { width: 14, render: () => hint("shift+tab", "Prev") },
738
- { width: 14, render: () => hint("ctrl+enter", "Run", submitColor, submitLabelColor) },
738
+ { width: 10, render: () => hint("ctrl+x", "Run", submitColor, submitLabelColor) },
739
739
  { width: 10, render: () => hint("esc", "Cancel") },
740
740
  ];
741
741
  const medium = [
742
742
  { width: 3, render: () => keyOnly("tab") },
743
743
  { width: 9, render: () => keyOnly("shift+tab") },
744
- { width: 10, render: () => keyOnly("ctrl+enter", submitColor) },
744
+ { width: 6, render: () => keyOnly("ctrl+x", submitColor) },
745
745
  { width: 6, render: () => keyOnly("esc") },
746
746
  ];
747
747
  const tight = [
748
748
  { width: 3, render: () => keyOnly("tab") },
749
749
  { width: 4, render: () => keyOnly("⇧tab") },
750
- { width: 2, render: () => keyOnly("⌃↵", submitColor) },
750
+ { width: 6, render: () => keyOnly("ctrl+x", submitColor) },
751
751
  { width: 6, render: () => keyOnly("esc") },
752
752
  ];
753
753
  const narrow = [
754
- { width: 2, render: () => keyOnly("⌃↵", submitColor) },
754
+ { width: 6, render: () => keyOnly("ctrl+x", submitColor) },
755
755
  { width: 6, render: () => keyOnly("esc") },
756
756
  ];
757
757
 
@@ -762,7 +762,7 @@ function renderFooterHints(width: number, theme: GraphTheme, submitDisabled: boo
762
762
  }
763
763
  }
764
764
  // Truly tiny terminal — show just the run+cancel keys joined by a single space.
765
- return paint("⌃↵", submitColor) + " " + paint("esc", theme.text);
765
+ return paint("ctrl+x", submitColor) + " " + paint("esc", theme.text);
766
766
  }
767
767
 
768
768
  /**
@@ -827,7 +827,7 @@ function shortVal(s: string): string {
827
827
  * left / right — select: cycle choices; boolean: flip; text: caret
828
828
  * space — boolean: flip
829
829
  * enter — text: newline; otherwise: next field
830
- * ctrl+enter — open confirm modal (if all required filled)
830
+ * ctrl+x — open confirm modal (if all required filled)
831
831
  * backspace — delete char left of caret
832
832
  * esc / ctrl+c — close picker without running
833
833
  *
@@ -860,7 +860,7 @@ function handleFormKey(
860
860
  ): InputsPickerAction {
861
861
  // ── Global navigation (workflow form contract, not Pi actions) ──
862
862
  if (isCancelKey(key)) return { kind: "cancel" };
863
- if (matchesKey(key, "ctrl+enter")) return attemptPickerSubmit(state, fields);
863
+ if (matchesKey(key, "ctrl+x")) return attemptPickerSubmit(state, fields);
864
864
  if (matchesKey(key, "tab")) {
865
865
  moveFocus(state, fields, +1);
866
866
  return { kind: "noop" };
@@ -989,11 +989,13 @@ function handleFormKey(
989
989
  }
990
990
  return { kind: "noop" };
991
991
  }
992
- // Printable insert. Accept exactly one grapheme cluster so CJK, emoji ZWJ
993
- // sequences, and combining-mark input follow pi-tui Input semantics.
994
- if (isPrintableGrapheme(key)) {
995
- state.rawText[name] = cur.slice(0, caret) + key + cur.slice(caret);
996
- state.caret = caret + key.length;
992
+ // Printable insert. Accept raw graphemes and terminal-encoded printable
993
+ // keys (CSI-u / Kitty). VSCode's integrated terminal can emit printable
994
+ // keys as escape sequences when modifyOtherKeys is active.
995
+ const printable = decodePrintableKey(key) ?? key;
996
+ if (isPrintableGrapheme(printable)) {
997
+ state.rawText[name] = cur.slice(0, caret) + printable + cur.slice(caret);
998
+ state.caret = caret + printable.length;
997
999
  return { kind: "noop" };
998
1000
  }
999
1001
  return { kind: "noop" };
@@ -1037,7 +1039,7 @@ function handleBooleanKey(
1037
1039
  kb: KeybindingsLike | undefined,
1038
1040
  ): InputsPickerAction {
1039
1041
  if (
1040
- key === " " ||
1042
+ matchesKey(key, "space") ||
1041
1043
  matchesAction(kb, key, "tui.input.submit") ||
1042
1044
  matchesAction(kb, key, "tui.editor.cursorLeft") ||
1043
1045
  matchesAction(kb, key, "tui.editor.cursorRight")
@@ -1064,10 +1066,10 @@ function handleConfirmKey(
1064
1066
  // Confirm-modal answers are single-char prompts (`y`/`n`) plus the form's
1065
1067
  // raw esc/enter contract. These do not flow through Pi action ids because
1066
1068
  // they're a confirmation-modal contract, not an editor-mode action.
1067
- if (key === "y" || key === "Y" || key === "\r" || key === "\n") {
1069
+ if (key === "y" || key === "Y" || matchesKey(key, "enter")) {
1068
1070
  return { kind: "run", values: coerceValues(fields, state.rawText) };
1069
1071
  }
1070
- if (key === "\x03") return { kind: "cancel" };
1072
+ if (matchesKey(key, "ctrl+c")) return { kind: "cancel" };
1071
1073
  if (key === "n" || key === "N" || matchesKey(key, "escape")) {
1072
1074
  state.confirmOpen = false;
1073
1075
  return { kind: "noop" };
@@ -1076,7 +1078,7 @@ function handleConfirmKey(
1076
1078
  }
1077
1079
 
1078
1080
  function isCancelKey(key: string): boolean {
1079
- return key === "\x03" || matchesKey(key, "escape");
1081
+ return matchesKey(key, "ctrl+c") || matchesKey(key, "escape");
1080
1082
  }
1081
1083
 
1082
1084
  function attemptPickerSubmit(
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import type { StageSnapshot, StageStatus } from "../shared/store-types.js";
29
+ import { elapsedStageMs } from "../shared/timing.js";
29
30
  import type { GraphTheme } from "./graph-theme.js";
30
31
  import { fmtDuration, statusIcon } from "./status-helpers.js";
31
32
  import { lerpColor, hexToAnsi, hexBg, paint, RESET, BOLD } from "./color-utils.js";
@@ -111,9 +112,8 @@ function blockedBadgeText(
111
112
  }
112
113
 
113
114
  function durationText(stage: StageSnapshot): string {
114
- if (stage.durationMs != null) return fmtDuration(stage.durationMs);
115
- if (stage.startedAt != null) return fmtDuration(Date.now() - stage.startedAt);
116
- return "—";
115
+ const elapsed = elapsedStageMs(stage);
116
+ return elapsed === undefined ? "—" : fmtDuration(elapsed);
117
117
  }
118
118
 
119
119
  function metaText(stage: StageSnapshot): string {
@@ -253,8 +253,6 @@ export function renderNodeCard(stage: StageSnapshot, opts: NodeCardOpts): string
253
253
  const bodyText =
254
254
  stage.status === "blocked"
255
255
  ? blockedBadgeText(stage, opts.stages, innerWidth)
256
- : stage.status === "paused"
257
- ? "❚❚ paused"
258
256
  : durationText(stage);
259
257
  const bodyHex = durationColor(stage.status, theme);
260
258
  const statusText = `${statusIcon(stage.status)} ${statusLabel(stage.status)}`;
@@ -99,7 +99,7 @@ export function handlePromptCardInput(
99
99
  data: string,
100
100
  state: PromptCardState,
101
101
  ): PromptCardAction {
102
- if (data === "\x03" || matchesKey(data, "escape")) {
102
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
103
103
  return { kind: "cancel" };
104
104
  }
105
105
 
@@ -119,7 +119,7 @@ function handleConfirm(
119
119
  data: string,
120
120
  state: PromptCardState,
121
121
  ): PromptCardAction {
122
- if (matchesKey(data, "left") || data === "\x1b[D" || matchesKey(data, "right") || data === "\x1b[C" || data === " " || matchesKey(data, "tab")) {
122
+ if (matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "space") || matchesKey(data, "tab")) {
123
123
  state.confirmValue = !state.confirmValue;
124
124
  return { kind: "noop" };
125
125
  }
@@ -129,7 +129,7 @@ function handleConfirm(
129
129
  if (data === "n" || data === "N") {
130
130
  return { kind: "submit", response: false };
131
131
  }
132
- if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
132
+ if (matchesKey(data, "enter")) {
133
133
  return { kind: "submit", response: state.confirmValue };
134
134
  }
135
135
  return { kind: "noop" };
@@ -138,7 +138,7 @@ function handleConfirm(
138
138
  function handleSelect(data: string, state: PromptCardState): PromptCardAction {
139
139
  const choices = state.prompt.choices ?? [];
140
140
  if (choices.length === 0) {
141
- if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
141
+ if (matchesKey(data, "enter")) {
142
142
  return { kind: "submit", response: "" };
143
143
  }
144
144
  return { kind: "noop" };
@@ -155,7 +155,7 @@ function handleSelect(data: string, state: PromptCardState): PromptCardAction {
155
155
  }
156
156
 
157
157
  function handleInput(data: string, state: PromptCardState): PromptCardAction {
158
- if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
158
+ if (matchesKey(data, "enter")) {
159
159
  return { kind: "submit", response: state.rawText };
160
160
  }
161
161
  return applyTextEdit(data, state);
@@ -172,7 +172,7 @@ function handleEditor(data: string, state: PromptCardState): PromptCardAction {
172
172
  }
173
173
  return { kind: "noop" };
174
174
  }
175
- if (data === "\r" || data === "\n") {
175
+ if (matchesKey(data, "enter")) {
176
176
  state.rawText = state.rawText.slice(0, state.caret) + "\n" + state.rawText.slice(state.caret);
177
177
  state.caret += 1;
178
178
  return { kind: "noop" };
@@ -184,15 +184,15 @@ function applyTextEdit(
184
184
  data: string,
185
185
  state: PromptCardState,
186
186
  ): PromptCardAction {
187
- if (data === "\x1b[D") {
187
+ if (matchesKey(data, "left")) {
188
188
  state.caret = previousGraphemeBoundary(state.rawText, state.caret);
189
189
  return { kind: "noop" };
190
190
  }
191
- if (data === "\x1b[C") {
191
+ if (matchesKey(data, "right")) {
192
192
  state.caret = nextGraphemeBoundary(state.rawText, state.caret);
193
193
  return { kind: "noop" };
194
194
  }
195
- if (data === "\x7f" || data === "\b") {
195
+ if (matchesKey(data, "backspace")) {
196
196
  if (state.caret > 0) {
197
197
  const prev = previousGraphemeBoundary(state.rawText, state.caret);
198
198
  state.rawText = state.rawText.slice(0, prev) + state.rawText.slice(state.caret);
@@ -335,8 +335,8 @@ function normalizeSelectKeyData(data: string): string {
335
335
  // The historical prompt card accepted left/right as select aliases; feed the
336
336
  // corresponding vertical key into pi-tui's SelectList so it owns the actual
337
337
  // wrap/clamp/selection update behavior.
338
- if (matchesKey(data, "right") || data === "\x1b[C") return "\x1b[B";
339
- if (matchesKey(data, "left") || data === "\x1b[D") return "\x1b[A";
338
+ if (matchesKey(data, "right")) return "\x1b[B";
339
+ if (matchesKey(data, "left")) return "\x1b[A";
340
340
  return data;
341
341
  }
342
342
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  import type { RunDetail } from "../runs/background/status.js";
21
21
  import type { StageSnapshot } from "../shared/store-types.js";
22
+ import { elapsedRunMs, elapsedStageMs } from "../shared/timing.js";
22
23
  import type { GraphTheme } from "./graph-theme.js";
23
24
  import { renderBandHeader } from "./header.js";
24
25
  import type { BandBadge } from "./header.js";
@@ -201,7 +202,7 @@ function summaryRows(detail: RunDetail, now: number): Array<[string, string | un
201
202
  const startedAgo = formatRelative(now - detail.startedAt);
202
203
  const updatedAt = detail.endedAt ?? detail.startedAt;
203
204
  const updatedAgo = formatRelative(now - updatedAt);
204
- const duration = detail.durationMs ?? (detail.endedAt !== undefined ? detail.endedAt - detail.startedAt : now - detail.startedAt);
205
+ const duration = elapsedRunMs(detail, now);
205
206
 
206
207
  const rows: Array<[string, string | undefined]> = [
207
208
  ["workflow", detail.name],
@@ -276,11 +277,8 @@ function stageLineThemed(stage: StageSnapshot, now: number, theme: GraphTheme, w
276
277
  }
277
278
 
278
279
  function stageDurationString(stage: StageSnapshot, now: number): string | undefined {
279
- if (stage.durationMs !== undefined) return fmtDuration(stage.durationMs);
280
- if (stage.startedAt !== undefined && stage.endedAt === undefined) {
281
- return fmtDuration(now - stage.startedAt);
282
- }
283
- return undefined;
280
+ const elapsed = elapsedStageMs(stage, now);
281
+ return elapsed === undefined ? undefined : fmtDuration(elapsed);
284
282
  }
285
283
 
286
284
  function stageActivityString(stage: StageSnapshot): string | undefined {
@@ -19,11 +19,13 @@
19
19
  */
20
20
 
21
21
  import { keyText } from "@bastani/atomic";
22
- import type { RunSnapshot } from "../shared/store-types.js";
22
+ import { Key } from "@earendil-works/pi-tui";
23
+ import type { RunSnapshot, RunStatus } from "../shared/store-types.js";
24
+ import { elapsedRunMs } from "../shared/timing.js";
23
25
  import type { GraphTheme } from "./graph-theme.js";
24
26
  import { fmtDuration } from "./status-helpers.js";
25
27
  import { hexToAnsi, hexBg, RESET, BOLD } from "./color-utils.js";
26
- import { truncateToWidth, visibleWidth } from "./text-helpers.js";
28
+ import { matchesKey, truncateToWidth, visibleWidth } from "./text-helpers.js";
27
29
 
28
30
  export interface KillConfirmState {
29
31
  /** 0 = Cancel (focused by default), 1 = Kill. */
@@ -117,9 +119,7 @@ export function renderKillConfirm(opts: KillConfirmRenderOpts): string[] {
117
119
  const inner = Math.max(50, width - 2);
118
120
 
119
121
  const idShort = run.id.slice(0, 8);
120
- const elapsed = run.endedAt !== undefined
121
- ? fmtDuration(run.durationMs ?? Math.max(0, run.endedAt - run.startedAt))
122
- : fmtDuration(Math.max(0, now - run.startedAt));
122
+ const elapsed = fmtDuration(elapsedRunMs(run, now));
123
123
  const stagesRunning = run.stages.filter((s) => s.status === "running").length;
124
124
  const stagesTotal = run.stages.length;
125
125
 
@@ -195,15 +195,100 @@ export function handleKillConfirmInput(
195
195
  // Direct shortcuts bypass focus.
196
196
  if (data === "y" || data === "Y") return { kind: "confirm" };
197
197
  if (data === "n" || data === "N") return { kind: "cancel" };
198
- if (data === "\x1b") return { kind: "cancel" };
198
+ if (matchesKey(data, Key.escape)) return { kind: "cancel" };
199
199
 
200
200
  // Tab / arrows toggle focus.
201
- if (data === "\t" || data === "\x1b[C" || data === "\x1b[D" || data === "h" || data === "l") {
201
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || matchesKey(data, Key.left) || data === "h" || data === "l") {
202
202
  state.focusedButton = state.focusedButton === 0 ? 1 : 0;
203
203
  return { kind: "noop" };
204
204
  }
205
- if (data === "\r" || data === "\n") {
205
+ if (matchesKey(data, Key.enter)) {
206
206
  return state.focusedButton === 1 ? { kind: "confirm" } : { kind: "cancel" };
207
207
  }
208
208
  return { kind: "noop" };
209
209
  }
210
+
211
+ export interface WorkflowKilledNoticeRenderOpts {
212
+ width: number;
213
+ theme: GraphTheme;
214
+ run: RunSnapshot;
215
+ previousStatus: RunStatus;
216
+ wasInFlight: boolean;
217
+ }
218
+
219
+ const KILLED_TITLE = "Workflow killed";
220
+
221
+ function renderKilledHeader(width: number, theme: GraphTheme): string {
222
+ const inner = Math.max(4, width - 2);
223
+ const border = hexToAnsi(theme.border);
224
+ const error = hexToAnsi(theme.error);
225
+ const padded = ` ${KILLED_TITLE} `;
226
+ const padLen = Math.max(0, inner - visibleWidth(padded));
227
+ const left = Math.min(2, padLen);
228
+ const right = padLen - left;
229
+ return `${border}╭${"─".repeat(left)}${RESET}${error}${BOLD}${padded}${RESET}${border}${"─".repeat(right)}╮${RESET}`;
230
+ }
231
+
232
+ function renderKilledFooter(width: number, theme: GraphTheme): string {
233
+ const inner = Math.max(4, width - 2);
234
+ const border = hexToAnsi(theme.border);
235
+ return `${border}╰${"─".repeat(inner)}╯${RESET}`;
236
+ }
237
+
238
+ function renderKilledTextRow(
239
+ inner: number,
240
+ theme: GraphTheme,
241
+ content: string,
242
+ ): string {
243
+ return renderTextRow(inner, theme, truncateToWidth(content, inner, "…", true));
244
+ }
245
+
246
+ export function renderWorkflowKilledNotice(
247
+ opts: WorkflowKilledNoticeRenderOpts,
248
+ ): string[] {
249
+ const { width, theme, run, previousStatus, wasInFlight } = opts;
250
+ const inner = Math.max(4, width - 2);
251
+ const idShort = run.id.slice(0, 8);
252
+ const stageCount = run.stages.length;
253
+ const runningStages = run.stages.filter((s) => s.status === "running").length;
254
+
255
+ const error = hexToAnsi(theme.error);
256
+ const success = hexToAnsi(theme.success);
257
+ const text = hexToAnsi(theme.text);
258
+ const dim = hexToAnsi(theme.dim);
259
+ const muted = hexToAnsi(theme.textMuted);
260
+ const panelBg = hexBg(theme.bg);
261
+
262
+ const lines: string[] = [];
263
+ lines.push(renderKilledHeader(width, theme));
264
+ lines.push(renderBlankRow(inner, theme));
265
+
266
+ const identityPrefixW = visibleWidth(" ⊘ ");
267
+ const identitySuffixW = visibleWidth(` · ${idShort}`);
268
+ const nameBudget = Math.max(1, inner - identityPrefixW - identitySuffixW);
269
+ const name = truncateToWidth(run.name, nameBudget, "…");
270
+ const identity =
271
+ ` ${error}⊘${RESET}${panelBg} ${text}${BOLD}${name}${RESET}${panelBg} ${dim}·${RESET}${panelBg} ${muted}${idShort}${RESET}`;
272
+ lines.push(renderKilledTextRow(inner, theme, identity));
273
+
274
+ const statusText = `${previousStatus} → killed`;
275
+ lines.push(renderKilledTextRow(
276
+ inner,
277
+ theme,
278
+ ` ${muted}${statusText}, ${runningStages}/${stageCount} stages were active${RESET}`,
279
+ ));
280
+ lines.push(renderBlankRow(inner, theme));
281
+
282
+ const action = wasInFlight
283
+ ? "Active stage work was aborted."
284
+ : "The completed run was removed.";
285
+ lines.push(renderKilledTextRow(inner, theme, ` ${success}✓${RESET}${panelBg} ${muted}${action}${RESET}`));
286
+ lines.push(renderKilledTextRow(
287
+ inner,
288
+ theme,
289
+ ` ${success}✓${RESET}${panelBg} ${muted}Run removed from live history and status.${RESET}`,
290
+ ));
291
+ lines.push(renderBlankRow(inner, theme));
292
+ lines.push(renderKilledFooter(width, theme));
293
+ return lines;
294
+ }
@@ -19,11 +19,12 @@
19
19
  */
20
20
 
21
21
  import type { RunSnapshot } from "../shared/store-types.js";
22
+ import { elapsedRunMs } from "../shared/timing.js";
22
23
  import type { GraphTheme } from "./graph-theme.js";
23
24
  import { keyText } from "@bastani/atomic";
24
25
  import { fmtDuration, statusIcon, statusColor } from "./status-helpers.js";
25
26
  import { hexToAnsi, hexBg, RESET, BOLD } from "./color-utils.js";
26
- import { truncateToWidth, visibleWidth } from "./text-helpers.js";
27
+ import { matchesKey, truncateToWidth, visibleWidth } from "./text-helpers.js";
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // State + filtering
@@ -206,13 +207,7 @@ function renderFilterRow(inner: number, theme: GraphTheme, state: SessionPickerS
206
207
  }
207
208
 
208
209
  function fmtElapsed(run: RunSnapshot, now: number): string {
209
- if (run.endedAt !== undefined && run.durationMs !== undefined) {
210
- return fmtDuration(run.durationMs);
211
- }
212
- if (run.endedAt !== undefined) {
213
- return fmtDuration(Math.max(0, run.endedAt - run.startedAt));
214
- }
215
- return fmtDuration(Math.max(0, now - run.startedAt));
210
+ return fmtDuration(elapsedRunMs(run, now));
216
211
  }
217
212
 
218
213
  function stageProgress(run: RunSnapshot): string {
@@ -347,15 +342,15 @@ export function handleSessionPickerInput(
347
342
  ): SessionPickerAction {
348
343
  // Filter mode — typed chars feed the query, Enter/Esc exit.
349
344
  if (state.filterFocused) {
350
- if (data === "\x1b" || data === "\x1b\x1b") {
345
+ if (matchesKey(data, "escape") || data === "\x1b\x1b") {
351
346
  state.filterFocused = false;
352
347
  return { kind: "noop" };
353
348
  }
354
- if (data === "\r" || data === "\n") {
349
+ if (matchesKey(data, "enter")) {
355
350
  state.filterFocused = false;
356
351
  return { kind: "noop" };
357
352
  }
358
- if (data === "\x7f" || data === "\b") {
353
+ if (matchesKey(data, "backspace")) {
359
354
  state.query = state.query.slice(0, -1);
360
355
  state.selectedIndex = 0;
361
356
  return { kind: "noop" };
@@ -373,7 +368,7 @@ export function handleSessionPickerInput(
373
368
  state.filterFocused = true;
374
369
  return { kind: "noop" };
375
370
  }
376
- if (data === "\x1b") return { kind: "close" };
371
+ if (matchesKey(data, "escape")) return { kind: "close" };
377
372
  if (data === "q" || data === "Q") return { kind: "close" };
378
373
  if (data === "a" || data === "A") {
379
374
  state.includeAll = !state.includeAll;
@@ -382,17 +377,17 @@ export function handleSessionPickerInput(
382
377
  }
383
378
 
384
379
  // Arrows + j/k.
385
- if (data === "\x1b[B" || data === "j") {
380
+ if (matchesKey(data, "down") || data === "j") {
386
381
  state.selectedIndex = Math.min(state.selectedIndex + 1, Math.max(0, rows.length - 1));
387
382
  return { kind: "noop" };
388
383
  }
389
- if (data === "\x1b[A" || data === "k") {
384
+ if (matchesKey(data, "up") || data === "k") {
390
385
  if (rows.length > 0 && state.selectedIndex === 0) return { kind: "noop" };
391
386
  state.selectedIndex = Math.max(state.selectedIndex - 1, 0);
392
387
  return { kind: "noop" };
393
388
  }
394
389
 
395
- if (data === "\r" || data === "\n") {
390
+ if (matchesKey(data, "enter")) {
396
391
  const row = rows[state.selectedIndex];
397
392
  if (!row) return { kind: "noop" };
398
393
  return { kind: "connect", runId: row.run.id };