@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/README.md +3 -2
- package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +797 -0
- package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +576 -0
- package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +916 -0
- package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +182 -0
- package/docs/superpowers/plans/2026-08-22-stable-list-order.md +51 -0
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +106 -0
- package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +109 -0
- package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +131 -0
- package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +80 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +28 -4
- package/src/commands/agent-board.ts +1 -0
- package/src/core/auto-state.mjs +62 -7
- package/src/core/cwd-stats.mjs +112 -0
- package/src/core/derive.mjs +4 -4
- package/src/core/events.mjs +3 -2
- package/src/core/launch-options.mjs +47 -0
- package/src/core/paths.mjs +2 -0
- package/src/core/pty-attach-jiggle-controller.mjs +112 -0
- package/src/core/pty-attach-jiggle-retry.mjs +29 -10
- package/src/core/pty-attach-render.mjs +21 -0
- package/src/core/rows.mjs +23 -8
- package/src/core/types.mjs +2 -2
- package/src/runtime/service.mjs +15 -1
- package/src/ui/dashboard.ts +64 -15
- package/src/ui/pty-attach.ts +26 -77
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
|
|
152
|
-
*
|
|
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[],
|
|
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],
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
/**
|
|
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(
|
|
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 */
|
package/src/core/types.mjs
CHANGED
|
@@ -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
|
|
65
|
+
needs_input: "Needs answer",
|
|
66
66
|
working: "Running",
|
|
67
67
|
queued: "Queued",
|
|
68
68
|
failed: "Failed",
|
|
69
69
|
completed: "Done",
|
|
70
|
-
idle: "
|
|
70
|
+
idle: "Needs instructions",
|
|
71
71
|
stopped: "Stopped",
|
|
72
72
|
};
|
|
73
73
|
|
package/src/runtime/service.mjs
CHANGED
|
@@ -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)" : "
|
|
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 } });
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
662
|
-
cwdSuggestions:
|
|
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
|
-
|
|
682
|
-
|
|
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: "
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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 ? "
|
|
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"),
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -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.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
private
|
|
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.
|
|
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
|
-
},
|
|
815
|
+
}, JIGGLE_RESTORE_MS);
|
|
808
816
|
this.redrawTimer.unref?.();
|
|
809
817
|
}
|
|
810
818
|
|
|
811
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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,
|