@jmoyers/harness 0.1.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/LICENSE +21 -0
- package/README.md +145 -0
- package/native/ptyd/Cargo.lock +16 -0
- package/native/ptyd/Cargo.toml +7 -0
- package/native/ptyd/src/main.rs +257 -0
- package/package.json +90 -0
- package/scripts/build-ptyd.sh +73 -0
- package/scripts/control-plane-daemon.ts +277 -0
- package/scripts/cursor-hook-relay.ts +82 -0
- package/scripts/harness-animate.ts +469 -0
- package/scripts/harness-bin.js +77 -0
- package/scripts/harness-core.ts +1 -0
- package/scripts/harness-inspector.ts +439 -0
- package/scripts/harness.ts +2493 -0
- package/src/adapters/agent-session-state.ts +390 -0
- package/src/cli/gateway-record.ts +173 -0
- package/src/codex/live-session.ts +872 -0
- package/src/config/config-core.ts +1359 -0
- package/src/config/secrets-core.ts +170 -0
- package/src/control-plane/agent-realtime-api.ts +2441 -0
- package/src/control-plane/codex-session-stream.ts +392 -0
- package/src/control-plane/codex-telemetry.ts +1325 -0
- package/src/control-plane/lifecycle-hooks.ts +706 -0
- package/src/control-plane/session-summary.ts +380 -0
- package/src/control-plane/status/agent-status-reducer.ts +21 -0
- package/src/control-plane/status/reducer-base.ts +170 -0
- package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
- package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
- package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
- package/src/control-plane/status/session-status-engine.ts +76 -0
- package/src/control-plane/stream-client.ts +396 -0
- package/src/control-plane/stream-command-parser.ts +1673 -0
- package/src/control-plane/stream-protocol.ts +1808 -0
- package/src/control-plane/stream-server-background.ts +486 -0
- package/src/control-plane/stream-server-command.ts +2557 -0
- package/src/control-plane/stream-server-connection.ts +234 -0
- package/src/control-plane/stream-server-observed-filter.ts +112 -0
- package/src/control-plane/stream-server-session-runtime.ts +566 -0
- package/src/control-plane/stream-server-state-store.ts +15 -0
- package/src/control-plane/stream-server.ts +3192 -0
- package/src/cursor/managed-hooks.ts +282 -0
- package/src/domain/conversations.ts +414 -0
- package/src/domain/directories.ts +78 -0
- package/src/domain/repositories.ts +123 -0
- package/src/domain/tasks.ts +148 -0
- package/src/domain/workspace.ts +156 -0
- package/src/events/normalized-events.ts +124 -0
- package/src/mux/ansi-integrity.ts +103 -0
- package/src/mux/control-plane-op-queue.ts +212 -0
- package/src/mux/conversation-rail.ts +339 -0
- package/src/mux/double-click.ts +78 -0
- package/src/mux/dual-pane-core.ts +435 -0
- package/src/mux/harness-core-ui.ts +817 -0
- package/src/mux/input-shortcuts.ts +667 -0
- package/src/mux/live-mux/actions-conversation.ts +344 -0
- package/src/mux/live-mux/actions-repository.ts +246 -0
- package/src/mux/live-mux/actions-task.ts +115 -0
- package/src/mux/live-mux/args.ts +142 -0
- package/src/mux/live-mux/command-menu.ts +298 -0
- package/src/mux/live-mux/control-plane-records.ts +546 -0
- package/src/mux/live-mux/conversation-state.ts +188 -0
- package/src/mux/live-mux/directory-resolution.ts +34 -0
- package/src/mux/live-mux/event-mapping.ts +96 -0
- package/src/mux/live-mux/gateway-profiler.ts +152 -0
- package/src/mux/live-mux/gateway-render-trace.ts +177 -0
- package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
- package/src/mux/live-mux/git-parsing.ts +131 -0
- package/src/mux/live-mux/git-snapshot.ts +263 -0
- package/src/mux/live-mux/git-state.ts +136 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
- package/src/mux/live-mux/home-pane-actions.ts +58 -0
- package/src/mux/live-mux/home-pane-drop.ts +44 -0
- package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
- package/src/mux/live-mux/home-pane-pointer.ts +96 -0
- package/src/mux/live-mux/input-forwarding.ts +112 -0
- package/src/mux/live-mux/layout.ts +30 -0
- package/src/mux/live-mux/left-nav-activation.ts +103 -0
- package/src/mux/live-mux/left-nav.ts +85 -0
- package/src/mux/live-mux/left-rail-actions.ts +118 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
- package/src/mux/live-mux/left-rail-pointer.ts +74 -0
- package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
- package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
- package/src/mux/live-mux/modal-input-reducers.ts +94 -0
- package/src/mux/live-mux/modal-overlays.ts +287 -0
- package/src/mux/live-mux/modal-pointer.ts +70 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
- package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
- package/src/mux/live-mux/observed-stream.ts +87 -0
- package/src/mux/live-mux/palette-parsing.ts +128 -0
- package/src/mux/live-mux/pointer-routing.ts +108 -0
- package/src/mux/live-mux/process-usage.ts +53 -0
- package/src/mux/live-mux/project-pane-pointer.ts +44 -0
- package/src/mux/live-mux/rail-layout.ts +244 -0
- package/src/mux/live-mux/render-trace-analysis.ts +213 -0
- package/src/mux/live-mux/render-trace-state.ts +84 -0
- package/src/mux/live-mux/repository-folding.ts +207 -0
- package/src/mux/live-mux/runtime-shutdown.ts +51 -0
- package/src/mux/live-mux/selection.ts +411 -0
- package/src/mux/live-mux/startup-utils.ts +187 -0
- package/src/mux/live-mux/status-timeline-state.ts +82 -0
- package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
- package/src/mux/live-mux/terminal-palette.ts +79 -0
- package/src/mux/new-thread-prompt.ts +165 -0
- package/src/mux/project-tree.ts +295 -0
- package/src/mux/render-frame.ts +113 -0
- package/src/mux/runtime-wiring.ts +185 -0
- package/src/mux/selector-index.ts +160 -0
- package/src/mux/startup-sequencer.ts +238 -0
- package/src/mux/task-composer.ts +289 -0
- package/src/mux/task-focused-pane.ts +417 -0
- package/src/mux/task-screen-keybindings.ts +539 -0
- package/src/mux/terminal-input-modes.ts +35 -0
- package/src/mux/workspace-path.ts +55 -0
- package/src/mux/workspace-rail-model.ts +701 -0
- package/src/mux/workspace-rail.ts +247 -0
- package/src/perf/perf-core.ts +307 -0
- package/src/pty/pty_host.ts +217 -0
- package/src/pty/session-broker.ts +158 -0
- package/src/recording/terminal-recording.ts +383 -0
- package/src/services/control-plane.ts +567 -0
- package/src/services/conversation-lifecycle.ts +176 -0
- package/src/services/conversation-startup-hydration.ts +47 -0
- package/src/services/directory-hydration.ts +49 -0
- package/src/services/event-persistence.ts +104 -0
- package/src/services/mux-ui-state-persistence.ts +82 -0
- package/src/services/output-load-sampler.ts +231 -0
- package/src/services/process-usage-refresh.ts +88 -0
- package/src/services/recording.ts +75 -0
- package/src/services/render-trace-recorder.ts +177 -0
- package/src/services/runtime-control-actions.ts +123 -0
- package/src/services/runtime-control-plane-ops.ts +131 -0
- package/src/services/runtime-conversation-actions.ts +113 -0
- package/src/services/runtime-conversation-activation.ts +78 -0
- package/src/services/runtime-conversation-starter.ts +171 -0
- package/src/services/runtime-conversation-title-edit.ts +149 -0
- package/src/services/runtime-directory-actions.ts +164 -0
- package/src/services/runtime-envelope-handler.ts +198 -0
- package/src/services/runtime-git-state.ts +92 -0
- package/src/services/runtime-input-pipeline.ts +50 -0
- package/src/services/runtime-input-router.ts +202 -0
- package/src/services/runtime-layout-resize.ts +236 -0
- package/src/services/runtime-left-rail-render.ts +159 -0
- package/src/services/runtime-main-pane-input.ts +230 -0
- package/src/services/runtime-modal-input.ts +119 -0
- package/src/services/runtime-navigation-input.ts +207 -0
- package/src/services/runtime-process-wiring.ts +68 -0
- package/src/services/runtime-rail-input.ts +287 -0
- package/src/services/runtime-render-flush.ts +146 -0
- package/src/services/runtime-render-lifecycle.ts +104 -0
- package/src/services/runtime-render-orchestrator.ts +108 -0
- package/src/services/runtime-render-pipeline.ts +167 -0
- package/src/services/runtime-render-state.ts +72 -0
- package/src/services/runtime-repository-actions.ts +197 -0
- package/src/services/runtime-right-pane-render.ts +132 -0
- package/src/services/runtime-shutdown.ts +79 -0
- package/src/services/runtime-stream-subscriptions.ts +56 -0
- package/src/services/runtime-task-composer-persistence.ts +139 -0
- package/src/services/runtime-task-editor-actions.ts +83 -0
- package/src/services/runtime-task-pane-actions.ts +198 -0
- package/src/services/runtime-task-pane-shortcuts.ts +189 -0
- package/src/services/runtime-task-pane.ts +62 -0
- package/src/services/runtime-workspace-actions.ts +153 -0
- package/src/services/runtime-workspace-observed-events.ts +190 -0
- package/src/services/session-projection-instrumentation.ts +190 -0
- package/src/services/startup-background-probe.ts +91 -0
- package/src/services/startup-background-resume.ts +65 -0
- package/src/services/startup-orchestrator.ts +166 -0
- package/src/services/startup-output-tracker.ts +54 -0
- package/src/services/startup-paint-tracker.ts +115 -0
- package/src/services/startup-persisted-conversation-queue.ts +45 -0
- package/src/services/startup-settled-gate.ts +67 -0
- package/src/services/startup-shutdown.ts +53 -0
- package/src/services/startup-span-tracker.ts +77 -0
- package/src/services/startup-state-hydration.ts +94 -0
- package/src/services/startup-visibility.ts +35 -0
- package/src/services/status-timeline-recorder.ts +144 -0
- package/src/services/task-pane-selection-actions.ts +153 -0
- package/src/services/task-planning-hydration.ts +58 -0
- package/src/services/task-planning-observed-events.ts +89 -0
- package/src/services/workspace-observed-events.ts +113 -0
- package/src/store/control-plane-store-normalize.ts +760 -0
- package/src/store/control-plane-store-types.ts +224 -0
- package/src/store/control-plane-store.ts +2951 -0
- package/src/store/event-store.ts +253 -0
- package/src/store/sqlite.ts +81 -0
- package/src/terminal/compat-matrix.ts +345 -0
- package/src/terminal/differential-checkpoints.ts +132 -0
- package/src/terminal/parity-suite.ts +441 -0
- package/src/terminal/snapshot-oracle.ts +1840 -0
- package/src/ui/conversation-input-forwarder.ts +114 -0
- package/src/ui/conversation-selection-input.ts +103 -0
- package/src/ui/debug-footer-notice.ts +39 -0
- package/src/ui/global-shortcut-input.ts +126 -0
- package/src/ui/input-preflight.ts +68 -0
- package/src/ui/input-token-router.ts +312 -0
- package/src/ui/input.ts +238 -0
- package/src/ui/kit.ts +509 -0
- package/src/ui/left-nav-input.ts +80 -0
- package/src/ui/left-rail-pointer-input.ts +148 -0
- package/src/ui/main-pane-pointer-input.ts +150 -0
- package/src/ui/modals/manager.ts +192 -0
- package/src/ui/mux-theme.ts +529 -0
- package/src/ui/panes/conversation.ts +19 -0
- package/src/ui/panes/home-gridfire.ts +302 -0
- package/src/ui/panes/home.ts +109 -0
- package/src/ui/panes/left-rail.ts +12 -0
- package/src/ui/panes/project.ts +44 -0
- package/src/ui/pointer-routing-input.ts +158 -0
- package/src/ui/repository-fold-input.ts +91 -0
- package/src/ui/screen.ts +210 -0
- package/src/ui/surface.ts +224 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
type ThreadAgentType = 'codex' | 'claude' | 'cursor' | 'terminal' | 'critique';
|
|
2
|
+
|
|
3
|
+
const EMPTY_NEW_THREAD_PROMPT_INPUT = new Uint8Array();
|
|
4
|
+
|
|
5
|
+
interface NewThreadPromptState {
|
|
6
|
+
readonly directoryId: string;
|
|
7
|
+
readonly selectedAgentType: ThreadAgentType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface NewThreadPromptInputResult {
|
|
11
|
+
readonly nextState: NewThreadPromptState;
|
|
12
|
+
readonly submit: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createNewThreadPromptState(directoryId: string): NewThreadPromptState {
|
|
16
|
+
return {
|
|
17
|
+
directoryId,
|
|
18
|
+
selectedAgentType: 'codex',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeThreadAgentType(value: string): ThreadAgentType {
|
|
23
|
+
if (value === 'terminal' || value === 'claude' || value === 'cursor' || value === 'critique') {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
return 'codex';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function nextThreadAgentType(value: ThreadAgentType): ThreadAgentType {
|
|
30
|
+
if (value === 'codex') {
|
|
31
|
+
return 'claude';
|
|
32
|
+
}
|
|
33
|
+
if (value === 'claude') {
|
|
34
|
+
return 'cursor';
|
|
35
|
+
}
|
|
36
|
+
if (value === 'cursor') {
|
|
37
|
+
return 'terminal';
|
|
38
|
+
}
|
|
39
|
+
if (value === 'terminal') {
|
|
40
|
+
return 'critique';
|
|
41
|
+
}
|
|
42
|
+
return 'codex';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseEncodedPromptKeyCode(input: Uint8Array): number | null {
|
|
46
|
+
if (!input.includes(0x1b)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const text = Buffer.from(input).toString('utf8');
|
|
50
|
+
if (text.startsWith('\u001b[') && text.endsWith('u')) {
|
|
51
|
+
const kittyPayload = text.slice(2, -1);
|
|
52
|
+
const kittyMatch = kittyPayload.match(/^(\d+)(?::\d+)?(?:;\d+(?::\d+)?)?$/u);
|
|
53
|
+
if (kittyMatch !== null) {
|
|
54
|
+
return Number.parseInt(kittyMatch[1]!, 10);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (text.startsWith('\u001b[') && text.endsWith('~')) {
|
|
58
|
+
const modifyPayload = text.slice(2, -1);
|
|
59
|
+
const modifyOtherKeysMatch = modifyPayload.match(/^27;\d+;(\d+)$/u);
|
|
60
|
+
if (modifyOtherKeysMatch !== null) {
|
|
61
|
+
return Number.parseInt(modifyOtherKeysMatch[1]!, 10);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizePromptInputBytes(input: Uint8Array): Uint8Array {
|
|
68
|
+
const keyCode = parseEncodedPromptKeyCode(input);
|
|
69
|
+
if (keyCode === null) {
|
|
70
|
+
return input.includes(0x1b) ? EMPTY_NEW_THREAD_PROMPT_INPUT : input;
|
|
71
|
+
}
|
|
72
|
+
if (keyCode < 0 || keyCode > 0xff) {
|
|
73
|
+
return EMPTY_NEW_THREAD_PROMPT_INPUT;
|
|
74
|
+
}
|
|
75
|
+
return Uint8Array.from([keyCode]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function reduceNewThreadPromptInput(
|
|
79
|
+
state: NewThreadPromptState,
|
|
80
|
+
input: Uint8Array,
|
|
81
|
+
): NewThreadPromptInputResult {
|
|
82
|
+
const normalizedInput = normalizePromptInputBytes(input);
|
|
83
|
+
let selectedAgentType = state.selectedAgentType;
|
|
84
|
+
let submit = false;
|
|
85
|
+
for (const byte of normalizedInput) {
|
|
86
|
+
if (byte === 0x0d || byte === 0x0a) {
|
|
87
|
+
submit = true;
|
|
88
|
+
break;
|
|
89
|
+
} else if (byte === 0x09 || byte === 0x20) {
|
|
90
|
+
selectedAgentType = nextThreadAgentType(selectedAgentType);
|
|
91
|
+
} else if (byte === 0x31 || byte === 0x63 || byte === 0x43) {
|
|
92
|
+
selectedAgentType = 'codex';
|
|
93
|
+
} else if (byte === 0x32 || byte === 0x61 || byte === 0x41) {
|
|
94
|
+
selectedAgentType = 'claude';
|
|
95
|
+
} else if (byte === 0x33 || byte === 0x75 || byte === 0x55) {
|
|
96
|
+
selectedAgentType = 'cursor';
|
|
97
|
+
} else if (byte === 0x34 || byte === 0x74 || byte === 0x54) {
|
|
98
|
+
selectedAgentType = 'terminal';
|
|
99
|
+
} else if (byte === 0x35 || byte === 0x72 || byte === 0x52) {
|
|
100
|
+
selectedAgentType = 'critique';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
nextState: {
|
|
105
|
+
directoryId: state.directoryId,
|
|
106
|
+
selectedAgentType,
|
|
107
|
+
},
|
|
108
|
+
submit,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveNewThreadPromptAgentByRow(
|
|
113
|
+
overlayTopRowZeroBased: number,
|
|
114
|
+
rowOneBased: number,
|
|
115
|
+
): ThreadAgentType | null {
|
|
116
|
+
const codexRow = overlayTopRowZeroBased + 4;
|
|
117
|
+
const claudeRow = overlayTopRowZeroBased + 5;
|
|
118
|
+
const cursorRow = overlayTopRowZeroBased + 6;
|
|
119
|
+
const terminalRow = overlayTopRowZeroBased + 7;
|
|
120
|
+
const critiqueRow = overlayTopRowZeroBased + 8;
|
|
121
|
+
if (rowOneBased - 1 === codexRow) {
|
|
122
|
+
return 'codex';
|
|
123
|
+
}
|
|
124
|
+
if (rowOneBased - 1 === claudeRow) {
|
|
125
|
+
return 'claude';
|
|
126
|
+
}
|
|
127
|
+
if (rowOneBased - 1 === cursorRow) {
|
|
128
|
+
return 'cursor';
|
|
129
|
+
}
|
|
130
|
+
if (rowOneBased - 1 === terminalRow) {
|
|
131
|
+
return 'terminal';
|
|
132
|
+
}
|
|
133
|
+
if (rowOneBased - 1 === critiqueRow) {
|
|
134
|
+
return 'critique';
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function newThreadPromptBodyLines(
|
|
140
|
+
state: NewThreadPromptState,
|
|
141
|
+
labels: {
|
|
142
|
+
readonly codexButtonLabel: string;
|
|
143
|
+
readonly claudeButtonLabel: string;
|
|
144
|
+
readonly cursorButtonLabel: string;
|
|
145
|
+
readonly terminalButtonLabel: string;
|
|
146
|
+
readonly critiqueButtonLabel: string;
|
|
147
|
+
},
|
|
148
|
+
): readonly string[] {
|
|
149
|
+
const codexSelected = state.selectedAgentType === 'codex';
|
|
150
|
+
const claudeSelected = state.selectedAgentType === 'claude';
|
|
151
|
+
const cursorSelected = state.selectedAgentType === 'cursor';
|
|
152
|
+
const terminalSelected = state.selectedAgentType === 'terminal';
|
|
153
|
+
const critiqueSelected = state.selectedAgentType === 'critique';
|
|
154
|
+
return [
|
|
155
|
+
'choose thread type',
|
|
156
|
+
'',
|
|
157
|
+
`${codexSelected ? '●' : '○'} ${labels.codexButtonLabel}`,
|
|
158
|
+
`${claudeSelected ? '●' : '○'} ${labels.claudeButtonLabel}`,
|
|
159
|
+
`${cursorSelected ? '●' : '○'} ${labels.cursorButtonLabel}`,
|
|
160
|
+
`${terminalSelected ? '●' : '○'} ${labels.terminalButtonLabel}`,
|
|
161
|
+
`${critiqueSelected ? '●' : '○'} ${labels.critiqueButtonLabel}`,
|
|
162
|
+
'',
|
|
163
|
+
'c/a/u/t/r toggle',
|
|
164
|
+
];
|
|
165
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync, type Dirent } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PROJECT_TREE_MAX_DEPTH = 3;
|
|
6
|
+
const DEFAULT_PROJECT_TREE_MAX_ENTRIES = 240;
|
|
7
|
+
const DEFAULT_PROJECT_TREE_SKIP_NAMES = new Set<string>([
|
|
8
|
+
'.git',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.next',
|
|
11
|
+
'.turbo',
|
|
12
|
+
'dist',
|
|
13
|
+
'build',
|
|
14
|
+
'target',
|
|
15
|
+
]);
|
|
16
|
+
const PROJECT_TREE_KIND_SORT_WEIGHT: Record<ProjectTreeDirectoryEntry['kind'], string> = {
|
|
17
|
+
directory: '0',
|
|
18
|
+
file: '1',
|
|
19
|
+
symlink: '1',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface ProjectTreeDirectoryEntry {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly kind: 'directory' | 'file' | 'symlink';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type GitLsFilesRunner = (cwd: string, args: readonly string[]) => string | null;
|
|
28
|
+
type ReadDirectoryEntries = (path: string) => readonly ProjectTreeDirectoryEntry[];
|
|
29
|
+
|
|
30
|
+
interface BuildProjectTreeLinesOptions {
|
|
31
|
+
readonly maxDepth?: number;
|
|
32
|
+
readonly maxEntries?: number;
|
|
33
|
+
readonly skipNames?: ReadonlySet<string> | readonly string[];
|
|
34
|
+
readonly runGitLsFiles?: GitLsFilesRunner;
|
|
35
|
+
readonly readDirectoryEntries?: ReadDirectoryEntries;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ProjectTreeEmitState {
|
|
39
|
+
readonly lines: string[];
|
|
40
|
+
readonly maxDepth: number;
|
|
41
|
+
readonly maxEntries: number;
|
|
42
|
+
emitted: number;
|
|
43
|
+
truncated: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface GitTreeNode {
|
|
47
|
+
readonly directories: Map<string, GitTreeNode>;
|
|
48
|
+
readonly files: Set<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatProjectTreeError(error: unknown): string {
|
|
52
|
+
if (error instanceof Error) {
|
|
53
|
+
return error.message;
|
|
54
|
+
}
|
|
55
|
+
return String(error);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toPositiveInt(value: number | undefined, fallback: number): number {
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
return Math.floor(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveSkipNames(
|
|
69
|
+
value: ReadonlySet<string> | readonly string[] | undefined,
|
|
70
|
+
): ReadonlySet<string> {
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
return DEFAULT_PROJECT_TREE_SKIP_NAMES;
|
|
73
|
+
}
|
|
74
|
+
if (value instanceof Set) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
return new Set(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mapDirentToProjectTreeEntry(entry: Dirent): ProjectTreeDirectoryEntry {
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
return {
|
|
83
|
+
name: entry.name,
|
|
84
|
+
kind: 'directory',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (entry.isSymbolicLink()) {
|
|
88
|
+
return {
|
|
89
|
+
name: entry.name,
|
|
90
|
+
kind: 'symlink',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
name: entry.name,
|
|
95
|
+
kind: 'file',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readDirectoryEntriesFromFilesystem(path: string): readonly ProjectTreeDirectoryEntry[] {
|
|
100
|
+
return readdirSync(path, { withFileTypes: true }).map(mapDirentToProjectTreeEntry);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sortProjectTreeEntries(
|
|
104
|
+
entries: readonly ProjectTreeDirectoryEntry[],
|
|
105
|
+
): ProjectTreeDirectoryEntry[] {
|
|
106
|
+
return [...entries].sort((left, right) => {
|
|
107
|
+
const leftKey = `${PROJECT_TREE_KIND_SORT_WEIGHT[left.kind]}:${left.name}`;
|
|
108
|
+
const rightKey = `${PROJECT_TREE_KIND_SORT_WEIGHT[right.kind]}:${right.name}`;
|
|
109
|
+
return leftKey.localeCompare(rightKey);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runGitLsFilesSync(cwd: string, args: readonly string[]): string | null {
|
|
114
|
+
try {
|
|
115
|
+
return execFileSync('git', [...args], {
|
|
116
|
+
cwd,
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
timeout: 1500,
|
|
119
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
120
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseGitLsFilesOutput(output: string): readonly string[] {
|
|
128
|
+
if (output.length === 0) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
return output.split('\0').filter((entry) => entry.length > 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createGitTreeNode(): GitTreeNode {
|
|
135
|
+
return {
|
|
136
|
+
directories: new Map<string, GitTreeNode>(),
|
|
137
|
+
files: new Set<string>(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function insertGitTreePath(root: GitTreeNode, value: string): void {
|
|
142
|
+
const normalized = value.trim().replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
143
|
+
if (normalized.length === 0) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const segments = normalized.split('/').filter((segment) => segment.length > 0);
|
|
147
|
+
let current = root;
|
|
148
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
149
|
+
const segment = segments[index]!;
|
|
150
|
+
const last = index === segments.length - 1;
|
|
151
|
+
if (last) {
|
|
152
|
+
current.files.add(segment);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
let next = current.directories.get(segment);
|
|
156
|
+
if (next === undefined) {
|
|
157
|
+
next = createGitTreeNode();
|
|
158
|
+
current.directories.set(segment, next);
|
|
159
|
+
}
|
|
160
|
+
current = next;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function emitProjectTreeLine(state: ProjectTreeEmitState, line: string): boolean {
|
|
165
|
+
if (state.truncated) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
state.lines.push(line);
|
|
169
|
+
state.emitted += 1;
|
|
170
|
+
if (state.emitted >= state.maxEntries) {
|
|
171
|
+
state.truncated = true;
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function walkGitTree(
|
|
178
|
+
node: GitTreeNode,
|
|
179
|
+
depth: number,
|
|
180
|
+
prefix: string,
|
|
181
|
+
state: ProjectTreeEmitState,
|
|
182
|
+
): void {
|
|
183
|
+
if (depth >= state.maxDepth) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const directories = [...node.directories.keys()].sort((left, right) => left.localeCompare(right));
|
|
187
|
+
const files = [...node.files.values()].sort((left, right) => left.localeCompare(right));
|
|
188
|
+
const entries = [
|
|
189
|
+
...directories.map((name) => ({ kind: 'directory' as const, name })),
|
|
190
|
+
...files.map((name) => ({ kind: 'file' as const, name })),
|
|
191
|
+
];
|
|
192
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
193
|
+
const entry = entries[index]!;
|
|
194
|
+
const isLast = index === entries.length - 1;
|
|
195
|
+
const connector = isLast ? '└─ ' : '├─ ';
|
|
196
|
+
const suffix = entry.kind === 'directory' ? '/' : '';
|
|
197
|
+
if (!emitProjectTreeLine(state, `${prefix}${connector}${entry.name}${suffix}`)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (entry.kind !== 'directory') {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const child = node.directories.get(entry.name)!;
|
|
204
|
+
walkGitTree(child, depth + 1, `${prefix}${isLast ? ' ' : '│ '}`, state);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function walkFilesystemTree(
|
|
209
|
+
currentPath: string,
|
|
210
|
+
depth: number,
|
|
211
|
+
prefix: string,
|
|
212
|
+
skipNames: ReadonlySet<string>,
|
|
213
|
+
readDirectoryEntries: ReadDirectoryEntries,
|
|
214
|
+
state: ProjectTreeEmitState,
|
|
215
|
+
): void {
|
|
216
|
+
if (depth >= state.maxDepth) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
let entries: ProjectTreeDirectoryEntry[];
|
|
220
|
+
try {
|
|
221
|
+
entries = sortProjectTreeEntries(
|
|
222
|
+
readDirectoryEntries(currentPath).filter((entry) => !skipNames.has(entry.name)),
|
|
223
|
+
);
|
|
224
|
+
} catch (error: unknown) {
|
|
225
|
+
void emitProjectTreeLine(state, `${prefix}└─ [unreadable: ${formatProjectTreeError(error)}]`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
229
|
+
const entry = entries[index]!;
|
|
230
|
+
const isLast = index === entries.length - 1;
|
|
231
|
+
const connector = isLast ? '└─ ' : '├─ ';
|
|
232
|
+
const suffix = entry.kind === 'directory' ? '/' : entry.kind === 'symlink' ? '@' : '';
|
|
233
|
+
if (!emitProjectTreeLine(state, `${prefix}${connector}${entry.name}${suffix}`)) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (entry.kind !== 'directory') {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
walkFilesystemTree(
|
|
240
|
+
join(currentPath, entry.name),
|
|
241
|
+
depth + 1,
|
|
242
|
+
`${prefix}${isLast ? ' ' : '│ '}`,
|
|
243
|
+
skipNames,
|
|
244
|
+
readDirectoryEntries,
|
|
245
|
+
state,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function finalizeProjectTreeLines(state: ProjectTreeEmitState): readonly string[] {
|
|
251
|
+
if (state.truncated) {
|
|
252
|
+
state.lines.push('└─ …');
|
|
253
|
+
}
|
|
254
|
+
return state.lines;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function buildProjectTreeLines(
|
|
258
|
+
rootPath: string,
|
|
259
|
+
options: BuildProjectTreeLinesOptions = {},
|
|
260
|
+
): readonly string[] {
|
|
261
|
+
const rootLabel = basename(rootPath) || rootPath;
|
|
262
|
+
const lines: string[] = [`${rootLabel}/`];
|
|
263
|
+
const state: ProjectTreeEmitState = {
|
|
264
|
+
lines,
|
|
265
|
+
maxDepth: toPositiveInt(options.maxDepth, DEFAULT_PROJECT_TREE_MAX_DEPTH),
|
|
266
|
+
maxEntries: Math.max(1, toPositiveInt(options.maxEntries, DEFAULT_PROJECT_TREE_MAX_ENTRIES)),
|
|
267
|
+
emitted: 0,
|
|
268
|
+
truncated: false,
|
|
269
|
+
};
|
|
270
|
+
const runGitLsFiles = options.runGitLsFiles ?? runGitLsFilesSync;
|
|
271
|
+
const gitOutput = runGitLsFiles(rootPath, [
|
|
272
|
+
'ls-files',
|
|
273
|
+
'--cached',
|
|
274
|
+
'--others',
|
|
275
|
+
'--exclude-standard',
|
|
276
|
+
'-z',
|
|
277
|
+
]);
|
|
278
|
+
if (gitOutput !== null) {
|
|
279
|
+
const root = createGitTreeNode();
|
|
280
|
+
for (const path of parseGitLsFilesOutput(gitOutput)) {
|
|
281
|
+
insertGitTreePath(root, path);
|
|
282
|
+
}
|
|
283
|
+
walkGitTree(root, 0, '', state);
|
|
284
|
+
return finalizeProjectTreeLines(state);
|
|
285
|
+
}
|
|
286
|
+
walkFilesystemTree(
|
|
287
|
+
rootPath,
|
|
288
|
+
0,
|
|
289
|
+
'',
|
|
290
|
+
resolveSkipNames(options.skipNames),
|
|
291
|
+
options.readDirectoryEntries ?? readDirectoryEntriesFromFilesystem,
|
|
292
|
+
state,
|
|
293
|
+
);
|
|
294
|
+
return finalizeProjectTreeLines(state);
|
|
295
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { padOrTrimDisplay } from './dual-pane-core.ts';
|
|
2
|
+
|
|
3
|
+
interface RenderCursorStyle {
|
|
4
|
+
readonly shape: 'block' | 'underline' | 'bar';
|
|
5
|
+
readonly blinking: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface MuxPerfStatusRow {
|
|
9
|
+
readonly fps: number;
|
|
10
|
+
readonly kbPerSecond: number;
|
|
11
|
+
readonly renderAvgMs: number;
|
|
12
|
+
readonly renderMaxMs: number;
|
|
13
|
+
readonly outputHandleAvgMs: number;
|
|
14
|
+
readonly outputHandleMaxMs: number;
|
|
15
|
+
readonly eventLoopP95Ms: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MuxPaneLayout {
|
|
19
|
+
readonly cols: number;
|
|
20
|
+
readonly paneRows: number;
|
|
21
|
+
readonly leftCols: number;
|
|
22
|
+
readonly rightCols: number;
|
|
23
|
+
readonly separatorCol: number;
|
|
24
|
+
readonly rightStartCol: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MuxModalOverlay {
|
|
28
|
+
readonly left: number;
|
|
29
|
+
readonly top: number;
|
|
30
|
+
readonly rows: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MUTED_SEPARATOR = '\u001b[0;38;5;240m│\u001b[0m';
|
|
34
|
+
|
|
35
|
+
export function buildRenderRows(
|
|
36
|
+
layout: MuxPaneLayout,
|
|
37
|
+
railRows: readonly string[],
|
|
38
|
+
rightRows: readonly string[],
|
|
39
|
+
perf: MuxPerfStatusRow,
|
|
40
|
+
statusRowDetailText?: string,
|
|
41
|
+
): string[] {
|
|
42
|
+
const rows: string[] = [];
|
|
43
|
+
const separatorAnchor = `\u001b[${String(layout.separatorCol)}G`;
|
|
44
|
+
const rightAnchor = `\u001b[${String(layout.rightStartCol)}G`;
|
|
45
|
+
for (let row = 0; row < layout.paneRows; row += 1) {
|
|
46
|
+
const left = railRows[row] ?? ' '.repeat(layout.leftCols);
|
|
47
|
+
const right = rightRows[row] ?? ' '.repeat(layout.rightCols);
|
|
48
|
+
rows.push(`${left}\u001b[0m${separatorAnchor}${MUTED_SEPARATOR}${rightAnchor}${right}`);
|
|
49
|
+
}
|
|
50
|
+
const defaultStatus = `[mux] fps=${perf.fps.toFixed(1)} kb/s=${perf.kbPerSecond.toFixed(1)} render=${perf.renderAvgMs.toFixed(2)}/${perf.renderMaxMs.toFixed(2)}ms output=${perf.outputHandleAvgMs.toFixed(2)}/${perf.outputHandleMaxMs.toFixed(2)}ms loop.p95=${perf.eventLoopP95Ms.toFixed(1)}ms`;
|
|
51
|
+
const statusText =
|
|
52
|
+
statusRowDetailText === undefined || statusRowDetailText.length === 0
|
|
53
|
+
? defaultStatus
|
|
54
|
+
: `${defaultStatus} ${statusRowDetailText}`;
|
|
55
|
+
const status = padOrTrimDisplay(statusText, layout.cols);
|
|
56
|
+
rows.push(status);
|
|
57
|
+
return rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function applyModalOverlay(rows: string[], overlay: MuxModalOverlay): void {
|
|
61
|
+
for (let rowOffset = 0; rowOffset < overlay.rows.length; rowOffset += 1) {
|
|
62
|
+
const targetRow = overlay.top + rowOffset;
|
|
63
|
+
if (targetRow < 0 || targetRow >= rows.length) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const overlayRow = overlay.rows[rowOffset];
|
|
67
|
+
if (overlayRow === undefined) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
rows[targetRow] = `${rows[targetRow] ?? ''}\u001b[${String(overlay.left + 1)}G${overlayRow}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function cursorStyleToDecscusr(style: RenderCursorStyle): string {
|
|
75
|
+
if (style.shape === 'block') {
|
|
76
|
+
return style.blinking ? '\u001b[1 q' : '\u001b[2 q';
|
|
77
|
+
}
|
|
78
|
+
if (style.shape === 'underline') {
|
|
79
|
+
return style.blinking ? '\u001b[3 q' : '\u001b[4 q';
|
|
80
|
+
}
|
|
81
|
+
return style.blinking ? '\u001b[5 q' : '\u001b[6 q';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function cursorStyleEqual(
|
|
85
|
+
left: RenderCursorStyle | null,
|
|
86
|
+
right: RenderCursorStyle,
|
|
87
|
+
): boolean {
|
|
88
|
+
if (left === null) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return left.shape === right.shape && left.blinking === right.blinking;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function renderCanonicalFrameAnsi(
|
|
95
|
+
rows: readonly string[],
|
|
96
|
+
cursorStyle: RenderCursorStyle,
|
|
97
|
+
cursorVisible: boolean,
|
|
98
|
+
cursorRow: number,
|
|
99
|
+
cursorCol: number,
|
|
100
|
+
): string {
|
|
101
|
+
let output = '\u001b[?25l\u001b[H\u001b[2J';
|
|
102
|
+
output += cursorStyleToDecscusr(cursorStyle);
|
|
103
|
+
for (let row = 0; row < rows.length; row += 1) {
|
|
104
|
+
output += `\u001b[${String(row + 1)};1H\u001b[2K${rows[row] ?? ''}`;
|
|
105
|
+
}
|
|
106
|
+
if (cursorVisible) {
|
|
107
|
+
output += '\u001b[?25h';
|
|
108
|
+
output += `\u001b[${String(cursorRow + 1)};${String(cursorCol + 1)}H`;
|
|
109
|
+
} else {
|
|
110
|
+
output += '\u001b[?25l';
|
|
111
|
+
}
|
|
112
|
+
return output;
|
|
113
|
+
}
|