mixdog 0.9.22 → 0.9.23
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 +1 -4
- package/package.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
package/src/tui/App.jsx
CHANGED
|
@@ -85,7 +85,7 @@ import {
|
|
|
85
85
|
memoryCoreResultErrorText,
|
|
86
86
|
} from './app/input-parsers.mjs';
|
|
87
87
|
import { copyToClipboard } from './app/clipboard.mjs';
|
|
88
|
-
import { wrappedTextRows, promptContentRows, wrappedDetailRows, textEntryReservedRows } from './app/text-layout.mjs';
|
|
88
|
+
import { wrappedTextRows, promptContentRows, wrappedDetailRows, textEntryReservedRows, queuedBandRows } from './app/text-layout.mjs';
|
|
89
89
|
import stringWidth from 'string-width';
|
|
90
90
|
import { useMouseInput } from './app/use-mouse-input.mjs';
|
|
91
91
|
import { useTranscriptScroll } from './app/use-transcript-scroll.mjs';
|
|
@@ -194,6 +194,8 @@ const PANEL_LAYOUT_SIG = {
|
|
|
194
194
|
// Prompt-wrap/meta row counts (trailing churn tokens, see token order note
|
|
195
195
|
// below). PROMPT_META is the 2-row live-spinner band slot.
|
|
196
196
|
PROMPT_META: 9,
|
|
197
|
+
// Queued steering band rows (full wrapped height, see queuedBandRows).
|
|
198
|
+
QUEUED: 10,
|
|
197
199
|
};
|
|
198
200
|
const PROJECT_TEXT_ENTRY_KINDS = new Set(['project-new', 'project-create-confirm', 'project-rename']);
|
|
199
201
|
const CORE_MULTILINE_TEXT_ENTRY_KINDS = new Set(['core-add', 'core-edit']);
|
|
@@ -319,7 +321,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
319
321
|
// factory is instantiated (see initialPickerBuiltRef below), which React
|
|
320
322
|
// applies before the first commit — no picker-less flash frame.
|
|
321
323
|
const [picker, setPickerState] = useState(null);
|
|
324
|
+
// Live handle to the current picker state so async callbacks (e.g. the MCP
|
|
325
|
+
// toggle settle guard in extension-pickers) read the picker actually on
|
|
326
|
+
// screen at call time — including pickers opened by other factories — rather
|
|
327
|
+
// than a stale closure. Updated synchronously in setPicker (below) so a
|
|
328
|
+
// settle firing before the next render sees the right _kind; render-time
|
|
329
|
+
// sync further down is a backstop.
|
|
330
|
+
const livePickerRef = useRef(null);
|
|
322
331
|
const setPicker = useCallback((next) => {
|
|
332
|
+
// Synchronous ref update so out-of-band setPicker(null/other) is visible to
|
|
333
|
+
// in-flight async guards immediately, before React commits the next render.
|
|
334
|
+
livePickerRef.current = typeof next === 'function' ? next(livePickerRef.current) : next;
|
|
323
335
|
setPickerState((prev) => {
|
|
324
336
|
const resolved = typeof next === 'function' ? next(prev) : next;
|
|
325
337
|
if (resolved && typeof resolved === 'object' && pickerOpenedFromEnterRef.current) {
|
|
@@ -330,9 +342,22 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
330
342
|
}
|
|
331
343
|
return resolved.indexMode ? resolved : { ...resolved, indexMode: 'always' };
|
|
332
344
|
}
|
|
345
|
+
// Same-kind reopen (toggle-driven rebuilds like the MCP ←/→ flip):
|
|
346
|
+
// carry the previous picker's indexMode so an 'always' injected at
|
|
347
|
+
// Enter-open time survives the rebuild instead of falling back to
|
|
348
|
+
// 'auto' and hiding the row indexes.
|
|
349
|
+
if (
|
|
350
|
+
resolved && typeof resolved === 'object' && !resolved.indexMode
|
|
351
|
+
&& prev && typeof prev === 'object' && prev.indexMode
|
|
352
|
+
&& prev._kind && prev._kind === resolved._kind
|
|
353
|
+
) {
|
|
354
|
+
return { ...resolved, indexMode: prev.indexMode };
|
|
355
|
+
}
|
|
333
356
|
return resolved;
|
|
334
357
|
});
|
|
335
358
|
}, []);
|
|
359
|
+
// Backstop: keep the ref aligned with committed state each render.
|
|
360
|
+
livePickerRef.current = picker;
|
|
336
361
|
const [contextPanel, setContextPanel] = useState(null);
|
|
337
362
|
const [usagePanel, setUsagePanel] = useState(null);
|
|
338
363
|
const usageRequestRef = useRef(0);
|
|
@@ -385,7 +410,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
385
410
|
onboardingOwnsScreen = status?.completed !== true || forceOnboarding;
|
|
386
411
|
} catch { /* status probe failed → fall through to the project picker */ }
|
|
387
412
|
if (!onboardingOwnsScreen && state.items.length === 0) {
|
|
388
|
-
|
|
413
|
+
setPicker(projectPicker.buildProjectPickerState({ initialEntry: true }));
|
|
389
414
|
}
|
|
390
415
|
}
|
|
391
416
|
const {
|
|
@@ -465,6 +490,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
465
490
|
clean,
|
|
466
491
|
copyToClipboard,
|
|
467
492
|
setPicker,
|
|
493
|
+
getPicker: () => livePickerRef.current,
|
|
468
494
|
setProviderPrompt,
|
|
469
495
|
setChannelPrompt,
|
|
470
496
|
setHookPrompt,
|
|
@@ -1061,14 +1087,23 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1061
1087
|
// The stitch buffer accumulates rows harvested across every scroll position
|
|
1062
1088
|
// during a transcript drag, so it can reconstruct rows that scrolled out of
|
|
1063
1089
|
// view entirely (neither renderText nor the last-full-paint remembered text
|
|
1064
|
-
// ever saw them).
|
|
1065
|
-
|
|
1066
|
-
|
|
1090
|
+
// ever saw them). getStitchedSelectionText now reports a `complete` flag:
|
|
1091
|
+
// prefer the stitch ONLY when it contiguously covers the selection (no
|
|
1092
|
+
// interior gap) AND adds rows. A gapped stitch silently drops a scrolled-off
|
|
1093
|
+
// row, so preferring it purely on length yielded a mangled copy.
|
|
1094
|
+
const stitched = getStitchedSelectionText?.() || { text: '', complete: false };
|
|
1095
|
+
if (stitched.complete && stitched.text.length > text.length) text = stitched.text;
|
|
1067
1096
|
if ((!text || !text.trim()) && attempt < 4) {
|
|
1068
1097
|
setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
|
|
1069
1098
|
return;
|
|
1070
1099
|
}
|
|
1071
|
-
if (!text || !text.trim())
|
|
1100
|
+
if (!text || !text.trim()) {
|
|
1101
|
+
// Retries exhausted with nothing to copy: never return silently — the
|
|
1102
|
+
// user pressed Ctrl+C expecting feedback. Surface a hint (and still
|
|
1103
|
+
// swallow the key, which the caller already did).
|
|
1104
|
+
showSelectionCopyHint('nothing to copy · select text first', 'error');
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1072
1107
|
selectionTextRef.current = text;
|
|
1073
1108
|
copyToClipboard(text)
|
|
1074
1109
|
.then(() => {
|
|
@@ -2532,9 +2567,21 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2532
2567
|
// active.
|
|
2533
2568
|
const overlayHintRequested = !inputBoxHidden && !hasFloatingPanel && !liveSpinner && !!inputHint && !queuedVisible;
|
|
2534
2569
|
const overlayHintRows = 0;
|
|
2535
|
-
// QueuedCommands renders
|
|
2536
|
-
//
|
|
2537
|
-
|
|
2570
|
+
// QueuedCommands renders each queued command at its FULL wrapped height
|
|
2571
|
+
// (same content width the promoted transcript user row wraps at), pinned
|
|
2572
|
+
// above the prompt box with no extra top-margin row. Reserving the true
|
|
2573
|
+
// height keeps promotion from re-expanding the text mid-flight ("row jump").
|
|
2574
|
+
// If the whole queue would eat too much of the frame, fall back to the old
|
|
2575
|
+
// compact 1-row-per-entry truncation so the input box never leaves screen.
|
|
2576
|
+
const queuedFullRows = queuedVisible
|
|
2577
|
+
? state.queued.reduce(
|
|
2578
|
+
(sum, item) => sum + queuedBandRows(String(item.displayText || item.text || ''), Math.max(1, frameColumns - 4)),
|
|
2579
|
+
0,
|
|
2580
|
+
)
|
|
2581
|
+
: 0;
|
|
2582
|
+
const queuedRowBudget = Math.max(3, Math.floor(resizeState.rows / 3));
|
|
2583
|
+
const queuedCompact = queuedFullRows > queuedRowBudget;
|
|
2584
|
+
const queuedRows = queuedVisible ? (queuedCompact ? state.queued.length : queuedFullRows) : 0;
|
|
2538
2585
|
const INPUT_BOX_ROWS = promptBoxRows + promptMetaRows + overlayHintRows;
|
|
2539
2586
|
const baseReserve = WELCOME_ROWS + SCROLL_HINT_ROWS + LIVE_STATUS_ROWS + INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows;
|
|
2540
2587
|
const maxFloatingPanelRows = Math.max(0, resizeState.rows - baseReserve - 1);
|
|
@@ -2617,6 +2664,22 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2617
2664
|
const spinnerMetaCollapseRows = doneTailAppendedThisCommit
|
|
2618
2665
|
? Math.max(0, prevMetaRows - nextMetaRows)
|
|
2619
2666
|
: 0;
|
|
2667
|
+
// Queued-band promotion: drain() removes the queued band and appends the
|
|
2668
|
+
// promoted user transcript row in the SAME commit (session-flow.mjs drain
|
|
2669
|
+
// → pushUserOrSyntheticItem → runTurn spinner, one microtask flush). The
|
|
2670
|
+
// new user row (full wrapped height + margin) already backfills the
|
|
2671
|
+
// vacated band rows, so masking them blank for one commit only to drop
|
|
2672
|
+
// the mask on the next commit made the whole transcript bounce down.
|
|
2673
|
+
// Exempt exactly the vacated queued rows when a user row landed at the
|
|
2674
|
+
// tail in this commit; queue edits/removals without a tail append (tail
|
|
2675
|
+
// id unchanged, or non-user tail) keep the mask.
|
|
2676
|
+
const prevQueuedSigRows = Number(String(panelTransition.signature).split('|')[PANEL_LAYOUT_SIG.QUEUED]) || 0;
|
|
2677
|
+
const nextQueuedSigRows = Number(String(panelLayoutSignature).split('|')[PANEL_LAYOUT_SIG.QUEUED]) || 0;
|
|
2678
|
+
const userTailAppendedThisCommit = latestTranscriptItem?.kind === 'user'
|
|
2679
|
+
&& (latestTranscriptItem?.id ?? null) !== panelTransition.tailId;
|
|
2680
|
+
const queuedPromoteCollapseRows = userTailAppendedThisCommit
|
|
2681
|
+
? Math.max(0, prevQueuedSigRows - nextQueuedSigRows)
|
|
2682
|
+
: 0;
|
|
2620
2683
|
const instantPanelClose = panelShrinkRows > 0
|
|
2621
2684
|
&& (promptRowsOnlyChange
|
|
2622
2685
|
|| isInstantPanelCloseTransition(panelTransition.signature, panelLayoutSignature, initialProjectEntryClose));
|
|
@@ -2637,8 +2700,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2637
2700
|
// the transcript clip instead of inflating bottomReserve + reclaiming on
|
|
2638
2701
|
// the next tick. Subtract any turn-end spinner-meta rows that the same-
|
|
2639
2702
|
// commit done tail already backfills (spinnerMetaCollapseRows) so that
|
|
2640
|
-
// transition masks nothing and does not bounce
|
|
2641
|
-
|
|
2703
|
+
// transition masks nothing and does not bounce; same for queued-band
|
|
2704
|
+
// rows backfilled by a just-promoted user row (queuedPromoteCollapseRows).
|
|
2705
|
+
panelCloseInkMaskRowsRef.current = Math.max(0, panelShrinkRows - spinnerMetaCollapseRows - queuedPromoteCollapseRows);
|
|
2642
2706
|
panelTransition.clearRows = 0;
|
|
2643
2707
|
panelTransition.guardRows = 0;
|
|
2644
2708
|
panelTransition.epoch = panelTransitionEpoch;
|
|
@@ -2695,8 +2759,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2695
2759
|
// renderScrollOffset=0. Without this, the stale offset shrinks
|
|
2696
2760
|
// transcriptContentHeight by one row that never actually gets rendered,
|
|
2697
2761
|
// producing a one-row bounce that snaps back once state catches up.
|
|
2698
|
-
|
|
2699
|
-
|
|
2762
|
+
// [2026-07-06] Scroll-time extra guard DISABLED: the widened (2-row) guard
|
|
2763
|
+
// rendered as a visibly empty band between the transcript and the prompt box
|
|
2764
|
+
// whenever the viewport was scrolled up (user-reported "bottom rows look
|
|
2765
|
+
// blank while scrolling"). The base 1-row guard below stays; if the
|
|
2766
|
+
// scrolled-row-over-statusline overpaint resurfaces, fix it in the renderer
|
|
2767
|
+
// diff (clip/erase) instead of carving more blank viewport rows.
|
|
2768
|
+
const scrollGuardRows = 0;
|
|
2700
2769
|
const transcriptGuardRows = Math.min(guardCapacityRows, baseGuardRows + panelTransitionGuardRows + scrollGuardRows);
|
|
2701
2770
|
// Welcome prompt hint: a one-row band rendered INSIDE the transcript
|
|
2702
2771
|
// viewport (as a sibling below the content clip), so it must be part of the
|
|
@@ -2724,6 +2793,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2724
2793
|
const welcomePromptHintRows = welcomePromptHintVisible
|
|
2725
2794
|
&& (viewportHeight - transcriptGuardRows) >= 2 ? 1 : 0;
|
|
2726
2795
|
welcomePromptHintVisibleRef.current = welcomePromptHintRows > 0;
|
|
2796
|
+
// Transient hint/error on the EMPTY transcript: the guard row sits directly
|
|
2797
|
+
// above the prompt box, so painting the hint there hugs the textbox one row
|
|
2798
|
+
// below where the live-spinner line renders. Carve one in-viewport row ABOVE
|
|
2799
|
+
// the guard row instead so the hint's baseline matches the spinner row (two
|
|
2800
|
+
// rows above the box, guard row stays blank as the spacer). This is an
|
|
2801
|
+
// in-viewport carve like welcomePromptHintRows — bottomReserve is untouched,
|
|
2802
|
+
// so the prompt box and statusline never move. Non-empty transcripts keep the
|
|
2803
|
+
// existing attach-to-last-item / guard-row fallback placements.
|
|
2804
|
+
const overlayHintBandRows = overlayHintRequested
|
|
2805
|
+
&& state.items.length === 0
|
|
2806
|
+
&& (viewportHeight - transcriptGuardRows - welcomePromptHintRows) >= 2 ? 1 : 0;
|
|
2727
2807
|
// Instant panel close (slash palette): the reclaimed rows stay blank for
|
|
2728
2808
|
// exactly one commit via panelCloseMaskRows. The mask MUST be part of this
|
|
2729
2809
|
// frame's row accounting — subtract it from the transcript content height
|
|
@@ -2734,11 +2814,11 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2734
2814
|
// commit (the "textbox dips when the slash palette closes" bug).
|
|
2735
2815
|
const panelCloseMaskRows = Math.min(
|
|
2736
2816
|
panelCloseInkMaskRows,
|
|
2737
|
-
Math.max(0, viewportHeight - transcriptGuardRows - welcomePromptHintRows - 1),
|
|
2817
|
+
Math.max(0, viewportHeight - transcriptGuardRows - welcomePromptHintRows - overlayHintBandRows - 1),
|
|
2738
2818
|
);
|
|
2739
2819
|
const transcriptContentHeight = Math.max(
|
|
2740
2820
|
1,
|
|
2741
|
-
viewportHeight - transcriptGuardRows - panelCloseMaskRows - welcomePromptHintRows,
|
|
2821
|
+
viewportHeight - transcriptGuardRows - panelCloseMaskRows - welcomePromptHintRows - overlayHintBandRows,
|
|
2742
2822
|
);
|
|
2743
2823
|
// Bottom-follow / pin semantics must NOT widen with the scroll-time guard, or
|
|
2744
2824
|
// the "pinned to tail" threshold would drift and streaming could freeze a row
|
|
@@ -3049,10 +3129,18 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
3049
3129
|
backgroundColor={surfaceBackground()}
|
|
3050
3130
|
/>
|
|
3051
3131
|
) : null}
|
|
3132
|
+
{overlayHintBandRows > 0 ? (
|
|
3133
|
+
<Box height={1} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
|
|
3134
|
+
<Box flexGrow={1} flexShrink={1} overflow="hidden" />
|
|
3135
|
+
<Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
3136
|
+
<Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
|
|
3137
|
+
</Box>
|
|
3138
|
+
</Box>
|
|
3139
|
+
) : null}
|
|
3052
3140
|
{transcriptGuardRows > 0 ? (
|
|
3053
3141
|
<Box height={transcriptGuardRows} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
|
|
3054
3142
|
<Box flexGrow={1} flexShrink={1} overflow="hidden" />
|
|
3055
|
-
{overlayHintFallbackRow ? (
|
|
3143
|
+
{overlayHintFallbackRow && overlayHintBandRows === 0 ? (
|
|
3056
3144
|
<Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
3057
3145
|
<Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
|
|
3058
3146
|
</Box>
|
|
@@ -3314,7 +3402,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
3314
3402
|
</>
|
|
3315
3403
|
) : null}
|
|
3316
3404
|
{queuedVisible ? (
|
|
3317
|
-
<QueuedCommands queued={state.queued} columns={frameColumns} />
|
|
3405
|
+
<QueuedCommands queued={state.queued} columns={frameColumns} compact={queuedCompact} />
|
|
3318
3406
|
) : null}
|
|
3319
3407
|
<Box
|
|
3320
3408
|
marginTop={0}
|
|
@@ -12,9 +12,17 @@ export function osc52ClipboardSequence(text) {
|
|
|
12
12
|
return `\x1bPtmux;${raw.replaceAll('\x1b', '\x1b\x1b')}\x1b\\`;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Base64 of large selections becomes a multi-hundred-KB TTY write. Emitting
|
|
16
|
+
// that as a single OSC 52 sequence blocks the terminal (and our render loop)
|
|
17
|
+
// for a noticeable beat, so skip OSC 52 past this size and rely on the native
|
|
18
|
+
// helper. ~256KB of clipboard text → ~350KB of base64.
|
|
19
|
+
const OSC52_MAX_BYTES = 256 * 1024;
|
|
20
|
+
|
|
15
21
|
export function writeOsc52Clipboard(text) {
|
|
22
|
+
const value = String(text ?? '');
|
|
23
|
+
if (Buffer.byteLength(value, 'utf8') > OSC52_MAX_BYTES) return false;
|
|
16
24
|
try {
|
|
17
|
-
process.stdout.write(osc52ClipboardSequence(
|
|
25
|
+
process.stdout.write(osc52ClipboardSequence(value));
|
|
18
26
|
return true;
|
|
19
27
|
} catch {
|
|
20
28
|
return false;
|
|
@@ -22,27 +30,32 @@ export function writeOsc52Clipboard(text) {
|
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
export function nativeClipboardCommand(text) {
|
|
33
|
+
const value = String(text ?? '');
|
|
25
34
|
if (process.platform === 'win32') {
|
|
35
|
+
// clip.exe starts in tens of ms (vs 1s+ for powershell.exe). It reads its
|
|
36
|
+
// stdin as UTF-16LE, so feed plain UTF-16LE bytes for correct Unicode. No
|
|
37
|
+
// BOM: clip.exe copies a leading FF FE verbatim, leaking U+FEFF as the
|
|
38
|
+
// first pasted char.
|
|
26
39
|
return {
|
|
27
|
-
cmd: '
|
|
28
|
-
args: [
|
|
29
|
-
|
|
30
|
-
'-NoProfile',
|
|
31
|
-
'-NonInteractive',
|
|
32
|
-
'-Command',
|
|
33
|
-
'$b=[Console]::In.ReadToEnd();$t=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b));Set-Clipboard -Value $t',
|
|
34
|
-
],
|
|
35
|
-
input: Buffer.from(String(text ?? ''), 'utf8').toString('base64'),
|
|
40
|
+
cmd: 'clip.exe',
|
|
41
|
+
args: [],
|
|
42
|
+
input: Buffer.from(value, 'utf16le'),
|
|
36
43
|
};
|
|
37
44
|
}
|
|
38
|
-
if (process.platform === 'darwin') return { cmd: 'pbcopy', args: [], input:
|
|
39
|
-
if (process.env.WAYLAND_DISPLAY) return { cmd: 'wl-copy', args: [], input:
|
|
40
|
-
return { cmd: 'xclip', args: ['-selection', 'clipboard'], input:
|
|
45
|
+
if (process.platform === 'darwin') return { cmd: 'pbcopy', args: [], input: value };
|
|
46
|
+
if (process.env.WAYLAND_DISPLAY) return { cmd: 'wl-copy', args: [], input: value };
|
|
47
|
+
return { cmd: 'xclip', args: ['-selection', 'clipboard'], input: value };
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
export function copyToClipboard(text) {
|
|
44
51
|
const value = String(text ?? '');
|
|
45
52
|
const wroteOsc52 = writeOsc52Clipboard(value);
|
|
53
|
+
// When OSC 52 already wrote the clipboard, fire-and-forget the native helper
|
|
54
|
+
// and resolve immediately so the TUI never blocks on subprocess startup/exit
|
|
55
|
+
// (clip.exe/pbcopy/etc. still finish in the background). When OSC 52 was
|
|
56
|
+
// skipped (payload too large), the native helper is the ONLY writer, so we
|
|
57
|
+
// must await its exit to report real success/failure instead of a false
|
|
58
|
+
// "copied" hint.
|
|
46
59
|
return new Promise((resolve, reject) => {
|
|
47
60
|
const { cmd, args, input } = nativeClipboardCommand(value);
|
|
48
61
|
let child;
|
|
@@ -54,14 +67,21 @@ export function copyToClipboard(text) {
|
|
|
54
67
|
return;
|
|
55
68
|
}
|
|
56
69
|
child.on('error', (e) => {
|
|
57
|
-
if
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
child.on('close', (code) => {
|
|
61
|
-
if (code === 0 || wroteOsc52) resolve();
|
|
62
|
-
else reject(new Error(`${cmd} exited with code ${code}`));
|
|
70
|
+
// Surface only if OSC 52 didn't cover us; otherwise the copy still landed.
|
|
71
|
+
if (!wroteOsc52) reject(e);
|
|
63
72
|
});
|
|
73
|
+
if (!wroteOsc52) {
|
|
74
|
+
child.on('close', (code) => {
|
|
75
|
+
if (code === 0) resolve();
|
|
76
|
+
else reject(new Error(`${cmd} exited with code ${code}`));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
64
79
|
child.stdin.on('error', () => { /* ignore EPIPE if the helper closed early */ });
|
|
65
80
|
child.stdin.end(input);
|
|
81
|
+
// OSC 52 covered us: resolve now, don't await the child's exit.
|
|
82
|
+
if (wroteOsc52) {
|
|
83
|
+
child.unref?.();
|
|
84
|
+
resolve();
|
|
85
|
+
}
|
|
66
86
|
});
|
|
67
87
|
}
|
package/src/tui/app/doctor.mjs
CHANGED
|
@@ -14,9 +14,37 @@ import { compareSemver } from '../../runtime/shared/update-checker.mjs';
|
|
|
14
14
|
import { readFileSync } from 'node:fs';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
|
|
17
18
|
|
|
18
19
|
const GLYPH = { ok: '✓', warn: '⚠', fail: '✗' };
|
|
19
20
|
|
|
21
|
+
// Windows Defender real-time scanning of the Postgres data dir shows up as
|
|
22
|
+
// pathologically long checkpoint write phases in pg.log (e.g. write=12 s for a
|
|
23
|
+
// <10MB checkpoint). Get-MpPreference needs admin, so we never read the actual
|
|
24
|
+
// exclusion list — we infer interference from checkpoint timings instead.
|
|
25
|
+
const CKPT_WRITE_WARN_S = 10; // any single checkpoint write phase over this = suspicious
|
|
26
|
+
const CKPT_WRITE_MEDIAN_S = 5; // sustained median over this = suspicious
|
|
27
|
+
|
|
28
|
+
function parseCheckpointWriteSeconds(logText) {
|
|
29
|
+
const lines = String(logText).split(/\r?\n/).slice(-200);
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const m = /checkpoint complete:.*?\bwrite=([\d.]+)\s*s/i.exec(line);
|
|
33
|
+
if (m) {
|
|
34
|
+
const v = Number(m[1]);
|
|
35
|
+
if (Number.isFinite(v)) out.push(v);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function median(nums) {
|
|
42
|
+
if (!nums.length) return 0;
|
|
43
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
44
|
+
const mid = s.length >> 1;
|
|
45
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
function readPackageJson() {
|
|
21
49
|
try {
|
|
22
50
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -171,5 +199,34 @@ export async function buildDoctorReport(runtime = {}, getState = () => ({})) {
|
|
|
171
199
|
row('ok', `${enabled ? 'enabled' : 'disabled'} · ${events.length} event${events.length === 1 ? '' : 's'}`);
|
|
172
200
|
});
|
|
173
201
|
|
|
202
|
+
// 8. defender (win32 only): infer AV real-time-scan interference on the PG
|
|
203
|
+
// data dir from checkpoint write timings; report-only, so surface the
|
|
204
|
+
// exact elevated exclusion one-liner as copyable fix text.
|
|
205
|
+
if (process.platform === 'win32') {
|
|
206
|
+
await check('defender', async (row) => {
|
|
207
|
+
const dataDir = resolvePluginData();
|
|
208
|
+
const pgdata = join(dataDir, 'pgdata');
|
|
209
|
+
let writes;
|
|
210
|
+
try {
|
|
211
|
+
writes = parseCheckpointWriteSeconds(readFileSync(join(dataDir, 'pg.log'), 'utf8'));
|
|
212
|
+
} catch {
|
|
213
|
+
row('ok', 'pg.log unavailable · check skipped');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!writes.length) {
|
|
217
|
+
row('ok', 'no checkpoint timings yet');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const max = Math.max(...writes);
|
|
221
|
+
const med = median(writes);
|
|
222
|
+
if (max <= CKPT_WRITE_WARN_S && med <= CKPT_WRITE_MEDIAN_S) {
|
|
223
|
+
row('ok', `checkpoint write median ${med.toFixed(1)}s · max ${max.toFixed(1)}s`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const fix = `Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-Command',"Add-MpPreference -ExclusionPath '${pgdata}'"`;
|
|
227
|
+
row('warn', `slow checkpoints (median ${med.toFixed(1)}s · max ${max.toFixed(1)}s) suggest Defender real-time scan of ${pgdata}. Fix (run in PowerShell): ${fix}`);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
174
231
|
return ['mixdog doctor — installation health', ...rows].join('\n');
|
|
175
232
|
}
|
|
@@ -15,6 +15,7 @@ export function createExtensionPickers({
|
|
|
15
15
|
clean,
|
|
16
16
|
copyToClipboard,
|
|
17
17
|
setPicker,
|
|
18
|
+
getPicker,
|
|
18
19
|
setProviderPrompt,
|
|
19
20
|
setChannelPrompt,
|
|
20
21
|
setHookPrompt,
|
|
@@ -22,6 +23,12 @@ export function createExtensionPickers({
|
|
|
22
23
|
getDisabledSkills,
|
|
23
24
|
setDisabledSkills,
|
|
24
25
|
}) {
|
|
26
|
+
// MCP toggle settle-guard state: bumped per toggle (epoch) and armed only
|
|
27
|
+
// while the MCP picker is on screen (see openMcpServersPicker). The live
|
|
28
|
+
// picker is read via getPicker() so a stale settle can also detect pickers
|
|
29
|
+
// opened outside this factory replacing the MCP one.
|
|
30
|
+
let mcpEpoch = 0;
|
|
31
|
+
let mcpActive = false;
|
|
25
32
|
const mcpStatus = () => {
|
|
26
33
|
let status;
|
|
27
34
|
try {
|
|
@@ -37,6 +44,7 @@ export function createExtensionPickers({
|
|
|
37
44
|
const status = mcpStatus();
|
|
38
45
|
if (!status) return;
|
|
39
46
|
const servers = status.servers || [];
|
|
47
|
+
const optimistic = options?.optimistic || null;
|
|
40
48
|
const items = [];
|
|
41
49
|
if (servers.length === 0) {
|
|
42
50
|
items.push({
|
|
@@ -47,13 +55,16 @@ export function createExtensionPickers({
|
|
|
47
55
|
});
|
|
48
56
|
}
|
|
49
57
|
for (const server of servers) {
|
|
50
|
-
const
|
|
58
|
+
const pending = optimistic && optimistic.name === server.name;
|
|
59
|
+
const enabled = pending ? optimistic.enabled : server.enabled !== false;
|
|
51
60
|
items.push({
|
|
52
61
|
value: `server:${server.name}`,
|
|
53
62
|
label: server.name,
|
|
54
63
|
marker: enabled ? '●' : '○',
|
|
55
64
|
markerColor: enabled ? theme.success : theme.inactive,
|
|
56
|
-
description:
|
|
65
|
+
description: pending
|
|
66
|
+
? `${optimistic.enabled ? 'enabling' : 'disabling'}… · ${server.transport || 'unknown'}`
|
|
67
|
+
: `${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
|
|
57
68
|
_action: 'server',
|
|
58
69
|
_server: server,
|
|
59
70
|
_enabled: enabled,
|
|
@@ -65,11 +76,31 @@ export function createExtensionPickers({
|
|
|
65
76
|
setSettingsPrompt(null);
|
|
66
77
|
const toggleServer = (item) => {
|
|
67
78
|
if (item._action !== 'server' || !item._server?.name) return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
79
|
+
const name = item._server.name;
|
|
80
|
+
const target = !item._enabled;
|
|
81
|
+
const highlightValue = `server:${name}`;
|
|
82
|
+
// A settle is only allowed to touch the UI if it is still the newest
|
|
83
|
+
// toggle (token === mcpEpoch) and the MCP picker is still on screen.
|
|
84
|
+
const token = ++mcpEpoch;
|
|
85
|
+
const settle = (fn) => {
|
|
86
|
+
if (token !== mcpEpoch || !mcpActive || getPicker?.()?._kind !== 'mcp-servers') return;
|
|
87
|
+
fn();
|
|
88
|
+
};
|
|
89
|
+
// Optimistic: instantly reopen with the row flipped + pending status.
|
|
90
|
+
openMcpServersPicker({ highlightValue, optimistic: { name, enabled: target } });
|
|
91
|
+
Promise.resolve(store.setMcpServerEnabled?.(name, target))
|
|
92
|
+
.then(() => {
|
|
93
|
+
// Per-server serialization in the runtime already converged rapid
|
|
94
|
+
// re-toggles to the last requested state; just refresh the row.
|
|
95
|
+
settle(() => openMcpServersPicker({ highlightValue }));
|
|
96
|
+
})
|
|
97
|
+
.catch((e) => {
|
|
98
|
+
store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error');
|
|
99
|
+
settle(() => openMcpServersPicker({ highlightValue }));
|
|
100
|
+
});
|
|
71
101
|
};
|
|
72
102
|
setPicker({
|
|
103
|
+
_kind: 'mcp-servers',
|
|
73
104
|
title: 'MCP servers',
|
|
74
105
|
description: 'Enable or disable configured MCP servers.',
|
|
75
106
|
initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options?.highlightValue)),
|
|
@@ -78,9 +109,13 @@ export function createExtensionPickers({
|
|
|
78
109
|
onLeft: (item) => toggleServer(item),
|
|
79
110
|
onRight: (item) => toggleServer(item),
|
|
80
111
|
onCancel: () => {
|
|
112
|
+
// Leaving the picker invalidates any in-flight settle.
|
|
113
|
+
mcpActive = false;
|
|
114
|
+
mcpEpoch++;
|
|
81
115
|
setPicker(null);
|
|
82
116
|
},
|
|
83
117
|
});
|
|
118
|
+
mcpActive = true;
|
|
84
119
|
};
|
|
85
120
|
|
|
86
121
|
const openMcpPicker = () => {
|
|
@@ -141,9 +176,16 @@ export function createExtensionPickers({
|
|
|
141
176
|
};
|
|
142
177
|
|
|
143
178
|
const openSkillsPicker = (options = {}) => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
179
|
+
// Reuse the skills list already fetched by the opening call when a toggle
|
|
180
|
+
// reopens the picker: avoids a store.skillsStatus() round-trip per keypress.
|
|
181
|
+
let skills;
|
|
182
|
+
if (Array.isArray(options.skills)) {
|
|
183
|
+
skills = options.skills;
|
|
184
|
+
} else {
|
|
185
|
+
const status = skillsStatus();
|
|
186
|
+
if (!status) return;
|
|
187
|
+
skills = status.skills || [];
|
|
188
|
+
}
|
|
147
189
|
const disabledSet = options.disabledOverride instanceof Set ? options.disabledOverride : getDisabledSkills();
|
|
148
190
|
const items = [];
|
|
149
191
|
if (skills.length === 0) {
|
|
@@ -181,9 +223,10 @@ export function createExtensionPickers({
|
|
|
181
223
|
`skill ${item._enabled ? 'disabled' : 'enabled'}: ${name} (prompt updates next session /clear)`,
|
|
182
224
|
'info',
|
|
183
225
|
);
|
|
184
|
-
openSkillsPicker({ highlightValue: name, disabledOverride: next });
|
|
226
|
+
openSkillsPicker({ highlightValue: name, disabledOverride: next, skills });
|
|
185
227
|
};
|
|
186
228
|
setPicker({
|
|
229
|
+
_kind: 'skills',
|
|
187
230
|
title: 'Skills',
|
|
188
231
|
description: 'Enable or disable project skills.',
|
|
189
232
|
initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
|
|
@@ -482,6 +525,7 @@ export function createExtensionPickers({
|
|
|
482
525
|
}
|
|
483
526
|
};
|
|
484
527
|
setPicker({
|
|
528
|
+
_kind: 'hooks',
|
|
485
529
|
title: 'Hooks',
|
|
486
530
|
description: 'Before-tool hook rules; Enter toggles a rule.',
|
|
487
531
|
items,
|
|
@@ -210,8 +210,6 @@ export function createMaintenancePickers({
|
|
|
210
210
|
const current = readCurrent();
|
|
211
211
|
const enabled = current?.enabled !== false;
|
|
212
212
|
const idleMs = Number(current?.idleMs || HOUR_MS);
|
|
213
|
-
const custom = current?.custom === true;
|
|
214
|
-
const providerDefault = Number(current?.providerDefault || HOUR_MS);
|
|
215
213
|
const cacheTtlLabel = !enabled || idleMs >= HOUR_MS ? '1h' : '5m';
|
|
216
214
|
const items = [
|
|
217
215
|
{
|
|
@@ -226,9 +224,6 @@ export function createMaintenancePickers({
|
|
|
226
224
|
{
|
|
227
225
|
value: 'advanced',
|
|
228
226
|
label: 'Advanced',
|
|
229
|
-
marker: !custom ? '✓' : '',
|
|
230
|
-
markerColor: theme.success,
|
|
231
|
-
meta: `${current?.provider || 'current'} · ${formatDuration(providerDefault)}`,
|
|
232
227
|
description: 'Edit provider-paired default idle windows as text.',
|
|
233
228
|
_action: 'advanced',
|
|
234
229
|
},
|
|
@@ -84,10 +84,10 @@ export function createSlashDispatch({
|
|
|
84
84
|
return true;
|
|
85
85
|
}
|
|
86
86
|
case 'search':
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
// No busy guard: /search only picks the search provider/model (a config
|
|
88
|
+
// save consumed by the NEXT search tool call). It never touches the
|
|
89
|
+
// in-flight turn, and the same picker is already reachable mid-turn via
|
|
90
|
+
// /settings, so blocking it here was inconsistent.
|
|
91
91
|
if (arg) store.pushNotice('/search sets the search provider/model; the search tool uses that model when called.', 'warn');
|
|
92
92
|
openSearchPicker();
|
|
93
93
|
return true;
|
|
@@ -57,6 +57,17 @@ export function wrappedDetailRows(text, width) {
|
|
|
57
57
|
return wrapAnsi(value, w, { trim: false, hard: true }).split('\n').length;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Rows one queued steering band (QueuedCommands expanded mode) occupies when
|
|
61
|
+
// its full text renders with ink's wrap="wrap" at `width` content columns.
|
|
62
|
+
// Same wrap-ansi call ink uses, so the queuedRows reservation in App.jsx stays
|
|
63
|
+
// in lock-step with the rendered height — a mismatch pushes the input box.
|
|
64
|
+
export function queuedBandRows(text, width) {
|
|
65
|
+
const value = String(text ?? '');
|
|
66
|
+
if (!value) return 1;
|
|
67
|
+
const w = Math.max(1, Math.floor(Number(width) || 1));
|
|
68
|
+
return Math.max(1, wrapAnsi(value, w, { trim: false, hard: true }).split('\n').length);
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
// Character offsets where each visual row starts (hard wrap + explicit newlines).
|
|
61
72
|
export function visualRowStartOffsets(text, width) {
|
|
62
73
|
const value = String(text ?? '');
|