@zhuxixi/pi-agent-board 0.3.0 → 0.4.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.
package/src/core/rows.mjs CHANGED
@@ -36,6 +36,7 @@ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
36
36
  * @property {string|null} followUpPreview
37
37
  * @property {string} steeringState
38
38
  * @property {number} lastActivityAt
39
+ * @property {number} createdAt
39
40
  */
40
41
 
41
42
  /** @param {Row} row @returns {SemanticState} */
@@ -139,6 +140,7 @@ export function rowView(row, now) {
139
140
  followUpPreview: row.state?.followUps?.lastQueuedPreview ?? null,
140
141
  steeringState: row.state?.steering?.status ?? "none",
141
142
  lastActivityAt,
143
+ createdAt: row.meta.createdAt ?? 0,
142
144
  };
143
145
  }
144
146
 
@@ -148,8 +150,9 @@ function oneLine(text) {
148
150
  }
149
151
 
150
152
  /**
151
- * Group rows by semantic state in GROUP_ORDER. Within a group: pinned first, then most
152
- * recently active first. Empty groups are omitted.
153
+ * Group rows by semantic state in GROUP_ORDER. Within a group: pinned first, then
154
+ * creation order (oldest first) — the order is stable and never reshuffles on activity.
155
+ * Empty groups are omitted.
153
156
  * @param {Row[]} rows
154
157
  * @param {number} now
155
158
  * @returns {Array<{ state: SemanticState, label: string, rows: RowView[] }>}
@@ -171,7 +174,7 @@ export function groupRows(rows, now) {
171
174
  * a single folder renders as a flat list (no redundant folder header).
172
175
  * @param {Row[]} rows
173
176
  * @param {number} now
174
- * @returns {Array<{ state: SemanticState, label: string, rowCount: number, showFolders: boolean, folders: Array<{ key: string, name: string, path: string, rows: RowView[], lastActivityAt: number, pinned: boolean }> }>}
177
+ * @returns {Array<{ state: SemanticState, label: string, rowCount: number, showFolders: boolean, folders: Array<{ key: string, name: string, path: string, rows: RowView[], createdAt: number, pinned: boolean }> }>}
175
178
  */
176
179
  export function groupRowsByFolder(rows, now) {
177
180
  const views = rows.map((r) => rowView(r, now));
@@ -184,23 +187,35 @@ export function groupRowsByFolder(rows, now) {
184
187
  const key = view.folderKey;
185
188
  const existing = byFolder.get(key);
186
189
  if (existing) existing.rows.push(view);
187
- else byFolder.set(key, { key, name: view.folderName, path: view.folderPath, rows: [view], lastActivityAt: view.lastActivityAt, pinned: view.pinned });
190
+ else byFolder.set(key, { key, name: view.folderName, path: view.folderPath, rows: [view], createdAt: view.createdAt, pinned: view.pinned });
188
191
  }
189
192
  const folders = [...byFolder.values()].map((folder) => {
190
193
  folder.rows = sortRowViews(folder.rows);
191
- folder.lastActivityAt = Math.max(...folder.rows.map((r) => r.lastActivityAt));
194
+ // Folder creation time = its first-created row, so folders keep a stable,
195
+ // creation-ordered position and don't jump when any row inside gets busy.
196
+ folder.createdAt = Math.min(...folder.rows.map((r) => r.createdAt));
192
197
  folder.pinned = folder.rows.some((r) => r.pinned);
193
198
  return folder;
194
- }).sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastActivityAt - a.lastActivityAt || a.name.localeCompare(b.name));
199
+ }).sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.createdAt - b.createdAt || a.name.localeCompare(b.name));
195
200
  const showFolders = folders.length > 1;
196
201
  groups.push({ state, label: GROUP_LABELS[state], rowCount: inState.length, showFolders, folders });
197
202
  }
198
203
  return groups;
199
204
  }
200
205
 
201
- /** @param {RowView[]} views */
206
+ /**
207
+ * Stable row order: pinned first, then creation order (oldest first), then id.
208
+ * Activity time deliberately does not participate, so rows never reshuffle while
209
+ * sessions are running.
210
+ * @param {RowView[]} views
211
+ */
202
212
  function sortRowViews(views) {
203
- return views.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastActivityAt - a.lastActivityAt);
213
+ return views.sort(
214
+ (a, b) =>
215
+ Number(b.pinned) - Number(a.pinned) ||
216
+ a.createdAt - b.createdAt ||
217
+ a.id.localeCompare(b.id),
218
+ );
204
219
  }
205
220
 
206
221
  /** @param {string} path */
@@ -62,12 +62,12 @@ export const GROUP_ORDER = /** @type {const} */ ([
62
62
 
63
63
  /** Human labels for group headers. @type {Record<SemanticState,string>} */
64
64
  export const GROUP_LABELS = {
65
- needs_input: "Needs input",
65
+ needs_input: "Needs answer",
66
66
  working: "Running",
67
67
  queued: "Queued",
68
68
  failed: "Failed",
69
69
  completed: "Done",
70
- idle: "In Progress",
70
+ idle: "Needs instructions",
71
71
  stopped: "Stopped",
72
72
  };
73
73
 
@@ -36,6 +36,7 @@ import {
36
36
  writeLaunchPrefs,
37
37
  writeMeta,
38
38
  writeState,
39
+ writeStatus,
39
40
  } from "../core/store.mjs";
40
41
  import { diagnoseNodePtyFailure, ensureNodePtySpawnHelperExecutable, nodePtyFallbackMessage, probeNodePtyEnvironment } from "../core/pty-support.mjs";
41
42
  import { normalizeScreenLogMaxBytes, pruneScreenLogs } from "../core/screen-log-gc.mjs";
@@ -270,6 +271,15 @@ export function createService(opts) {
270
271
  state.summary = completionSummary(state);
271
272
  state.lastActivityAt = Date.now();
272
273
  state.updatedAt = Date.now();
274
+ // Also clear autoState in the run status so in-flight model passes
275
+ // (job-runner / state-runner) see the manual completion and skip refinement.
276
+ if (state.currentRunId) {
277
+ const status = readStatus(root, viewId, state.currentRunId);
278
+ if (status) {
279
+ status.autoState = null;
280
+ writeStatus(root, status);
281
+ }
282
+ }
273
283
  writeState(root, state);
274
284
  return { ok: true };
275
285
  }
@@ -753,6 +763,10 @@ export function createService(opts) {
753
763
  return { ok: true, viewId: meta.id, reused: false };
754
764
  },
755
765
 
766
+ getRoot() {
767
+ return root;
768
+ },
769
+
756
770
  getLaunchPrefs() {
757
771
  return readLaunchPrefs(root);
758
772
  },
@@ -958,7 +972,7 @@ export function createService(opts) {
958
972
  s.question = null;
959
973
  s.pendingQuestions = [];
960
974
  s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
961
- s.summary = failed ? "Failed (PTY host exited)" : "In Progress";
975
+ s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
962
976
  s.updatedAt = now;
963
977
  writeState(root, s);
964
978
  appendDiagnostic(root, row.meta.id, { source: "service", level: failed ? "error" : "info", code: "host_reconciled", message: failed ? "PTY host exited before final event" : "PTY host finalized without final event", details: { hostState: row.host.state, exitCode: row.host.exitCode } });
@@ -17,11 +17,14 @@ import {
17
17
  canonicalModelRef,
18
18
  clampThinkingLevel,
19
19
  listDirectorySuggestions,
20
+ existingCwdCandidates,
21
+ nextCwdPickerState,
20
22
  resolveDirectoryValue,
21
23
  resolveLaunchContext,
22
24
  supportedThinkingLevels,
23
25
  } from "../core/launch-options.mjs";
24
26
  import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
27
+ import { ensureCwdStatsSeeded, rankedCwdCandidates, recordCwdLaunch } from "../core/cwd-stats.mjs";
25
28
  import { filterRows, groupRowsByFolder, rowState, stateGlyph } from "../core/rows.mjs";
26
29
  import { loadSessionView } from "../core/session-view.mjs";
27
30
  import { GROUP_LABELS } from "../core/types.mjs";
@@ -81,6 +84,11 @@ interface InputNotice {
81
84
  expiresAt: number;
82
85
  }
83
86
 
87
+ interface CwdCandidate {
88
+ path: string;
89
+ count: number;
90
+ }
91
+
84
92
  interface LaunchState {
85
93
  fieldIndex: number;
86
94
  picker: LaunchPicker;
@@ -89,6 +97,8 @@ interface LaunchState {
89
97
  cwdQuery: string;
90
98
  cwdSuggestions: string[];
91
99
  cwdSuggestionIndex: number;
100
+ cwdRanked: CwdCandidate[];
101
+ cwdPickerMode: "favorites" | "browse";
92
102
  choices: LaunchChoice[];
93
103
  scopeSource: "scoped" | "all";
94
104
  model: LaunchModel | null;
@@ -102,6 +112,7 @@ interface LaunchState {
102
112
 
103
113
  export interface DashboardDeps {
104
114
  service: Service;
115
+ root: string;
105
116
  defaultCwd: string;
106
117
  availableModels: LaunchModel[];
107
118
  currentModel: LaunchModel | null;
@@ -562,11 +573,24 @@ export class DashboardComponent implements Component {
562
573
  launch.picker = null;
563
574
  return;
564
575
  }
576
+ if (launch.picker === "cwd" && matchesKey(data, Key.tab)) {
577
+ if (launch.cwdPickerMode === "favorites" && launch.cwdSuggestions.length > 0) {
578
+ const completed = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? launch.cwdSuggestions[0];
579
+ launch.cwdQuery = completed;
580
+ const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
581
+ launch.cwdPickerMode = state.mode;
582
+ launch.cwdSuggestions = state.suggestions;
583
+ launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completed));
584
+ }
585
+ return;
586
+ }
565
587
  if (launch.picker === "cwd") {
566
588
  const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
567
589
  if (next !== null) {
568
590
  launch.cwdQuery = next;
569
- launch.cwdSuggestions = listDirectorySuggestions(next, launch.cwd);
591
+ const state = nextCwdPickerState(next, launch.cwdRanked, launch.cwd);
592
+ launch.cwdPickerMode = state.mode;
593
+ launch.cwdSuggestions = state.suggestions;
570
594
  launch.cwdSuggestionIndex = 0;
571
595
  }
572
596
  return;
@@ -611,6 +635,11 @@ export class DashboardComponent implements Component {
611
635
  const prompt = this.input.trim();
612
636
  if (!prompt) return this.toListMode();
613
637
  const defaults = this.launchDefaults();
638
+ try {
639
+ ensureCwdStatsSeeded(this.deps.root);
640
+ } catch {
641
+ /* best effort: favorites degrade to browse mode */
642
+ }
614
643
  this.launch = this.buildLaunchState(defaults.cwd, defaults.model, defaults.thinking);
615
644
  this.mode = "launch";
616
645
  this.inputNotice = null;
@@ -647,7 +676,6 @@ export class DashboardComponent implements Component {
647
676
  thinkingOptions: ThinkingLevel[];
648
677
  scopeSource: "scoped" | "all";
649
678
  };
650
- const initialBrowserCwd = homeLaunchRoot(cwd);
651
679
  const modelFiltered = filterLaunchChoices(context.choices, "");
652
680
  const modelIndex = Math.max(0, modelFiltered.findIndex((choice) => sameLaunchModel(choice.model, context.selectedModel)));
653
681
  const thinkingOptions = supportedThinkingLevels(context.selectedModel) as ThinkingLevel[];
@@ -658,9 +686,11 @@ export class DashboardComponent implements Component {
658
686
  picker: null,
659
687
  action: "background",
660
688
  cwd,
661
- cwdQuery: initialBrowserCwd,
662
- cwdSuggestions: listDirectorySuggestions(initialBrowserCwd, cwd),
689
+ cwdQuery: "",
690
+ cwdSuggestions: [],
663
691
  cwdSuggestionIndex: 0,
692
+ cwdRanked: [],
693
+ cwdPickerMode: "browse",
664
694
  choices: context.choices,
665
695
  scopeSource: context.scopeSource,
666
696
  model: context.selectedModel,
@@ -678,8 +708,16 @@ export class DashboardComponent implements Component {
678
708
  if (!launch) return;
679
709
  launch.picker = picker;
680
710
  if (picker === "cwd") {
681
- launch.cwdQuery = seed ?? launch.cwdQuery ?? homeLaunchRoot(launch.cwd);
682
- launch.cwdSuggestions = listDirectorySuggestions(launch.cwdQuery, launch.cwd);
711
+ try {
712
+ ensureCwdStatsSeeded(this.deps.root);
713
+ launch.cwdRanked = existingCwdCandidates(rankedCwdCandidates(this.deps.root, 8));
714
+ } catch {
715
+ launch.cwdRanked = [];
716
+ }
717
+ launch.cwdQuery = seed ?? "";
718
+ const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
719
+ launch.cwdPickerMode = state.mode;
720
+ launch.cwdSuggestions = state.suggestions;
683
721
  launch.cwdSuggestionIndex = 0;
684
722
  return;
685
723
  }
@@ -794,6 +832,11 @@ export class DashboardComponent implements Component {
794
832
  if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
795
833
  else {
796
834
  this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
835
+ try {
836
+ recordCwdLaunch(this.deps.root, launchCwd);
837
+ } catch {
838
+ /* best effort: stats must never block dispatch */
839
+ }
797
840
  try {
798
841
  this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
799
842
  } catch {
@@ -923,7 +966,7 @@ export class DashboardComponent implements Component {
923
966
  currentRunId: null,
924
967
  semanticState: "idle",
925
968
  processState: "exited",
926
- summary: "In Progress",
969
+ summary: "Needs instructions",
927
970
  lastActivityAt: Date.now(),
928
971
  updatedAt: Date.now(),
929
972
  needsInput: false,
@@ -1474,9 +1517,19 @@ export class DashboardComponent implements Component {
1474
1517
  lines.push("");
1475
1518
  if (launch.picker === "cwd") {
1476
1519
  lines.push(t.fg("warning", `cwd› ${singleLineInput(launch.cwdQuery)}${cursor()}`));
1477
- lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
1478
- lines.push("");
1479
- lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
1520
+ if (launch.cwdPickerMode === "favorites") {
1521
+ const counts = new Map(launch.cwdRanked.map((entry) => [entry.path, entry.count]));
1522
+ lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => {
1523
+ const count = counts.get(value) ?? 0;
1524
+ return `${displayPath(value)}${count > 0 ? ` ${count}×` : ""}`;
1525
+ }));
1526
+ lines.push("");
1527
+ lines.push(t.fg("dim", "常用目录 · type to search · tab complete · enter choose · esc back"));
1528
+ } else {
1529
+ lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
1530
+ lines.push("");
1531
+ lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
1532
+ }
1480
1533
  } else if (launch.picker === "model") {
1481
1534
  lines.push(t.fg("warning", `model› ${singleLineInput(launch.modelQuery)}${cursor()}`));
1482
1535
  lines.push(...this.renderLaunchSuggestions(inner, launch.modelFiltered, launch.modelIndex, (choice) => `${formatLaunchModel(choice.model)}${choice.thinkingLevel ? ` · ${choice.thinkingLevel}` : ""}`));
@@ -1662,10 +1715,6 @@ function stripBracketedPaste(data: string): string {
1662
1715
  return data.replace(/\x1b\[200~|\x1b\[201~/g, "");
1663
1716
  }
1664
1717
 
1665
- function homeLaunchRoot(fallback: string): string {
1666
- return process.env.HOME || process.env.USERPROFILE ? "~" : fallback;
1667
- }
1668
-
1669
1718
  function renderCenteredBox(lines: string[], width: number, height: number, theme: ThemeLike): string[] {
1670
1719
  const innerWidth = Math.max(20, Math.min(width - 6, Math.max(...lines.map((line) => visibleWidth(line)), 20)));
1671
1720
  const boxWidth = Math.min(width, innerWidth + 4);
@@ -1754,7 +1803,7 @@ function headerTextRows(
1754
1803
  function headerStageSummary(theme: ThemeLike, counts: HeaderCounts, filterQuery: string, compact: boolean): string {
1755
1804
  const joiner = theme.fg("dim", " · ");
1756
1805
  const parts = [
1757
- headerStagePart(theme, "needs_input", counts.needs, compact ? "awaiting" : "awaiting input"),
1806
+ headerStagePart(theme, "needs_input", counts.needs, compact ? "answer" : "needs answer"),
1758
1807
  headerStagePart(theme, "working", counts.working, "running"),
1759
1808
  headerUnreadPart(theme, counts.unread),
1760
1809
  headerStagePart(theme, "completed", counts.completed, "done"),
@@ -7,14 +7,8 @@ import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui"
7
7
  import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
8
  import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
9
9
  import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
10
- import { createAttachOutputRenderScheduler, nextAttachRender, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
11
- import {
12
- createJiggleRetryState,
13
- feedOutput as feedJiggleRetry,
14
- nextRetryDelay,
15
- advanceRetry,
16
- stopRetry,
17
- } from "../core/pty-attach-jiggle-retry.mjs";
10
+ import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
11
+ import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
18
12
  import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
19
13
 
20
14
  export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
@@ -53,6 +47,11 @@ const OSC52_MAX_BYTES = 1_000_000;
53
47
  const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096;
54
48
  const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000;
55
49
  const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096;
50
+ /** Delay between the shrink and restore halves of a resize jiggle. 200ms keeps
51
+ * the two SIGWINCHs apart well beyond pi-tui's 16ms render throttle, so a busy
52
+ * (booting) child processes them as two separate renders instead of coalescing
53
+ * the pair into a net-zero size change. */
54
+ const JIGGLE_RESTORE_MS = 200;
56
55
  const KITTY_IMAGE_PREFIX = "\x1b_G";
57
56
  const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
58
57
 
@@ -122,10 +121,19 @@ export class PtyAttachComponent implements Component {
122
121
  private mouseRefreshTimers: Array<ReturnType<typeof setTimeout>> = [];
123
122
  // Jiggle retry chain: re-send resize jiggle until we see a full-clear sequence
124
123
  // in the PTY output, proving the child pi-tui did a fullRender and the replay
125
- // garbage has been flushed. Replaces the one-shot forceChildRedrawAfterLiveOutput.
126
- private jiggleRetryState = createJiggleRetryState();
127
- private jiggleRetryTimer: ReturnType<typeof setTimeout> | null = null;
128
- private clearCarry = "";
124
+ // garbage has been flushed. The controller re-arms once on the child TUI's
125
+ // first frame so cold-start attaches get a fresh budget exactly when the
126
+ // child can finally observe a resize (issue #10).
127
+ private readonly jiggleRetry = createJiggleRetryController({
128
+ sendJiggle: () => this.forceChildRedraw(),
129
+ shouldFire: () => !this.closed && this.connected,
130
+ setTimeoutFn: (fn, ms) => {
131
+ const t = setTimeout(fn, ms);
132
+ t.unref?.();
133
+ return t;
134
+ },
135
+ clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
136
+ });
129
137
  private osc52Carry = "";
130
138
  private passthroughCarry = "";
131
139
  private readonly connectStartedAt = Date.now();
@@ -318,7 +326,7 @@ export class PtyAttachComponent implements Component {
318
326
  this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
319
327
  this.sendResize();
320
328
  this.forceChildRedraw();
321
- this.startJiggleRetry();
329
+ this.jiggleRetry.start();
322
330
  this.enableMouseScroll();
323
331
  this.scheduleRender();
324
332
  this.startAttachSettle();
@@ -804,56 +812,13 @@ export class PtyAttachComponent implements Component {
804
812
  this.sendResize(cols, rows);
805
813
  this.deferAttachSettle();
806
814
  }
807
- }, 40);
815
+ }, JIGGLE_RESTORE_MS);
808
816
  this.redrawTimer.unref?.();
809
817
  }
810
818
 
811
- /** Start the jiggle retry chain after initial jiggle. */
812
- private startJiggleRetry(): void {
813
- if (this.jiggleRetryTimer) {
814
- clearTimeout(this.jiggleRetryTimer);
815
- this.jiggleRetryTimer = null;
816
- }
817
- this.jiggleRetryState = createJiggleRetryState();
818
- this.clearCarry = "";
819
- this.scheduleNextJiggleRetry();
820
- }
821
-
822
- /** Check socket output for full-clear sequence; cancel retry if found. */
819
+ /** Feed socket output into the jiggle retry controller (clear/frame detection). */
823
820
  private checkClearSequence(data: string): void {
824
- if (this.jiggleRetryState.stopped) return;
825
- const result = feedJiggleRetry(this.jiggleRetryState, data, this.clearCarry);
826
- this.jiggleRetryState = result.state;
827
- this.clearCarry = result.carry;
828
- if (result.clearFound) this.cancelJiggleRetry();
829
- }
830
-
831
- /** Schedule the next jiggle retry with backoff. */
832
- private scheduleNextJiggleRetry(): void {
833
- const delay = nextRetryDelay(this.jiggleRetryState);
834
- if (delay === null) {
835
- // Chain exhausted (max retries) — mark stopped so checkClearSequence
836
- // short-circuits on subsequent output chunks.
837
- this.jiggleRetryState = stopRetry(this.jiggleRetryState);
838
- return;
839
- }
840
- this.jiggleRetryTimer = setTimeout(() => {
841
- this.jiggleRetryTimer = null;
842
- if (this.closed || !this.connected) return;
843
- this.forceChildRedraw();
844
- this.jiggleRetryState = advanceRetry(this.jiggleRetryState);
845
- this.scheduleNextJiggleRetry();
846
- }, delay);
847
- this.jiggleRetryTimer.unref?.();
848
- }
849
-
850
- /** Cancel the jiggle retry chain. */
851
- private cancelJiggleRetry(): void {
852
- if (this.jiggleRetryTimer) {
853
- clearTimeout(this.jiggleRetryTimer);
854
- this.jiggleRetryTimer = null;
855
- }
856
- this.jiggleRetryState = stopRetry(this.jiggleRetryState);
821
+ this.jiggleRetry.feed(data);
857
822
  }
858
823
 
859
824
  private tryScrollBy(linesUp: number): boolean {
@@ -1009,7 +974,7 @@ export class PtyAttachComponent implements Component {
1009
974
  // The PTY cursor: xterm buffer cursorY is relative to baseY (the viewport top of
1010
975
  // the child terminal); cursorX may equal cols (one past the last cell). Only render
1011
976
  // it when it lands inside the projected viewport.
1012
- const cursor = cursorInViewport(buf, start, height);
977
+ const cursor = projectPtyCursor(buf, start, height);
1013
978
  for (let i = start; i < end; i++) {
1014
979
  out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor));
1015
980
  }
@@ -1019,7 +984,7 @@ export class PtyAttachComponent implements Component {
1019
984
 
1020
985
  private close(): void {
1021
986
  this.closed = true;
1022
- this.cancelJiggleRetry();
987
+ this.jiggleRetry.stop();
1023
988
  this.disableMouseScroll();
1024
989
  this.clearMouseRefreshTimers();
1025
990
  this.clearPendingClick();
@@ -1177,22 +1142,6 @@ function lineToAnsi(
1177
1142
  return out + "\x1b[0m";
1178
1143
  }
1179
1144
 
1180
- /**
1181
- * Resolve the PTY cursor into viewport coordinates. xterm's buffer cursorY is relative
1182
- * to baseY (the child terminal's viewport top); returns null when the cursor is outside
1183
- * the projected window (e.g. the user scrolled up into history).
1184
- */
1185
- function cursorInViewport(
1186
- buf: XtermLike["buffer"]["active"],
1187
- start: number,
1188
- height: number,
1189
- ): { row: number; col: number } | null {
1190
- if (typeof buf.cursorX !== "number" || typeof buf.cursorY !== "number" || buf.cursorX < 0 || buf.cursorY < 0) return null;
1191
- const row = buf.baseY + buf.cursorY - start;
1192
- if (row < 0 || row >= height) return null;
1193
- return { row, col: buf.cursorX };
1194
- }
1195
-
1196
1145
  function attrKey(cell: BufferCellLike, selected = false, isCursor = false): string {
1197
1146
  return [
1198
1147
  selected ? 1 : 0,