@oh-my-pi/pi-coding-agent 17.0.8 → 17.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/CHANGELOG.md +87 -1
- package/README.md +1 -1
- package/dist/cli.js +5495 -5021
- package/dist/types/async/job-manager.d.ts +37 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/bench-cli.d.ts +41 -0
- package/dist/types/commands/bench.d.ts +15 -3
- package/dist/types/commands/launch.d.ts +4 -0
- package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +30 -1
- package/dist/types/config/models-config-schema.d.ts +18 -6
- package/dist/types/config/models-config.d.ts +14 -5
- package/dist/types/config/provider-globals.d.ts +2 -2
- package/dist/types/config/service-tier.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +350 -50
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/dap/client.d.ts +20 -0
- package/dist/types/exec/bash-executor.d.ts +38 -0
- package/dist/types/exec/direnv.d.ts +33 -0
- package/dist/types/export/html/args.d.ts +15 -0
- package/dist/types/export/html/index.d.ts +11 -26
- package/dist/types/export/html/web-palette.d.ts +55 -116
- package/dist/types/extensibility/extensions/loader.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +17 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
- package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
- package/dist/types/live/controller.d.ts +40 -0
- package/dist/types/live/protocol.d.ts +85 -0
- package/dist/types/live/protocol.test.d.ts +1 -0
- package/dist/types/live/transport.d.ts +48 -0
- package/dist/types/live/visualizer.d.ts +33 -0
- package/dist/types/lsp/client.d.ts +18 -1
- package/dist/types/mcp/config-writer.test.d.ts +1 -0
- package/dist/types/mcp/manager.d.ts +6 -2
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/render.d.ts +2 -2
- package/dist/types/mcp/smithery-auth.d.ts +1 -1
- package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
- package/dist/types/mcp/tool-bridge.d.ts +7 -3
- package/dist/types/mcp/types.d.ts +6 -0
- package/dist/types/memory-backend/tool-names.d.ts +2 -0
- package/dist/types/modes/components/login-dialog.d.ts +2 -2
- package/dist/types/modes/components/oauth-selector.d.ts +8 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
- package/dist/types/modes/components/settings-defs.d.ts +8 -1
- package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +9 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +6 -1
- package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
- package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +12 -2
- package/dist/types/plan-mode/plan-files.d.ts +10 -0
- package/dist/types/registry/agent-lifecycle.d.ts +21 -9
- package/dist/types/registry/agent-registry.d.ts +11 -4
- package/dist/types/sdk.d.ts +15 -1
- package/dist/types/secrets/index.d.ts +15 -2
- package/dist/types/secrets/obfuscator.d.ts +113 -15
- package/dist/types/session/acp-permission-gate.d.ts +19 -0
- package/dist/types/session/agent-session-events.d.ts +84 -0
- package/dist/types/session/agent-session-types.d.ts +314 -0
- package/dist/types/session/agent-session.d.ts +120 -750
- package/dist/types/session/async-job-delivery.d.ts +28 -0
- package/dist/types/session/bash-runner.d.ts +75 -0
- package/dist/types/session/blob-store.d.ts +11 -1
- package/dist/types/session/blob-store.test.d.ts +1 -0
- package/dist/types/session/checkpoint-entries.d.ts +28 -0
- package/dist/types/session/eval-runner.d.ts +49 -0
- package/dist/types/session/irc-bridge.d.ts +46 -0
- package/dist/types/session/messages.d.ts +22 -0
- package/dist/types/session/model-controls.d.ts +180 -0
- package/dist/types/session/prewalk.d.ts +46 -0
- package/dist/types/session/queued-messages.d.ts +22 -0
- package/dist/types/session/redis-session-storage.d.ts +1 -0
- package/dist/types/session/retry-fallback-chains.d.ts +74 -0
- package/dist/types/session/role-models.d.ts +15 -0
- package/dist/types/session/session-advisors.d.ts +228 -0
- package/dist/types/session/session-entries.d.ts +8 -0
- package/dist/types/session/session-handoff.d.ts +70 -0
- package/dist/types/session/session-history-format.d.ts +6 -1
- package/dist/types/session/session-loader.d.ts +6 -4
- package/dist/types/session/session-maintenance.d.ts +246 -0
- package/dist/types/session/session-manager.d.ts +41 -0
- package/dist/types/session/session-memory.d.ts +56 -0
- package/dist/types/session/session-provider-boundary.d.ts +62 -0
- package/dist/types/session/session-stats.d.ts +48 -0
- package/dist/types/session/session-tools.d.ts +143 -0
- package/dist/types/session/session-workspace.d.ts +32 -0
- package/dist/types/session/stream-guards.d.ts +44 -0
- package/dist/types/session/todo-tracker.d.ts +56 -0
- package/dist/types/session/ttsr-coordinator.d.ts +51 -0
- package/dist/types/session/turn-recovery.d.ts +225 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +4 -1
- package/dist/types/task/output-manager.d.ts +3 -1
- package/dist/types/task/types.d.ts +14 -0
- package/dist/types/tools/approval.d.ts +1 -0
- package/dist/types/tools/browser/launch.d.ts +5 -0
- package/dist/types/tools/hub/jobs.d.ts +1 -1
- package/dist/types/tools/image-gen.d.ts +4 -4
- package/dist/types/tools/image-providers.d.ts +38 -0
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/path-utils.d.ts +8 -0
- package/dist/types/tools/report-tool-issue.d.ts +24 -9
- package/dist/types/tools/todo.d.ts +10 -4
- package/dist/types/tools/xdev.d.ts +5 -1
- package/dist/types/tts/streaming-player.d.ts +14 -0
- package/dist/types/tts/vocalizer.d.ts +5 -0
- package/dist/types/utils/jj.d.ts +29 -0
- package/dist/types/utils/lang-from-path.d.ts +1 -0
- package/dist/types/utils/title-generator.d.ts +30 -0
- package/dist/types/vibe/runtime.d.ts +36 -8
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +15 -5
- package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +95 -1
- package/package.json +13 -12
- package/scripts/generate-share-viewer.ts +4 -6
- package/scripts/legacy-pi-virtual-module.ts +1 -1
- package/src/advisor/__tests__/advisor.test.ts +643 -0
- package/src/advisor/runtime.ts +144 -43
- package/src/async/job-manager.ts +90 -2
- package/src/cli/args.ts +2 -0
- package/src/cli/auth-gateway-cli.ts +18 -3
- package/src/cli/bench-cli.ts +395 -29
- package/src/cli/flag-tables.ts +3 -0
- package/src/cli/gc-cli.ts +2 -2
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/usage-cli.ts +11 -2
- package/src/collab/host.ts +3 -2
- package/src/commands/bench.ts +18 -4
- package/src/commands/launch.ts +4 -0
- package/src/config/__tests__/model-registry.test.ts +147 -0
- package/src/config/model-registry.ts +50 -2
- package/src/config/model-resolver.ts +0 -1
- package/src/config/models-config-schema.ts +15 -4
- package/src/config/provider-globals.ts +9 -9
- package/src/config/service-tier.ts +26 -1
- package/src/config/settings-schema.ts +250 -54
- package/src/config/settings.ts +81 -2
- package/src/dap/client.ts +37 -4
- package/src/edit/index.ts +49 -12
- package/src/eval/py/executor.ts +103 -33
- package/src/exec/bash-executor.ts +89 -6
- package/src/exec/direnv.ts +145 -0
- package/src/export/html/args.ts +20 -0
- package/src/export/html/index.ts +45 -47
- package/src/export/html/template.css +19 -1
- package/src/export/html/template.html +6 -0
- package/src/export/html/template.js +21 -0
- package/src/export/html/tool-views.generated.js +31 -31
- package/src/export/html/web-palette.ts +116 -132
- package/src/export/share.ts +249 -49
- package/src/extensibility/extensions/loader.ts +29 -1
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +33 -2
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
- package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
- package/src/hindsight/state.ts +64 -6
- package/src/internal-urls/local-protocol.ts +2 -1
- package/src/internal-urls/memory-protocol.ts +2 -2
- package/src/internal-urls/registry-helpers.ts +40 -0
- package/src/internal-urls/skill-protocol.ts +2 -2
- package/src/internal-urls/ssh-protocol.ts +2 -1
- package/src/internal-urls/vault-protocol.ts +2 -1
- package/src/live/audio-worklet.txt +59 -0
- package/src/live/browser-runtime.txt +221 -0
- package/src/live/controller.ts +464 -0
- package/src/live/prompts/agent-final-message.md +3 -0
- package/src/live/prompts/live-instructions.md +23 -0
- package/src/live/protocol.test.ts +140 -0
- package/src/live/protocol.ts +233 -0
- package/src/live/transport.ts +490 -0
- package/src/live/visualizer.ts +234 -0
- package/src/lsp/client.ts +52 -9
- package/src/lsp/clients/biome-client.ts +3 -4
- package/src/lsp/index.ts +44 -11
- package/src/main.ts +9 -1
- package/src/mcp/config-writer.test.ts +43 -0
- package/src/mcp/config-writer.ts +109 -82
- package/src/mcp/manager.ts +39 -8
- package/src/mcp/oauth-discovery.ts +10 -2
- package/src/mcp/render.ts +94 -35
- package/src/mcp/smithery-auth.test.ts +29 -0
- package/src/mcp/smithery-auth.ts +3 -2
- package/src/mcp/tool-bridge.ts +107 -11
- package/src/mcp/types.ts +7 -0
- package/src/memories/index.ts +40 -20
- package/src/memory-backend/tool-names.ts +2 -0
- package/src/mnemopi/backend.ts +24 -7
- package/src/modes/acp/acp-agent.ts +56 -10
- package/src/modes/acp/acp-event-mapper.ts +8 -1
- package/src/modes/components/agent-hub.ts +27 -10
- package/src/modes/components/login-dialog.ts +14 -4
- package/src/modes/components/oauth-selector.ts +24 -7
- package/src/modes/components/plan-review-overlay.ts +350 -35
- package/src/modes/components/settings-defs.ts +28 -3
- package/src/modes/components/settings-selector.ts +178 -6
- package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
- package/src/modes/components/status-line/component.ts +123 -2
- package/src/modes/components/tool-execution.test.ts +63 -2
- package/src/modes/components/tool-execution.ts +11 -2
- package/src/modes/controllers/command-controller.ts +70 -22
- package/src/modes/controllers/event-controller.ts +111 -16
- package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
- package/src/modes/controllers/extension-ui-controller.ts +20 -4
- package/src/modes/controllers/live-command-controller.ts +178 -0
- package/src/modes/controllers/mcp-command-controller.ts +80 -41
- package/src/modes/controllers/omfg-controller.ts +44 -40
- package/src/modes/controllers/selector-controller.ts +18 -10
- package/src/modes/controllers/session-focus-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +160 -38
- package/src/modes/rpc/rpc-client.ts +94 -3
- package/src/modes/rpc/rpc-frame.ts +164 -4
- package/src/modes/rpc/rpc-messages.ts +127 -0
- package/src/modes/rpc/rpc-mode.ts +79 -7
- package/src/modes/rpc/rpc-types.ts +34 -2
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/setup-wizard/scenes/model.ts +5 -7
- package/src/modes/setup-wizard/scenes/providers.ts +3 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
- package/src/modes/setup-wizard/scenes/theme.ts +13 -3
- package/src/modes/setup-wizard/scenes/types.ts +9 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +3 -1
- package/src/modes/utils/context-usage.ts +23 -7
- package/src/modes/utils/ui-helpers.ts +10 -5
- package/src/plan-mode/plan-files.ts +40 -0
- package/src/prompts/bench/cache-prefix-chunk.md +1 -0
- package/src/prompts/bench/cache-prefix.md +3 -0
- package/src/prompts/bench/cache-suffix.md +1 -0
- package/src/prompts/goals/guided-goal-system.md +24 -3
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/project-prompt.md +8 -1
- package/src/prompts/system/subagent-async-pending.md +6 -0
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/system/workflow-notice.md +48 -40
- package/src/prompts/system/xdev-mount-notice.md +4 -0
- package/src/prompts/tools/task.md +12 -2
- package/src/prompts/tools/todo.md +3 -1
- package/src/registry/agent-lifecycle.ts +116 -46
- package/src/registry/agent-registry.ts +35 -8
- package/src/registry/persisted-agents.ts +26 -2
- package/src/sdk.ts +255 -139
- package/src/secrets/index.ts +127 -4
- package/src/secrets/obfuscator.ts +2328 -105
- package/src/session/acp-permission-gate.ts +165 -0
- package/src/session/agent-session-error-log.test.ts +1 -1
- package/src/session/agent-session-events.ts +66 -0
- package/src/session/agent-session-types.ts +334 -0
- package/src/session/agent-session.ts +1910 -12213
- package/src/session/artifacts.ts +6 -5
- package/src/session/async-job-delivery.ts +74 -0
- package/src/session/bash-runner.ts +326 -0
- package/src/session/blob-store.test.ts +56 -0
- package/src/session/blob-store.ts +18 -2
- package/src/session/checkpoint-entries.ts +81 -0
- package/src/session/eval-runner.ts +212 -0
- package/src/session/indexed-session-storage.ts +9 -2
- package/src/session/irc-bridge.ts +203 -0
- package/src/session/messages.ts +263 -1
- package/src/session/model-controls.ts +714 -0
- package/src/session/prewalk.ts +252 -0
- package/src/session/queued-messages.ts +93 -0
- package/src/session/redis-session-storage.ts +51 -11
- package/src/session/retry-fallback-chains.ts +455 -0
- package/src/session/role-models.ts +85 -0
- package/src/session/session-advisors.ts +1679 -0
- package/src/session/session-context.test.ts +224 -1
- package/src/session/session-context.ts +47 -5
- package/src/session/session-entries.ts +8 -0
- package/src/session/session-handoff.ts +308 -0
- package/src/session/session-history-format.ts +20 -9
- package/src/session/session-loader.ts +10 -51
- package/src/session/session-maintenance.ts +2983 -0
- package/src/session/session-manager.ts +390 -30
- package/src/session/session-memory.ts +222 -0
- package/src/session/session-provider-boundary.ts +307 -0
- package/src/session/session-stats.ts +293 -0
- package/src/session/session-tools.ts +943 -0
- package/src/session/session-workspace.ts +53 -0
- package/src/session/stream-guards.ts +417 -0
- package/src/session/todo-tracker.ts +380 -0
- package/src/session/ttsr-coordinator.ts +496 -0
- package/src/session/turn-recovery.ts +1629 -0
- package/src/slash-commands/builtin-registry.ts +92 -11
- package/src/slash-commands/helpers/usage-report.ts +25 -4
- package/src/system-prompt.ts +41 -17
- package/src/task/executor.ts +288 -70
- package/src/task/index.ts +148 -111
- package/src/task/output-manager.ts +25 -3
- package/src/task/persisted-revive.ts +4 -3
- package/src/task/structured-subagent.ts +4 -1
- package/src/task/types.ts +16 -0
- package/src/tools/approval.ts +55 -10
- package/src/tools/bash-interactive.ts +26 -0
- package/src/tools/bash-skill-urls.ts +28 -2
- package/src/tools/bash.ts +339 -123
- package/src/tools/browser/launch.ts +9 -1
- package/src/tools/browser/tab-supervisor.ts +8 -5
- package/src/tools/browser.ts +94 -47
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +67 -8
- package/src/tools/image-gen.ts +20 -28
- package/src/tools/image-providers.ts +50 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/path-utils.ts +49 -2
- package/src/tools/read.ts +156 -88
- package/src/tools/renderers.ts +7 -1
- package/src/tools/report-tool-issue.ts +79 -28
- package/src/tools/todo.ts +101 -11
- package/src/tools/vibe.ts +1 -2
- package/src/tools/write.ts +51 -1
- package/src/tools/xdev.ts +67 -3
- package/src/tts/streaming-player.ts +62 -4
- package/src/tts/vocalizer.ts +18 -1
- package/src/utils/jj.ts +125 -4
- package/src/utils/lang-from-path.ts +7 -0
- package/src/utils/title-generator.ts +137 -1
- package/src/vibe/runtime.ts +852 -72
- package/src/web/search/index.ts +3 -6
- package/src/web/search/provider.ts +31 -16
- package/src/web/search/providers/firecrawl.ts +46 -13
- package/src/web/search/providers/xai.ts +7 -1
- package/src/web/search/types.ts +8 -1
|
@@ -65,6 +65,7 @@ class TextInputSubmenu extends Container {
|
|
|
65
65
|
label: string,
|
|
66
66
|
description: string,
|
|
67
67
|
currentValue: string,
|
|
68
|
+
secret: boolean,
|
|
68
69
|
private readonly onSubmit: (value: string) => void,
|
|
69
70
|
private readonly onCancel: () => void,
|
|
70
71
|
) {
|
|
@@ -78,6 +79,7 @@ class TextInputSubmenu extends Container {
|
|
|
78
79
|
this.addChild(new Spacer(1));
|
|
79
80
|
|
|
80
81
|
this.#input = new Input();
|
|
82
|
+
this.#input.mask = secret;
|
|
81
83
|
if (currentValue) {
|
|
82
84
|
this.#input.setValue(currentValue);
|
|
83
85
|
}
|
|
@@ -219,6 +221,131 @@ class SelectSubmenu extends Container {
|
|
|
219
221
|
}
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Submenu for array-of-enum settings: every option is a toggle row. Enter or
|
|
226
|
+
* Space flips membership; ordered lists render 1-based positions and reorder
|
|
227
|
+
* the highlighted member with ←/→. Changes apply live; Esc goes back.
|
|
228
|
+
*/
|
|
229
|
+
class MultiSelectSubmenu extends Container {
|
|
230
|
+
#selectList!: SelectList;
|
|
231
|
+
#value: string[];
|
|
232
|
+
#cursor = 0;
|
|
233
|
+
#selectListLineOffset = 0;
|
|
234
|
+
|
|
235
|
+
constructor(
|
|
236
|
+
private readonly title: string,
|
|
237
|
+
private readonly description: string,
|
|
238
|
+
private readonly options: ReadonlyArray<SelectItem>,
|
|
239
|
+
initial: readonly string[],
|
|
240
|
+
private readonly ordered: boolean,
|
|
241
|
+
private readonly onApply: (value: string[]) => void,
|
|
242
|
+
private readonly onClose: () => void,
|
|
243
|
+
) {
|
|
244
|
+
super();
|
|
245
|
+
// Drop stale ids (renamed/removed providers) so positions stay contiguous.
|
|
246
|
+
this.#value = initial.filter(id => options.some(option => option.value === id));
|
|
247
|
+
this.#rebuild();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#rebuild(): void {
|
|
251
|
+
this.clear();
|
|
252
|
+
this.addChild(new Text(theme.bold(theme.fg("accent", this.title)), 0, 0));
|
|
253
|
+
if (this.description) {
|
|
254
|
+
this.addChild(new Spacer(1));
|
|
255
|
+
this.addChild(new Text(theme.fg("muted", this.description), 0, 0));
|
|
256
|
+
}
|
|
257
|
+
this.addChild(new Spacer(1));
|
|
258
|
+
|
|
259
|
+
const items = this.options.map((option): SelectItem => {
|
|
260
|
+
const position = this.#value.indexOf(option.value);
|
|
261
|
+
const mark =
|
|
262
|
+
position === -1
|
|
263
|
+
? theme.fg("dim", this.ordered ? " · " : " ○ ")
|
|
264
|
+
: this.ordered
|
|
265
|
+
? theme.fg("accent", `${String(position + 1).padStart(2)}.`)
|
|
266
|
+
: theme.fg("accent", " ● ");
|
|
267
|
+
return { value: option.value, label: `${mark} ${option.label}`, description: option.description };
|
|
268
|
+
});
|
|
269
|
+
this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
|
|
270
|
+
this.#selectList.setSelectedIndex(this.#cursor);
|
|
271
|
+
this.#selectList.onSelect = item => this.#toggle(item.value);
|
|
272
|
+
this.#selectList.onSelectionChange = item => {
|
|
273
|
+
this.#cursor = this.options.findIndex(option => option.value === item.value);
|
|
274
|
+
};
|
|
275
|
+
this.#selectList.onCancel = this.onClose;
|
|
276
|
+
this.addChild(this.#selectList);
|
|
277
|
+
|
|
278
|
+
this.addChild(new Spacer(1));
|
|
279
|
+
const hint = this.ordered
|
|
280
|
+
? " Enter/Space to toggle · ←/→ move · 1-9 place at position · Esc to go back"
|
|
281
|
+
: " Enter/Space to toggle · Esc to go back";
|
|
282
|
+
this.addChild(new Text(theme.fg("dim", hint), 0, 0));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#apply(next: string[]): void {
|
|
286
|
+
this.#value = next;
|
|
287
|
+
this.onApply([...next]);
|
|
288
|
+
this.#rebuild();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#toggle(id: string): void {
|
|
292
|
+
const next = this.#value.includes(id) ? this.#value.filter(v => v !== id) : [...this.#value, id];
|
|
293
|
+
this.#apply(next);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#move(id: string, delta: -1 | 1): void {
|
|
297
|
+
const from = this.#value.indexOf(id);
|
|
298
|
+
if (from === -1) return;
|
|
299
|
+
const to = from + delta;
|
|
300
|
+
if (to < 0 || to >= this.#value.length) return;
|
|
301
|
+
const next = [...this.#value];
|
|
302
|
+
next[from] = next[to]!;
|
|
303
|
+
next[to] = id;
|
|
304
|
+
this.#apply(next);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Splice the option into the 1-based `position` of the selection (adding it if unselected). */
|
|
308
|
+
#placeAt(id: string, position: number): void {
|
|
309
|
+
const next = this.#value.filter(v => v !== id);
|
|
310
|
+
next.splice(Math.min(position - 1, next.length), 0, id);
|
|
311
|
+
this.#apply(next);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Concatenate children, recording the select list's line offset for mouse routing. */
|
|
315
|
+
override render(width: number): readonly string[] {
|
|
316
|
+
const lines: string[] = [];
|
|
317
|
+
for (const child of this.children) {
|
|
318
|
+
const childLines = child.render(Math.max(1, width));
|
|
319
|
+
if (child === this.#selectList) {
|
|
320
|
+
this.#selectListLineOffset = lines.length;
|
|
321
|
+
}
|
|
322
|
+
lines.push(...childLines);
|
|
323
|
+
}
|
|
324
|
+
return lines;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
|
|
328
|
+
routeSelectListMouse(this.#selectList, event, line - this.#selectListLineOffset);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
handleInput(data: string): void {
|
|
332
|
+
const current = this.options[this.#cursor]?.value;
|
|
333
|
+
if (data === " " && current !== undefined) {
|
|
334
|
+
this.#toggle(current);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (this.ordered && current !== undefined && (data === "\x1b[D" || data === "\x1b[C")) {
|
|
338
|
+
this.#move(current, data === "\x1b[D" ? -1 : 1);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (this.ordered && current !== undefined && data.length === 1 && data >= "1" && data <= "9") {
|
|
342
|
+
this.#placeAt(current, Number(data));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.#selectList.handleInput(data);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
222
349
|
class ProviderLimitsSubmenu extends Container {
|
|
223
350
|
#selectList: SelectList | undefined;
|
|
224
351
|
|
|
@@ -293,6 +420,7 @@ class ProviderLimitsSubmenu extends Container {
|
|
|
293
420
|
`Max In-Flight Requests: ${provider}`,
|
|
294
421
|
"Enter a positive number. Decimals round down. Clear the field to make this provider unlimited.",
|
|
295
422
|
limits[provider]?.toString() ?? "",
|
|
423
|
+
false,
|
|
296
424
|
value => {
|
|
297
425
|
const next = { ...limits };
|
|
298
426
|
const trimmed = value.trim();
|
|
@@ -837,7 +965,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
837
965
|
id: def.path,
|
|
838
966
|
label: def.label,
|
|
839
967
|
description: def.description,
|
|
840
|
-
currentValue: this.#formatTextInputValue(def
|
|
968
|
+
currentValue: this.#formatTextInputValue(def, currentValue),
|
|
841
969
|
submenu: (cv, done) => this.#createTextInput(def, cv, done),
|
|
842
970
|
changed,
|
|
843
971
|
};
|
|
@@ -851,6 +979,16 @@ export class SettingsSelectorComponent implements Component {
|
|
|
851
979
|
submenu: (_cv, done) => this.#createProviderLimitsInput(done),
|
|
852
980
|
changed,
|
|
853
981
|
};
|
|
982
|
+
|
|
983
|
+
case "multiselect":
|
|
984
|
+
return {
|
|
985
|
+
id: def.path,
|
|
986
|
+
label: def.label,
|
|
987
|
+
description: def.description,
|
|
988
|
+
currentValue: this.#formatMultiSelectValue(def, currentValue),
|
|
989
|
+
submenu: (_cv, done) => this.#createMultiSelect(def, done),
|
|
990
|
+
changed,
|
|
991
|
+
};
|
|
854
992
|
}
|
|
855
993
|
}
|
|
856
994
|
|
|
@@ -862,7 +1000,14 @@ export class SettingsSelectorComponent implements Component {
|
|
|
862
1000
|
}
|
|
863
1001
|
|
|
864
1002
|
#isChanged(def: SettingDef, currentValue: unknown): boolean {
|
|
865
|
-
|
|
1003
|
+
const defaultValue: unknown = getDefault(def.path);
|
|
1004
|
+
if (Array.isArray(currentValue) && Array.isArray(defaultValue)) {
|
|
1005
|
+
return (
|
|
1006
|
+
currentValue.length !== defaultValue.length ||
|
|
1007
|
+
currentValue.some((entry, index) => entry !== defaultValue[index])
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
return !Object.is(currentValue, defaultValue);
|
|
866
1011
|
}
|
|
867
1012
|
|
|
868
1013
|
#getSubmenuCurrentValue(path: SettingPath, value: unknown): string {
|
|
@@ -992,12 +1137,13 @@ export class SettingsSelectorComponent implements Component {
|
|
|
992
1137
|
def.label,
|
|
993
1138
|
def.description,
|
|
994
1139
|
this.#formatTextInputEditValue(def.path, settings.get(def.path)),
|
|
1140
|
+
def.secret,
|
|
995
1141
|
value => {
|
|
996
1142
|
// Empty string clears the setting; undefined-typed string settings
|
|
997
1143
|
// store "" which the browser.ts expandPath ignores (no-op fallback).
|
|
998
1144
|
this.#setSettingValue(def.path, value);
|
|
999
1145
|
this.callbacks.onChange(def.path, settings.get(def.path));
|
|
1000
|
-
wrappedDone(this.#formatTextInputValue(def
|
|
1146
|
+
wrappedDone(this.#formatTextInputValue(def, settings.get(def.path)));
|
|
1001
1147
|
},
|
|
1002
1148
|
() => wrappedDone(),
|
|
1003
1149
|
);
|
|
@@ -1022,9 +1168,35 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1022
1168
|
return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
|
|
1023
1169
|
}
|
|
1024
1170
|
|
|
1025
|
-
#
|
|
1026
|
-
|
|
1027
|
-
|
|
1171
|
+
#createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
|
|
1172
|
+
const current: unknown = settings.get(def.path);
|
|
1173
|
+
const initial = Array.isArray(current)
|
|
1174
|
+
? current.filter((entry): entry is string => typeof entry === "string")
|
|
1175
|
+
: [];
|
|
1176
|
+
return new MultiSelectSubmenu(
|
|
1177
|
+
def.label,
|
|
1178
|
+
def.description,
|
|
1179
|
+
def.options,
|
|
1180
|
+
initial,
|
|
1181
|
+
def.ordered,
|
|
1182
|
+
value => {
|
|
1183
|
+
settings.set(def.path, value as never);
|
|
1184
|
+
this.callbacks.onChange(def.path, value);
|
|
1185
|
+
},
|
|
1186
|
+
() => done(this.#formatMultiSelectValue(def, settings.get(def.path))),
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
#formatMultiSelectValue(def: SettingDef & { type: "multiselect" }, value: unknown): string {
|
|
1191
|
+
const ids = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
1192
|
+
if (ids.length === 0) return def.ordered ? "default" : "none";
|
|
1193
|
+
const labels = ids.map(id => def.options.find(option => option.value === id)?.label ?? id);
|
|
1194
|
+
return def.ordered ? labels.join(" → ") : labels.join(", ");
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
#formatTextInputValue(def: SettingDef & { type: "text" }, value: unknown): string {
|
|
1198
|
+
if (def.secret) return value ? "••••••••" : "";
|
|
1199
|
+
return this.#formatTextInputEditValue(def.path, value);
|
|
1028
1200
|
}
|
|
1029
1201
|
|
|
1030
1202
|
#formatTextInputEditValue(_path: SettingPath, value: unknown): string {
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { Settings, settings } from "../../../config/settings";
|
|
7
|
+
import type { AgentSession } from "../../../session/agent-session";
|
|
8
|
+
import * as git from "../../../utils/git";
|
|
9
|
+
import * as jj from "../../../utils/jj";
|
|
10
|
+
import { getThemeByName, setThemeInstance } from "../../theme/theme";
|
|
11
|
+
import { StatusLineComponent } from "./component";
|
|
12
|
+
|
|
13
|
+
// Minimal session the git-only status line render path touches: state.messages
|
|
14
|
+
// (token-rate scan), model window, streaming flag, and the async-job snapshot.
|
|
15
|
+
// The `git` segment reads none of these — they only keep #buildStatusLine from
|
|
16
|
+
// throwing while it renders the single segment under test.
|
|
17
|
+
function makeSession(): AgentSession {
|
|
18
|
+
const model = { contextWindow: 128000 } as const;
|
|
19
|
+
return {
|
|
20
|
+
state: { messages: [], model },
|
|
21
|
+
messages: [],
|
|
22
|
+
model,
|
|
23
|
+
isStreaming: false,
|
|
24
|
+
sessionFile: undefined,
|
|
25
|
+
getAsyncJobSnapshot: () => null,
|
|
26
|
+
getContextUsage: () => ({ tokens: 0, contextWindow: 128000 }),
|
|
27
|
+
} as unknown as AgentSession;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Drain the microtask queue so the fire-and-forget async jj lookups started by a
|
|
31
|
+
// render() have applied their result to the cache. No real timers: only
|
|
32
|
+
// already-resolved promises are awaited, so this stays deterministic.
|
|
33
|
+
async function flushMicrotasks(): Promise<void> {
|
|
34
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Strip SGR/OSC escapes so assertions match on the visible branch label text.
|
|
38
|
+
function visible(s: string): string {
|
|
39
|
+
return s.replace(/\x1b\][^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const WIDTH = 200;
|
|
43
|
+
|
|
44
|
+
let spies: Array<{ mockRestore(): void }> = [];
|
|
45
|
+
let tmpA: string;
|
|
46
|
+
let tmpB: string;
|
|
47
|
+
let originalProjectDir: string;
|
|
48
|
+
const ROOT_A = "/virtual/jj-root-a";
|
|
49
|
+
const ROOT_B = "/virtual/jj-root-b";
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
await Settings.init({ inMemory: true });
|
|
53
|
+
const loaded = await getThemeByName("dark");
|
|
54
|
+
if (!loaded) throw new Error("theme unavailable");
|
|
55
|
+
setThemeInstance(loaded);
|
|
56
|
+
// Render only the git segment: shrinks the render surface to the code under
|
|
57
|
+
// test and keeps the fake session tiny. Constructor snapshots these, so they
|
|
58
|
+
// must be set before any `new StatusLineComponent`.
|
|
59
|
+
settings.override("statusLine.preset", "custom");
|
|
60
|
+
settings.override("statusLine.leftSegments", ["git"]);
|
|
61
|
+
settings.override("statusLine.rightSegments", []);
|
|
62
|
+
originalProjectDir = getProjectDir();
|
|
63
|
+
tmpA = fs.mkdtempSync(path.join(os.tmpdir(), "jjcache-a-"));
|
|
64
|
+
tmpB = fs.mkdtempSync(path.join(os.tmpdir(), "jjcache-b-"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
settings.clearOverride("statusLine.preset");
|
|
69
|
+
settings.clearOverride("statusLine.leftSegments");
|
|
70
|
+
settings.clearOverride("statusLine.rightSegments");
|
|
71
|
+
setProjectDir(originalProjectDir);
|
|
72
|
+
fs.rmSync(tmpA, { recursive: true, force: true });
|
|
73
|
+
fs.rmSync(tmpB, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
// Force the jj fallback branch in #buildSegmentContext: git HEAD resolves to
|
|
78
|
+
// null (so gitBranch === null triggers #getJjBranch), and no repo/worktree is
|
|
79
|
+
// detected (so effectiveGitCwd stays the raw project dir we control).
|
|
80
|
+
spies = [
|
|
81
|
+
spyOn(git.head, "resolveSync").mockReturnValue(null),
|
|
82
|
+
spyOn(git.repo, "resolveSync").mockReturnValue(null),
|
|
83
|
+
spyOn(git.repo, "linkedWorktreeSync").mockReturnValue(null),
|
|
84
|
+
// jj status: return a clean summary so #getJjStatus never falls through to
|
|
85
|
+
// the real `git status` subprocess. Keeps the git segment's status empty so
|
|
86
|
+
// the visible content is exactly the branch label.
|
|
87
|
+
spyOn(jj.status, "summary").mockResolvedValue({ staged: 0, unstaged: 0, untracked: 0 }),
|
|
88
|
+
// Map each controlled project dir to a stable virtual jj root.
|
|
89
|
+
spyOn(jj.repo, "rootSync").mockImplementation(cwd => {
|
|
90
|
+
if (cwd === tmpA) return ROOT_A;
|
|
91
|
+
if (cwd === tmpB) return ROOT_B;
|
|
92
|
+
return null;
|
|
93
|
+
}),
|
|
94
|
+
];
|
|
95
|
+
setProjectDir(tmpA);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
for (const s of spies) s.mockRestore();
|
|
100
|
+
spies = [];
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("StatusLineComponent jj cache coherence", () => {
|
|
104
|
+
it("invalidate() drops the throttled jj branch cache within its TTL and refetches", async () => {
|
|
105
|
+
// A live jj bookmark label; a second query for the SAME root returns a new
|
|
106
|
+
// label, simulating a colocated bookmark/HEAD move mid-TTL.
|
|
107
|
+
const branchSpy = spyOn(jj.workingCopy, "label").mockResolvedValue("bookmark-v1");
|
|
108
|
+
spies.push(branchSpy);
|
|
109
|
+
|
|
110
|
+
const statusLine = new StatusLineComponent(makeSession());
|
|
111
|
+
|
|
112
|
+
// First render kicks off the async lookup (returns the empty cache); after
|
|
113
|
+
// it resolves, a second render paints the fetched label.
|
|
114
|
+
statusLine.getTopBorder(WIDTH);
|
|
115
|
+
await flushMicrotasks();
|
|
116
|
+
expect(visible(statusLine.getTopBorder(WIDTH).content)).toContain("bookmark-v1");
|
|
117
|
+
expect(branchSpy).toHaveBeenCalledTimes(1);
|
|
118
|
+
|
|
119
|
+
// Move the bookmark: a plain render within the 5s TTL must keep serving the
|
|
120
|
+
// cached label without a refetch (guards that the TTL is real, so the next
|
|
121
|
+
// assertion proves invalidate() — not TTL expiry — forces the refresh).
|
|
122
|
+
branchSpy.mockResolvedValue("bookmark-v2");
|
|
123
|
+
const throttled = visible(statusLine.getTopBorder(WIDTH).content);
|
|
124
|
+
await flushMicrotasks();
|
|
125
|
+
expect(throttled).toContain("bookmark-v1");
|
|
126
|
+
expect(branchSpy).toHaveBeenCalledTimes(1);
|
|
127
|
+
|
|
128
|
+
// invalidate() (public watcher trigger) must reset the jj caches so the
|
|
129
|
+
// next render refetches despite being inside the TTL, and paint the new
|
|
130
|
+
// label — the finding-1 contract.
|
|
131
|
+
statusLine.invalidate();
|
|
132
|
+
statusLine.getTopBorder(WIDTH);
|
|
133
|
+
await flushMicrotasks();
|
|
134
|
+
expect(branchSpy).toHaveBeenCalledTimes(2);
|
|
135
|
+
expect(visible(statusLine.getTopBorder(WIDTH).content)).toContain("bookmark-v2");
|
|
136
|
+
expect(visible(statusLine.getTopBorder(WIDTH).content)).not.toContain("bookmark-v1");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("a jj lookup that resolves after the root changed never lands in the new root's cache", async () => {
|
|
140
|
+
// Root A's query hangs on a deferred so it is still in flight when we
|
|
141
|
+
// switch repos; root B resolves with its own label.
|
|
142
|
+
const deferredA = Promise.withResolvers<string | null>();
|
|
143
|
+
const branchSpy = spyOn(jj.workingCopy, "label").mockImplementation(async root => {
|
|
144
|
+
if (root === ROOT_A) return deferredA.promise;
|
|
145
|
+
if (root === ROOT_B) return "branch-B";
|
|
146
|
+
return null;
|
|
147
|
+
});
|
|
148
|
+
spies.push(branchSpy);
|
|
149
|
+
|
|
150
|
+
const statusLine = new StatusLineComponent(makeSession());
|
|
151
|
+
|
|
152
|
+
// Render in repo A: starts the (hanging) lookup for ROOT_A. Nothing painted
|
|
153
|
+
// yet — the cache is empty and the query is unresolved.
|
|
154
|
+
expect(visible(statusLine.getTopBorder(WIDTH).content)).not.toContain("branch-A-STALE");
|
|
155
|
+
expect(branchSpy).toHaveBeenCalledTimes(1);
|
|
156
|
+
|
|
157
|
+
// Switch to repo B mid-flight. #jjRootFor(tmpB) re-points #jjRoot to ROOT_B
|
|
158
|
+
// and resets the jj caches; ROOT_A's lookup is now stale. The render can't
|
|
159
|
+
// start B's lookup yet — the single in-flight flag is still held by A.
|
|
160
|
+
setProjectDir(tmpB);
|
|
161
|
+
statusLine.getTopBorder(WIDTH);
|
|
162
|
+
await flushMicrotasks();
|
|
163
|
+
expect(branchSpy).toHaveBeenCalledTimes(1);
|
|
164
|
+
|
|
165
|
+
// Let ROOT_A's slow query finish, then drain its continuation. The
|
|
166
|
+
// root-keyed guard must DROP it: #jjRoot is ROOT_B, so A's label must never
|
|
167
|
+
// become B's cached branch, and A's completion must not advance B's
|
|
168
|
+
// throttle (leaving B free to refetch) — the finding-2/4 contract.
|
|
169
|
+
deferredA.resolve("branch-A-STALE");
|
|
170
|
+
await flushMicrotasks();
|
|
171
|
+
|
|
172
|
+
// The very next render paints synchronously from the current cache BEFORE
|
|
173
|
+
// any new fetch resolves. This is the only window the transient stale is
|
|
174
|
+
// observable: with the fix the cache is empty here (A's result was
|
|
175
|
+
// dropped), so this render both refuses to paint A's label AND is free to
|
|
176
|
+
// launch B's refetch. Without the fix, A's stale label is already sitting
|
|
177
|
+
// in B's cache and paints here.
|
|
178
|
+
const paintedImmediate = visible(statusLine.getTopBorder(WIDTH).content);
|
|
179
|
+
expect(paintedImmediate).not.toContain("branch-A-STALE");
|
|
180
|
+
|
|
181
|
+
// Steady state: B's (now unthrottled) refetch resolves and paints B's real
|
|
182
|
+
// label — confirming the switch left B's cache coherent, not poisoned.
|
|
183
|
+
await flushMicrotasks();
|
|
184
|
+
expect(visible(statusLine.getTopBorder(WIDTH).content)).toContain("branch-B");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("a jj lookup that resolves after a same-root invalidation never lands as stale", async () => {
|
|
188
|
+
// The finding-6 race: a HEAD/bookmark move invalidates the caches while a
|
|
189
|
+
// branch query for the SAME root is still in flight. #invalidateGitCaches
|
|
190
|
+
// resets #jjRoot, but the next render re-resolves it to the identical root
|
|
191
|
+
// string — so a guard keyed only on root equality would accept the
|
|
192
|
+
// pre-invalidation result. The generation token must reject it.
|
|
193
|
+
const deferred = Promise.withResolvers<string | null>();
|
|
194
|
+
let call = 0;
|
|
195
|
+
const branchSpy = spyOn(jj.workingCopy, "label").mockImplementation(async () => {
|
|
196
|
+
call++;
|
|
197
|
+
// First query (pre-invalidation) hangs; later queries return the fresh label.
|
|
198
|
+
return call === 1 ? deferred.promise : "bookmark-fresh";
|
|
199
|
+
});
|
|
200
|
+
spies.push(branchSpy);
|
|
201
|
+
|
|
202
|
+
const statusLine = new StatusLineComponent(makeSession());
|
|
203
|
+
|
|
204
|
+
// Render in ROOT_A: starts the (hanging) first lookup. Nothing cached yet.
|
|
205
|
+
statusLine.getTopBorder(WIDTH);
|
|
206
|
+
await flushMicrotasks();
|
|
207
|
+
expect(branchSpy).toHaveBeenCalledTimes(1);
|
|
208
|
+
|
|
209
|
+
// A HEAD/bookmark move fires the watcher → invalidate(). The cwd is
|
|
210
|
+
// unchanged, so the next #jjRootFor re-resolves #jjRoot to the SAME ROOT_A.
|
|
211
|
+
statusLine.invalidate();
|
|
212
|
+
statusLine.getTopBorder(WIDTH);
|
|
213
|
+
await flushMicrotasks();
|
|
214
|
+
|
|
215
|
+
// The stale first query now resolves. The generation captured at its launch
|
|
216
|
+
// no longer matches (invalidate bumped it), so its label must be dropped —
|
|
217
|
+
// never cached, and the throttle must NOT be advanced on it.
|
|
218
|
+
deferred.resolve("bookmark-stale");
|
|
219
|
+
await flushMicrotasks();
|
|
220
|
+
const paintedImmediate = visible(statusLine.getTopBorder(WIDTH).content);
|
|
221
|
+
expect(paintedImmediate).not.toContain("bookmark-stale");
|
|
222
|
+
|
|
223
|
+
// Because the stale result did not advance the throttle, the post-invalidate
|
|
224
|
+
// cache is free to refetch and paint the fresh label.
|
|
225
|
+
await flushMicrotasks();
|
|
226
|
+
expect(visible(statusLine.getTopBorder(WIDTH).content)).toContain("bookmark-fresh");
|
|
227
|
+
expect(branchSpy).toHaveBeenCalledTimes(2);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -10,6 +10,7 @@ import type { OAuthAccountIdentity } from "../../../session/auth-storage";
|
|
|
10
10
|
import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/active-oauth-account";
|
|
11
11
|
import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context";
|
|
12
12
|
import * as git from "../../../utils/git";
|
|
13
|
+
import * as jj from "../../../utils/jj";
|
|
13
14
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
|
14
15
|
import { calculateTokensPerSecond } from "../../../utils/token-rate";
|
|
15
16
|
import { sanitizeStatusText } from "../../shared";
|
|
@@ -26,6 +27,8 @@ import type {
|
|
|
26
27
|
StatusLineSettings,
|
|
27
28
|
} from "./types";
|
|
28
29
|
|
|
30
|
+
const JJ_REFRESH_TTL_MS = 5000;
|
|
31
|
+
|
|
29
32
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
30
33
|
// Context-usage memo
|
|
31
34
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -292,6 +295,21 @@ export class StatusLineComponent implements Component {
|
|
|
292
295
|
#cachedGitStatusCwd: string | undefined = undefined;
|
|
293
296
|
#gitStatusLastFetch = 0;
|
|
294
297
|
#gitStatusInFlightCwd: string | undefined = undefined;
|
|
298
|
+
#jjRoot: string | null | undefined = undefined;
|
|
299
|
+
#jjRootCwd: string | undefined = undefined;
|
|
300
|
+
#cachedJjBranch: string | null = null;
|
|
301
|
+
#jjBranchLastFetch = 0;
|
|
302
|
+
#jjBranchInFlight = false;
|
|
303
|
+
#cachedJjStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
304
|
+
#jjStatusLastFetch = 0;
|
|
305
|
+
#jjStatusInFlight = false;
|
|
306
|
+
// Bumped on every jj-cache reset — a cwd switch (#jjRootFor) or a HEAD /
|
|
307
|
+
// bookmark move (#invalidateGitCaches). An in-flight jj query captures this
|
|
308
|
+
// at launch; a mismatch on resolve means the caches were reset underneath it
|
|
309
|
+
// (including a reset that re-resolves to the SAME root, which a root-equality
|
|
310
|
+
// check cannot detect), so the result is stale and must be dropped rather
|
|
311
|
+
// than poison the fresh cache or advance its throttle.
|
|
312
|
+
#jjCacheGeneration = 0;
|
|
295
313
|
|
|
296
314
|
// PR lookup caching (invalidated on branch/repo context changes)
|
|
297
315
|
#cachedPr: { number: number; url: string } | null | undefined = undefined;
|
|
@@ -609,6 +627,16 @@ export class StatusLineComponent implements Component {
|
|
|
609
627
|
this.#cachedBranchRepoId = undefined;
|
|
610
628
|
this.#cachedBranchCwd = undefined;
|
|
611
629
|
this.#cachedPrContext = undefined;
|
|
630
|
+
// jj label/status share the git segment's lifecycle: a HEAD move (e.g. a
|
|
631
|
+
// colocated `jj new`/bookmark move) must drop the throttled jj caches too,
|
|
632
|
+
// mirroring #jjRootFor's per-cwd reset so the next render refetches.
|
|
633
|
+
this.#jjRoot = undefined;
|
|
634
|
+
this.#jjRootCwd = undefined;
|
|
635
|
+
this.#cachedJjBranch = null;
|
|
636
|
+
this.#jjBranchLastFetch = 0;
|
|
637
|
+
this.#cachedJjStatus = null;
|
|
638
|
+
this.#jjStatusLastFetch = 0;
|
|
639
|
+
this.#jjCacheGeneration++;
|
|
612
640
|
}
|
|
613
641
|
#getCurrentBranch(effectiveGitCwd?: string): string | null {
|
|
614
642
|
if (!this.#gitEnabled()) return null;
|
|
@@ -676,10 +704,14 @@ export class StatusLineComponent implements Component {
|
|
|
676
704
|
nextStatus = null;
|
|
677
705
|
} finally {
|
|
678
706
|
if (this.#gitStatusInFlightCwd === gitCwd) {
|
|
707
|
+
const prev = this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
|
679
708
|
this.#cachedGitStatus = nextStatus;
|
|
680
709
|
this.#cachedGitStatusCwd = gitCwd;
|
|
681
710
|
this.#gitStatusLastFetch = Date.now();
|
|
682
711
|
this.#gitStatusInFlightCwd = undefined;
|
|
712
|
+
if (!this.#disposed && this.#onBranchChange && JSON.stringify(prev) !== JSON.stringify(nextStatus)) {
|
|
713
|
+
this.#onBranchChange();
|
|
714
|
+
}
|
|
683
715
|
}
|
|
684
716
|
}
|
|
685
717
|
})();
|
|
@@ -687,6 +719,83 @@ export class StatusLineComponent implements Component {
|
|
|
687
719
|
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
|
688
720
|
}
|
|
689
721
|
|
|
722
|
+
// Resolve (and cache per cwd) the jj workspace root, resetting both jj caches
|
|
723
|
+
// on a cwd change so a directory switch refetches label + status.
|
|
724
|
+
#jjRootFor(cwd: string): string | null {
|
|
725
|
+
if (this.#jjRoot === undefined || this.#jjRootCwd !== cwd) {
|
|
726
|
+
this.#jjRootCwd = cwd;
|
|
727
|
+
this.#jjRoot = jj.repo.rootSync(cwd);
|
|
728
|
+
this.#cachedJjBranch = null;
|
|
729
|
+
this.#jjBranchLastFetch = 0;
|
|
730
|
+
this.#cachedJjStatus = null;
|
|
731
|
+
this.#jjStatusLastFetch = 0;
|
|
732
|
+
this.#jjCacheGeneration++;
|
|
733
|
+
}
|
|
734
|
+
return this.#jjRoot;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// jj working-copy bookmark label (nearest bookmark, change-id fallback), shown
|
|
738
|
+
// in the `git` segment where git HEAD is detached/absent under jj. Throttled,
|
|
739
|
+
// cached, and repaints on resolve.
|
|
740
|
+
#getJjBranch(effectiveGitCwd?: string): string | null {
|
|
741
|
+
const cwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
|
|
742
|
+
const root = this.#jjRootFor(cwd);
|
|
743
|
+
if (!root) return null;
|
|
744
|
+
if (this.#jjBranchInFlight || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
|
|
745
|
+
return this.#cachedJjBranch;
|
|
746
|
+
}
|
|
747
|
+
this.#jjBranchInFlight = true;
|
|
748
|
+
const generation = this.#jjCacheGeneration;
|
|
749
|
+
(async () => {
|
|
750
|
+
let next: string | null = null;
|
|
751
|
+
try {
|
|
752
|
+
next = await jj.workingCopy.label(root);
|
|
753
|
+
} finally {
|
|
754
|
+
this.#jjBranchInFlight = false;
|
|
755
|
+
// Advance the throttle only if no reset raced this query; a reset
|
|
756
|
+
// leaves LastFetch at 0 so the current root refetches instead of
|
|
757
|
+
// being throttled on a superseded result.
|
|
758
|
+
if (this.#jjCacheGeneration === generation) this.#jjBranchLastFetch = Date.now();
|
|
759
|
+
}
|
|
760
|
+
// Drop a result whose caches were reset mid-flight — a repo switch OR a
|
|
761
|
+
// same-root HEAD/bookmark move — so a superseded label never lands in
|
|
762
|
+
// the live cache.
|
|
763
|
+
if (this.#jjCacheGeneration !== generation || this.#disposed) return;
|
|
764
|
+
const changed = next !== this.#cachedJjBranch;
|
|
765
|
+
this.#cachedJjBranch = next;
|
|
766
|
+
if (changed && this.#onBranchChange) this.#onBranchChange();
|
|
767
|
+
})();
|
|
768
|
+
return this.#cachedJjBranch;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// jj working-copy status counts (`@` vs its parent), used in place of git
|
|
772
|
+
// status in a jj repo where `git status` has no `.git` to read. Throttled,
|
|
773
|
+
// cached, and repaints on resolve like #getJjBranch.
|
|
774
|
+
#getJjStatus(effectiveGitCwd?: string): { staged: number; unstaged: number; untracked: number } | null {
|
|
775
|
+
const cwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
|
|
776
|
+
const root = this.#jjRootFor(cwd);
|
|
777
|
+
if (!root) return null;
|
|
778
|
+
if (this.#jjStatusInFlight || Date.now() - this.#jjStatusLastFetch < JJ_REFRESH_TTL_MS) {
|
|
779
|
+
return this.#cachedJjStatus;
|
|
780
|
+
}
|
|
781
|
+
this.#jjStatusInFlight = true;
|
|
782
|
+
const generation = this.#jjCacheGeneration;
|
|
783
|
+
(async () => {
|
|
784
|
+
let next: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
785
|
+
try {
|
|
786
|
+
next = await jj.status.summary(root);
|
|
787
|
+
} finally {
|
|
788
|
+
this.#jjStatusInFlight = false;
|
|
789
|
+
if (this.#jjCacheGeneration === generation) this.#jjStatusLastFetch = Date.now();
|
|
790
|
+
}
|
|
791
|
+
if (this.#jjCacheGeneration !== generation || this.#disposed) return;
|
|
792
|
+
const prev = this.#cachedJjStatus;
|
|
793
|
+
this.#cachedJjStatus = next;
|
|
794
|
+
if (this.#onBranchChange && JSON.stringify(prev) !== JSON.stringify(next)) this.#onBranchChange();
|
|
795
|
+
})();
|
|
796
|
+
return this.#cachedJjStatus;
|
|
797
|
+
}
|
|
798
|
+
|
|
690
799
|
#lookupPr(effectiveGitCwd?: string): { number: number; url: string } | null {
|
|
691
800
|
if (!this.#gitEnabled()) return null;
|
|
692
801
|
|
|
@@ -1102,8 +1211,20 @@ export class StatusLineComponent implements Component {
|
|
|
1102
1211
|
const activeRepoCache = shouldResolveActiveRepo
|
|
1103
1212
|
? this.#resolveActiveRepoCache()
|
|
1104
1213
|
: { projectDir, activeRepo: null, effectiveGitCwd: projectDir, worktree: null };
|
|
1105
|
-
|
|
1106
|
-
|
|
1214
|
+
let gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null;
|
|
1215
|
+
// A jj repo has no git branch to read: git HEAD is detached (colocated) or
|
|
1216
|
+
// absent. Gate BOTH the jj branch label and the jj status counts on that
|
|
1217
|
+
// same condition, captured before the label overlay rewrites gitBranch, so
|
|
1218
|
+
// a nested ordinary git checkout under a parent jj workspace keeps its own
|
|
1219
|
+
// git branch AND its own git status instead of the ancestor jj status.
|
|
1220
|
+
const gitHeadIsJjLike = gitBranch === "detached" || gitBranch === null;
|
|
1221
|
+
if (includeGit && gitHeadIsJjLike) {
|
|
1222
|
+
gitBranch = this.#getJjBranch(activeRepoCache.effectiveGitCwd) ?? gitBranch;
|
|
1223
|
+
}
|
|
1224
|
+
const gitStatus = includeGit
|
|
1225
|
+
? ((gitHeadIsJjLike ? this.#getJjStatus(activeRepoCache.effectiveGitCwd) : null) ??
|
|
1226
|
+
this.#getGitStatus(activeRepoCache.effectiveGitCwd))
|
|
1227
|
+
: null;
|
|
1107
1228
|
const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null;
|
|
1108
1229
|
return {
|
|
1109
1230
|
session: this.session,
|