@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.1
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 +75 -1
- package/dist/types/cli/dry-balance-cli.d.ts +15 -1
- package/dist/types/commit/analysis/conventional.d.ts +2 -2
- package/dist/types/commit/analysis/summary.d.ts +2 -2
- package/dist/types/commit/changelog/generate.d.ts +2 -2
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/index.d.ts +3 -3
- package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
- package/dist/types/commit/model-selection.d.ts +10 -4
- package/dist/types/config/api-key-resolver.d.ts +34 -0
- package/dist/types/config/model-registry.d.ts +17 -1
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/dap/config.d.ts +14 -1
- package/dist/types/dap/types.d.ts +10 -0
- package/dist/types/lsp/utils.d.ts +3 -2
- package/dist/types/modes/components/chat-block.d.ts +64 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/overlay-box.d.ts +17 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
- package/dist/types/modes/components/plan-toc.d.ts +41 -0
- package/dist/types/modes/components/read-tool-group.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +11 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +0 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
- package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +15 -5
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +18 -5
- package/dist/types/modes/utils/copy-targets.d.ts +21 -1
- package/dist/types/plan-mode/approved-plan.d.ts +27 -8
- package/dist/types/plan-mode/plan-protection.d.ts +4 -4
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +21 -0
- package/dist/types/session/messages.d.ts +12 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/slash-commands/types.d.ts +4 -6
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/index.d.ts +1 -0
- package/dist/types/task/render.d.ts +3 -2
- package/dist/types/tools/archive-reader.d.ts +5 -0
- package/dist/types/tools/ast-edit.d.ts +3 -0
- package/dist/types/tools/ast-grep.d.ts +3 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/find.d.ts +8 -4
- package/dist/types/tools/grouped-file-output.d.ts +95 -12
- package/dist/types/tools/memory-render.d.ts +4 -1
- package/dist/types/tools/plan-mode-guard.d.ts +8 -9
- package/dist/types/tools/render-utils.d.ts +5 -9
- package/dist/types/tools/search.d.ts +4 -0
- package/dist/types/tools/sqlite-reader.d.ts +1 -0
- package/dist/types/tools/todo.d.ts +3 -2
- package/dist/types/tools/write.d.ts +3 -0
- package/dist/types/tui/output-block.d.ts +16 -4
- package/dist/types/tui/status-line.d.ts +3 -0
- package/dist/types/utils/enhanced-paste.d.ts +20 -0
- package/dist/types/web/search/providers/kimi.d.ts +1 -1
- package/package.json +9 -9
- package/src/auto-thinking/classifier.ts +5 -1
- package/src/cli/dry-balance-cli.ts +52 -17
- package/src/cli/gallery-cli.ts +4 -1
- package/src/cli/gallery-fixtures/misc.ts +29 -0
- package/src/commit/analysis/conventional.ts +2 -2
- package/src/commit/analysis/summary.ts +2 -2
- package/src/commit/changelog/generate.ts +2 -2
- package/src/commit/changelog/index.ts +2 -2
- package/src/commit/map-reduce/index.ts +3 -3
- package/src/commit/map-reduce/map-phase.ts +2 -2
- package/src/commit/map-reduce/reduce-phase.ts +2 -2
- package/src/commit/model-selection.ts +33 -9
- package/src/commit/pipeline.ts +4 -4
- package/src/config/api-key-resolver.ts +58 -0
- package/src/config/model-registry.ts +25 -2
- package/src/config/settings-schema.ts +10 -0
- package/src/config/settings.ts +20 -2
- package/src/dap/config.ts +41 -2
- package/src/dap/defaults.json +1 -0
- package/src/dap/session.ts +1 -0
- package/src/dap/types.ts +10 -0
- package/src/debug/index.ts +40 -54
- package/src/edit/renderer.ts +82 -78
- package/src/eval/__tests__/llm-bridge.test.ts +90 -31
- package/src/eval/llm-bridge.ts +8 -3
- package/src/goals/tools/goal-tool.ts +36 -26
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/lsp/utils.ts +3 -2
- package/src/main.ts +9 -7
- package/src/memories/index.ts +12 -5
- package/src/mnemopi/backend.ts +5 -1
- package/src/modes/acp/acp-agent.ts +33 -26
- package/src/modes/components/assistant-message.ts +2 -9
- package/src/modes/components/chat-block.ts +111 -0
- package/src/modes/components/copy-selector.ts +1 -44
- package/src/modes/components/custom-editor.ts +23 -0
- package/src/modes/components/custom-message.ts +1 -3
- package/src/modes/components/execution-shared.ts +1 -2
- package/src/modes/components/hook-message.ts +1 -3
- package/src/modes/components/overlay-box.ts +108 -0
- package/src/modes/components/plan-review-overlay.ts +799 -0
- package/src/modes/components/plan-toc.ts +138 -0
- package/src/modes/components/read-tool-group.ts +20 -4
- package/src/modes/components/skill-message.ts +0 -1
- package/src/modes/components/tips.txt +1 -0
- package/src/modes/components/todo-reminder.ts +0 -2
- package/src/modes/components/tool-execution.ts +68 -88
- package/src/modes/components/transcript-container.ts +84 -24
- package/src/modes/components/user-message.ts +1 -2
- package/src/modes/controllers/command-controller-shared.ts +7 -6
- package/src/modes/controllers/command-controller.ts +57 -55
- package/src/modes/controllers/event-controller.ts +41 -40
- package/src/modes/controllers/extension-ui-controller.ts +10 -73
- package/src/modes/controllers/input-controller.ts +124 -119
- package/src/modes/controllers/mcp-command-controller.ts +69 -60
- package/src/modes/controllers/selector-controller.ts +23 -25
- package/src/modes/controllers/streaming-reveal.ts +212 -0
- package/src/modes/controllers/tan-command-controller.ts +173 -0
- package/src/modes/interactive-mode.ts +169 -94
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/theme-schema.json +1 -1
- package/src/modes/theme/theme.ts +8 -4
- package/src/modes/types.ts +18 -7
- package/src/modes/utils/copy-targets.ts +133 -27
- package/src/modes/utils/ui-helpers.ts +44 -46
- package/src/plan-mode/approved-plan.ts +66 -43
- package/src/plan-mode/plan-protection.ts +4 -4
- package/src/prompts/system/background-tan-dispatch.md +8 -0
- package/src/prompts/system/plan-mode-active.md +67 -58
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/sdk.ts +11 -37
- package/src/session/agent-session.ts +82 -6
- package/src/session/messages.ts +26 -0
- package/src/session/session-manager.ts +13 -5
- package/src/slash-commands/builtin-registry.ts +36 -9
- package/src/slash-commands/types.ts +4 -6
- package/src/task/executor.ts +5 -2
- package/src/task/index.ts +4 -0
- package/src/task/render.ts +212 -147
- package/src/tools/archive-reader.ts +64 -0
- package/src/tools/ask.ts +119 -164
- package/src/tools/ast-edit.ts +98 -71
- package/src/tools/ast-grep.ts +37 -43
- package/src/tools/bash.ts +50 -6
- package/src/tools/debug.ts +20 -8
- package/src/tools/fetch.ts +297 -7
- package/src/tools/find.ts +44 -30
- package/src/tools/gh-renderer.ts +81 -42
- package/src/tools/grouped-file-output.ts +272 -48
- package/src/tools/image-gen.ts +150 -103
- package/src/tools/inspect-image-renderer.ts +63 -41
- package/src/tools/inspect-image.ts +8 -1
- package/src/tools/job.ts +3 -4
- package/src/tools/memory-render.ts +4 -1
- package/src/tools/plan-mode-guard.ts +21 -39
- package/src/tools/read.ts +23 -16
- package/src/tools/render-utils.ts +21 -37
- package/src/tools/resolve.ts +14 -0
- package/src/tools/search-tool-bm25.ts +36 -23
- package/src/tools/search.ts +80 -78
- package/src/tools/sqlite-reader.ts +9 -12
- package/src/tools/todo.ts +118 -52
- package/src/tools/write.ts +81 -62
- package/src/tui/output-block.ts +60 -13
- package/src/tui/status-line.ts +5 -1
- package/src/utils/commit-message-generator.ts +9 -1
- package/src/utils/enhanced-paste.ts +202 -0
- package/src/utils/title-generator.ts +2 -1
- package/src/web/search/providers/anthropic.ts +25 -19
- package/src/web/search/providers/exa.ts +11 -3
- package/src/web/search/providers/kimi.ts +28 -17
- package/src/web/search/providers/parallel.ts +35 -24
- package/src/web/search/providers/synthetic.ts +8 -6
- package/src/web/search/providers/tavily.ts +9 -8
- package/src/web/search/providers/zai.ts +8 -6
|
@@ -37,7 +37,9 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
|
|
|
37
37
|
import type { OAuthCredential } from "../../session/auth-storage";
|
|
38
38
|
import { shortenPath } from "../../tools/render-utils";
|
|
39
39
|
import { openPath } from "../../utils/open";
|
|
40
|
+
import { ChatBlock } from "../components/chat-block";
|
|
40
41
|
import { MCPAddWizard } from "../components/mcp-add-wizard";
|
|
42
|
+
import { TranscriptBlock } from "../components/transcript-container";
|
|
41
43
|
import { parseCommandArgs } from "../shared";
|
|
42
44
|
import { theme } from "../theme/theme";
|
|
43
45
|
import type { InteractiveModeContext } from "../types";
|
|
@@ -49,6 +51,42 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
|
|
|
49
51
|
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Animated "Connecting to …" transcript block. Owns its spinner interval: it
|
|
56
|
+
* starts on mount and is cleared on {@link ChatBlock.finish}/dispose, so callers
|
|
57
|
+
* never juggle `setInterval`/`clearInterval` or `requestRender` by hand.
|
|
58
|
+
*/
|
|
59
|
+
class McpConnectingBlock extends ChatBlock {
|
|
60
|
+
readonly #text: Text;
|
|
61
|
+
|
|
62
|
+
constructor(private readonly serverName: string) {
|
|
63
|
+
super();
|
|
64
|
+
this.addChild(new Spacer(1));
|
|
65
|
+
const frame = theme.spinnerFrames[0] ?? "|";
|
|
66
|
+
this.#text = new Text(theme.fg("muted", `${frame} Connecting to "${serverName}"...`), 1, 0);
|
|
67
|
+
this.addChild(this.#text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
protected override onMount(): void {
|
|
71
|
+
const frames = theme.spinnerFrames;
|
|
72
|
+
let frame = 0;
|
|
73
|
+
const interval = setInterval(() => {
|
|
74
|
+
frame++;
|
|
75
|
+
this.#text.setText(
|
|
76
|
+
theme.fg("muted", `${frames[frame % frames.length] ?? "|"} Connecting to "${this.serverName}"...`),
|
|
77
|
+
);
|
|
78
|
+
this.requestRender();
|
|
79
|
+
}, 80);
|
|
80
|
+
this.onCleanup(() => clearInterval(interval));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Replace the spinner line with a terminal status; pair with {@link finish}. */
|
|
84
|
+
setStatus(text: string): void {
|
|
85
|
+
this.#text.setText(text);
|
|
86
|
+
this.requestRender();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
52
90
|
/**
|
|
53
91
|
* Outcome of {@link MCPCommandController}'s OAuth handler.
|
|
54
92
|
*
|
|
@@ -547,63 +585,45 @@ export class MCPCommandController {
|
|
|
547
585
|
},
|
|
548
586
|
{
|
|
549
587
|
onAuth: (info: { url: string; instructions?: string }) => {
|
|
550
|
-
// Show auth URL prominently in chat
|
|
551
|
-
|
|
552
|
-
this.ctx.
|
|
553
|
-
|
|
554
|
-
);
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
);
|
|
559
|
-
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
560
|
-
this.ctx.chatContainer.addChild(
|
|
588
|
+
// Show auth URL prominently in chat as one block
|
|
589
|
+
const block = new TranscriptBlock();
|
|
590
|
+
this.ctx.present(block);
|
|
591
|
+
block.addChild(new Text(theme.fg("accent", "━━━ OAuth Authorization Required ━━━"), 1, 0));
|
|
592
|
+
block.addChild(new Spacer(1));
|
|
593
|
+
block.addChild(new Text(theme.fg("muted", "Preparing browser authorization..."), 1, 0));
|
|
594
|
+
block.addChild(new Spacer(1));
|
|
595
|
+
block.addChild(
|
|
561
596
|
new Text(
|
|
562
597
|
theme.fg("muted", "Waiting for authorization... (Press Ctrl+C to cancel, 5 minute timeout)"),
|
|
563
598
|
1,
|
|
564
599
|
0,
|
|
565
600
|
),
|
|
566
601
|
);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0),
|
|
570
|
-
);
|
|
571
|
-
this.ctx.ui.requestRender();
|
|
602
|
+
block.addChild(new Spacer(1));
|
|
603
|
+
block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
|
|
572
604
|
// Try to open browser automatically
|
|
573
605
|
try {
|
|
574
606
|
openPath(info.url);
|
|
575
607
|
|
|
576
608
|
// Show confirmation that browser should open
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0),
|
|
584
|
-
);
|
|
585
|
-
this.ctx.chatContainer.addChild(
|
|
586
|
-
new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
|
|
587
|
-
);
|
|
588
|
-
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
|
|
609
|
+
block.addChild(new Spacer(1));
|
|
610
|
+
block.addChild(new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0));
|
|
611
|
+
block.addChild(new Spacer(1));
|
|
612
|
+
block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
|
|
613
|
+
block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
|
|
614
|
+
block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
|
|
589
615
|
this.ctx.ui.requestRender();
|
|
590
616
|
} catch (_error) {
|
|
591
617
|
// Show error if browser doesn't open
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
);
|
|
596
|
-
this.ctx.chatContainer.addChild(
|
|
597
|
-
new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
|
|
598
|
-
);
|
|
599
|
-
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
|
|
618
|
+
block.addChild(new Spacer(1));
|
|
619
|
+
block.addChild(new Text(theme.fg("warning", "→ Could not open browser automatically"), 1, 0));
|
|
620
|
+
block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
|
|
621
|
+
block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
|
|
600
622
|
this.ctx.ui.requestRender();
|
|
601
623
|
}
|
|
602
624
|
},
|
|
603
625
|
onProgress: (message: string) => {
|
|
604
|
-
this.ctx.
|
|
605
|
-
this.ctx.chatContainer.addChild(new Text(theme.fg("muted", message), 1, 0));
|
|
606
|
-
this.ctx.ui.requestRender();
|
|
626
|
+
this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
|
|
607
627
|
},
|
|
608
628
|
},
|
|
609
629
|
);
|
|
@@ -611,9 +631,10 @@ export class MCPCommandController {
|
|
|
611
631
|
// Execute OAuth flow with 5 minute timeout
|
|
612
632
|
const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
|
|
613
633
|
|
|
614
|
-
this.ctx.
|
|
615
|
-
|
|
616
|
-
|
|
634
|
+
this.ctx.present([
|
|
635
|
+
new Spacer(1),
|
|
636
|
+
new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0),
|
|
637
|
+
]);
|
|
617
638
|
|
|
618
639
|
// Generate a unique credential ID
|
|
619
640
|
const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
|
@@ -766,19 +787,8 @@ export class MCPCommandController {
|
|
|
766
787
|
): Promise<"connected" | "connecting" | "disconnected"> {
|
|
767
788
|
if (!this.ctx.mcpManager) return "disconnected";
|
|
768
789
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const initialFrame = frames[0] ?? "|";
|
|
772
|
-
const statusText = new Text(theme.fg("muted", `${initialFrame} Connecting to "${name}"...`), 1, 0);
|
|
773
|
-
this.ctx.chatContainer.addChild(statusText);
|
|
774
|
-
this.ctx.ui.requestRender();
|
|
775
|
-
|
|
776
|
-
let frame = 0;
|
|
777
|
-
const interval = setInterval(() => {
|
|
778
|
-
statusText.setText(theme.fg("muted", `${frames[frame % frames.length]} Connecting to "${name}"...`));
|
|
779
|
-
frame++;
|
|
780
|
-
this.ctx.ui.requestRender();
|
|
781
|
-
}, 80);
|
|
790
|
+
const block = new McpConnectingBlock(name);
|
|
791
|
+
this.ctx.present(block);
|
|
782
792
|
|
|
783
793
|
try {
|
|
784
794
|
try {
|
|
@@ -792,20 +802,19 @@ export class MCPCommandController {
|
|
|
792
802
|
await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
|
|
793
803
|
}
|
|
794
804
|
if (state === "connected") {
|
|
795
|
-
|
|
805
|
+
block.setStatus(theme.fg("success", `✓ Connected to "${name}"`));
|
|
796
806
|
} else if (state === "connecting") {
|
|
797
|
-
|
|
807
|
+
block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`));
|
|
798
808
|
} else {
|
|
799
|
-
|
|
809
|
+
block.setStatus(
|
|
800
810
|
options?.suppressDisconnectedWarning
|
|
801
811
|
? theme.fg("muted", `◌ Connection check complete for "${name}"`)
|
|
802
812
|
: theme.fg("warning", `⚠ Could not connect to "${name}" yet`),
|
|
803
813
|
);
|
|
804
814
|
}
|
|
805
|
-
this.ctx.ui.requestRender();
|
|
806
815
|
return state;
|
|
807
816
|
} finally {
|
|
808
|
-
|
|
817
|
+
block.finish();
|
|
809
818
|
}
|
|
810
819
|
}
|
|
811
820
|
|
|
@@ -50,6 +50,7 @@ import { SessionObserverOverlayComponent } from "../components/session-observer-
|
|
|
50
50
|
import { SessionSelectorComponent } from "../components/session-selector";
|
|
51
51
|
import { SettingsSelectorComponent } from "../components/settings-selector";
|
|
52
52
|
import { ToolExecutionComponent } from "../components/tool-execution";
|
|
53
|
+
import { TranscriptBlock } from "../components/transcript-container";
|
|
53
54
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
54
55
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
55
56
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
@@ -931,28 +932,28 @@ export class SelectorController {
|
|
|
931
932
|
try {
|
|
932
933
|
await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
|
933
934
|
onAuth: (info: { url: string; instructions?: string }) => {
|
|
934
|
-
|
|
935
|
-
|
|
935
|
+
const block = new TranscriptBlock();
|
|
936
|
+
block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
|
|
936
937
|
const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
|
|
937
|
-
|
|
938
|
+
block.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
|
|
938
939
|
if (info.instructions) {
|
|
939
|
-
|
|
940
|
-
|
|
940
|
+
block.addChild(new Spacer(1));
|
|
941
|
+
block.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
|
|
941
942
|
}
|
|
942
943
|
if (useManualInput) {
|
|
943
|
-
|
|
944
|
-
|
|
944
|
+
block.addChild(new Spacer(1));
|
|
945
|
+
block.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
|
|
945
946
|
}
|
|
946
|
-
this.ctx.
|
|
947
|
+
this.ctx.present(block);
|
|
947
948
|
this.ctx.openInBrowser(info.url);
|
|
948
949
|
},
|
|
949
950
|
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
|
950
|
-
|
|
951
|
-
|
|
951
|
+
const promptBlock = new TranscriptBlock();
|
|
952
|
+
promptBlock.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
|
|
952
953
|
if (prompt.placeholder) {
|
|
953
|
-
|
|
954
|
+
promptBlock.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
|
|
954
955
|
}
|
|
955
|
-
this.ctx.
|
|
956
|
+
this.ctx.present(promptBlock);
|
|
956
957
|
const { promise, resolve } = Promise.withResolvers<string>();
|
|
957
958
|
const codeInput = new Input();
|
|
958
959
|
codeInput.onSubmit = () => {
|
|
@@ -969,18 +970,17 @@ export class SelectorController {
|
|
|
969
970
|
return promise;
|
|
970
971
|
},
|
|
971
972
|
onProgress: (message: string) => {
|
|
972
|
-
this.ctx.
|
|
973
|
-
this.ctx.ui.requestRender();
|
|
973
|
+
this.ctx.present(new Text(theme.fg("dim", message), 1, 0));
|
|
974
974
|
},
|
|
975
975
|
onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
|
|
976
976
|
});
|
|
977
977
|
await this.ctx.session.modelRegistry.refresh();
|
|
978
|
-
|
|
979
|
-
|
|
978
|
+
const block = new TranscriptBlock();
|
|
979
|
+
block.addChild(
|
|
980
980
|
new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
|
|
981
981
|
);
|
|
982
|
-
|
|
983
|
-
this.ctx.
|
|
982
|
+
block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
|
|
983
|
+
this.ctx.present(block);
|
|
984
984
|
} catch (error: unknown) {
|
|
985
985
|
this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
986
986
|
} finally {
|
|
@@ -1002,20 +1002,18 @@ export class SelectorController {
|
|
|
1002
1002
|
|
|
1003
1003
|
await authStorage.logout(providerId);
|
|
1004
1004
|
await this.ctx.session.modelRegistry.refresh();
|
|
1005
|
-
|
|
1006
|
-
|
|
1005
|
+
const block = new TranscriptBlock();
|
|
1006
|
+
block.addChild(
|
|
1007
1007
|
new Text(theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`), 1, 0),
|
|
1008
1008
|
);
|
|
1009
|
-
|
|
1010
|
-
new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
|
|
1011
|
-
);
|
|
1009
|
+
block.addChild(new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0));
|
|
1012
1010
|
const remainingSource = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
|
|
1013
1011
|
if (remainingSource) {
|
|
1014
|
-
|
|
1012
|
+
block.addChild(
|
|
1015
1013
|
new Text(theme.fg("warning", `${providerId} is still authenticated via ${remainingSource}`), 1, 0),
|
|
1016
1014
|
);
|
|
1017
1015
|
}
|
|
1018
|
-
this.ctx.
|
|
1016
|
+
this.ctx.present(block);
|
|
1019
1017
|
} catch (error: unknown) {
|
|
1020
1018
|
this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1021
1019
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { getSegmenter } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import type { AssistantMessageComponent } from "../components/assistant-message";
|
|
4
|
+
|
|
5
|
+
export const STREAMING_REVEAL_FRAME_MS = 1000 / 30;
|
|
6
|
+
export const MIN_STEP = 3;
|
|
7
|
+
export const CATCHUP_FRAMES = 8;
|
|
8
|
+
|
|
9
|
+
type AssistantContentBlock = AssistantMessage["content"][number];
|
|
10
|
+
type StreamingRevealComponent = Pick<AssistantMessageComponent, "updateContent">;
|
|
11
|
+
|
|
12
|
+
type StreamingRevealControllerOptions = {
|
|
13
|
+
getSmoothStreaming(): boolean;
|
|
14
|
+
getHideThinkingBlock(): boolean;
|
|
15
|
+
requestRender(): void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function countGraphemes(text: string): number {
|
|
19
|
+
let count = 0;
|
|
20
|
+
for (const _segment of getSegmenter().segment(text)) {
|
|
21
|
+
count += 1;
|
|
22
|
+
}
|
|
23
|
+
return count;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sliceGraphemes(text: string, units: number): string {
|
|
27
|
+
if (units <= 0 || text.length === 0) return "";
|
|
28
|
+
let count = 0;
|
|
29
|
+
for (const { index, segment } of getSegmenter().segment(text)) {
|
|
30
|
+
count += 1;
|
|
31
|
+
if (count >= units) {
|
|
32
|
+
const end = index + segment.length;
|
|
33
|
+
return end >= text.length ? text : text.slice(0, end);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function visibleUnits(message: AssistantMessage, hideThinking: boolean): number {
|
|
40
|
+
let total = 0;
|
|
41
|
+
for (const block of message.content) {
|
|
42
|
+
if (block.type === "text") {
|
|
43
|
+
total += countGraphemes(block.text);
|
|
44
|
+
} else if (block.type === "thinking" && !hideThinking) {
|
|
45
|
+
total += countGraphemes(block.thinking);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return total;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function revealTextBlock(
|
|
52
|
+
block: Extract<AssistantContentBlock, { type: "text" }>,
|
|
53
|
+
remaining: number,
|
|
54
|
+
): AssistantContentBlock {
|
|
55
|
+
if (remaining <= 0) return block.text.length === 0 ? block : { ...block, text: "" };
|
|
56
|
+
const units = countGraphemes(block.text);
|
|
57
|
+
if (remaining >= units) return block;
|
|
58
|
+
return { ...block, text: sliceGraphemes(block.text, remaining) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function revealThinkingBlock(
|
|
62
|
+
block: Extract<AssistantContentBlock, { type: "thinking" }>,
|
|
63
|
+
remaining: number,
|
|
64
|
+
): AssistantContentBlock {
|
|
65
|
+
if (remaining <= 0) return block.thinking.length === 0 ? block : { ...block, thinking: "" };
|
|
66
|
+
const units = countGraphemes(block.thinking);
|
|
67
|
+
if (remaining >= units) return block;
|
|
68
|
+
return { ...block, thinking: sliceGraphemes(block.thinking, remaining) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildDisplayMessage(
|
|
72
|
+
target: AssistantMessage,
|
|
73
|
+
revealed: number,
|
|
74
|
+
hideThinking: boolean,
|
|
75
|
+
): AssistantMessage {
|
|
76
|
+
let remaining = Math.max(0, Math.floor(revealed));
|
|
77
|
+
const content: AssistantContentBlock[] = [];
|
|
78
|
+
for (const block of target.content) {
|
|
79
|
+
if (block.type === "text") {
|
|
80
|
+
content.push(revealTextBlock(block, remaining));
|
|
81
|
+
remaining = Math.max(0, remaining - countGraphemes(block.text));
|
|
82
|
+
} else if (block.type === "thinking" && !hideThinking) {
|
|
83
|
+
content.push(revealThinkingBlock(block, remaining));
|
|
84
|
+
remaining = Math.max(0, remaining - countGraphemes(block.thinking));
|
|
85
|
+
} else {
|
|
86
|
+
content.push(block);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ...target, content };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function nextStep(backlog: number): number {
|
|
93
|
+
return Math.max(MIN_STEP, Math.ceil(Math.max(0, backlog) / CATCHUP_FRAMES));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class StreamingRevealController {
|
|
97
|
+
readonly #getSmoothStreaming: () => boolean;
|
|
98
|
+
readonly #getHideThinkingBlock: () => boolean;
|
|
99
|
+
readonly #requestRender: () => void;
|
|
100
|
+
#target: AssistantMessage | undefined;
|
|
101
|
+
#component: StreamingRevealComponent | undefined;
|
|
102
|
+
#timer: NodeJS.Timeout | undefined;
|
|
103
|
+
#revealed = 0;
|
|
104
|
+
#hideThinkingBlock = false;
|
|
105
|
+
#smoothStreaming = true;
|
|
106
|
+
|
|
107
|
+
constructor(options: StreamingRevealControllerOptions) {
|
|
108
|
+
this.#getSmoothStreaming = options.getSmoothStreaming;
|
|
109
|
+
this.#getHideThinkingBlock = options.getHideThinkingBlock;
|
|
110
|
+
this.#requestRender = options.requestRender;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
begin(component: StreamingRevealComponent, message: AssistantMessage): void {
|
|
114
|
+
this.stop();
|
|
115
|
+
this.#component = component;
|
|
116
|
+
this.#target = message;
|
|
117
|
+
this.#revealed = 0;
|
|
118
|
+
this.#hideThinkingBlock = this.#getHideThinkingBlock();
|
|
119
|
+
this.#smoothStreaming = this.#getSmoothStreaming();
|
|
120
|
+
if (!this.#smoothStreaming) {
|
|
121
|
+
component.updateContent(message);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const total = visibleUnits(message, this.#hideThinkingBlock);
|
|
125
|
+
if (message.content.some(block => block.type === "toolCall")) {
|
|
126
|
+
// A tool call is a transcript-order boundary: finish any leading
|
|
127
|
+
// assistant text before EventController renders the separate tool card.
|
|
128
|
+
this.#revealed = total;
|
|
129
|
+
component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.#renderCurrent();
|
|
133
|
+
this.#syncTimer(total);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setTarget(message: AssistantMessage): void {
|
|
137
|
+
this.#target = message;
|
|
138
|
+
if (!this.#component) return;
|
|
139
|
+
if (!this.#smoothStreaming) {
|
|
140
|
+
this.#component.updateContent(message);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const total = visibleUnits(message, this.#hideThinkingBlock);
|
|
144
|
+
if (message.content.some(block => block.type === "toolCall")) {
|
|
145
|
+
// A tool call is a transcript-order boundary: finish any leading
|
|
146
|
+
// assistant text before EventController renders the separate tool card.
|
|
147
|
+
this.#revealed = total;
|
|
148
|
+
this.#stopTimer();
|
|
149
|
+
this.#component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.#revealed > total) {
|
|
153
|
+
this.#revealed = total;
|
|
154
|
+
}
|
|
155
|
+
this.#renderCurrent();
|
|
156
|
+
this.#syncTimer(total);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
stop(): void {
|
|
160
|
+
this.#stopTimer();
|
|
161
|
+
this.#target = undefined;
|
|
162
|
+
this.#component = undefined;
|
|
163
|
+
this.#revealed = 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#renderCurrent(): void {
|
|
167
|
+
if (!this.#target || !this.#component) return;
|
|
168
|
+
this.#component.updateContent(buildDisplayMessage(this.#target, this.#revealed, this.#hideThinkingBlock));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#syncTimer(total = this.#target ? visibleUnits(this.#target, this.#hideThinkingBlock) : 0): void {
|
|
172
|
+
if (!this.#target || !this.#component || this.#revealed >= total) {
|
|
173
|
+
this.#stopTimer();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.#startTimer();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#startTimer(): void {
|
|
180
|
+
if (this.#timer) return;
|
|
181
|
+
this.#timer = setInterval(() => {
|
|
182
|
+
this.#tick();
|
|
183
|
+
}, STREAMING_REVEAL_FRAME_MS);
|
|
184
|
+
this.#timer.unref?.();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#stopTimer(): void {
|
|
188
|
+
if (!this.#timer) return;
|
|
189
|
+
clearInterval(this.#timer);
|
|
190
|
+
this.#timer = undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#tick(): void {
|
|
194
|
+
const target = this.#target;
|
|
195
|
+
const component = this.#component;
|
|
196
|
+
if (!target || !component) {
|
|
197
|
+
this.stop();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const total = visibleUnits(target, this.#hideThinkingBlock);
|
|
201
|
+
if (this.#revealed >= total) {
|
|
202
|
+
this.#stopTimer();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
|
|
206
|
+
component.updateContent(buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock));
|
|
207
|
+
this.#requestRender();
|
|
208
|
+
if (this.#revealed >= total) {
|
|
209
|
+
this.#stopTimer();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
|
|
5
|
+
import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
|
6
|
+
import * as sdk from "../../sdk";
|
|
7
|
+
import type { AgentSession } from "../../session/agent-session";
|
|
8
|
+
import { SessionManager } from "../../session/session-manager";
|
|
9
|
+
import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
|
|
10
|
+
import type { InteractiveModeContext } from "../types";
|
|
11
|
+
|
|
12
|
+
const TAN_LABEL_PREVIEW_LENGTH = 80;
|
|
13
|
+
|
|
14
|
+
function previewWork(work: string): string {
|
|
15
|
+
const singleLine = work.trim().replace(/\s+/g, " ");
|
|
16
|
+
if (singleLine.length <= TAN_LABEL_PREVIEW_LENGTH) return singleLine;
|
|
17
|
+
return `${singleLine.slice(0, TAN_LABEL_PREVIEW_LENGTH - 1)}…`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractAssistantText(message: AssistantMessage | undefined): string {
|
|
21
|
+
if (!message) return "";
|
|
22
|
+
return message.content
|
|
23
|
+
.filter(content => content.type === "text")
|
|
24
|
+
.map(content => content.text)
|
|
25
|
+
.join("")
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function removeCloneSession(cloneFile: string): Promise<void> {
|
|
30
|
+
await Promise.allSettled([
|
|
31
|
+
fs.rm(cloneFile, { force: true }),
|
|
32
|
+
fs.rm(cloneFile.slice(0, -6), { recursive: true, force: true }),
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TanCommandController {
|
|
37
|
+
constructor(private readonly ctx: InteractiveModeContext) {}
|
|
38
|
+
|
|
39
|
+
async start(work: string): Promise<void> {
|
|
40
|
+
const trimmedWork = work.trim();
|
|
41
|
+
if (!trimmedWork) {
|
|
42
|
+
this.ctx.showStatus("Usage: /tan <work>");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const session = this.ctx.session;
|
|
47
|
+
if (session.isStreaming) {
|
|
48
|
+
this.ctx.showWarning("Wait for the current response to finish or abort it before using /tan.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const model = session.model;
|
|
53
|
+
if (!model) {
|
|
54
|
+
this.ctx.showError("No active model available for /tan.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const manager = session.asyncJobManager;
|
|
59
|
+
if (!manager) {
|
|
60
|
+
this.ctx.showError("Background jobs are disabled; enable async jobs to use /tan.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parentFile = this.ctx.sessionManager.getSessionFile();
|
|
65
|
+
if (!parentFile) {
|
|
66
|
+
this.ctx.showError("/tan requires a persisted session.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parentSessionId = session.sessionId;
|
|
71
|
+
const thinkingLevel = session.configuredThinkingLevel();
|
|
72
|
+
const systemPrompt = [...session.systemPrompt];
|
|
73
|
+
const toolNames = session.getActiveToolNames();
|
|
74
|
+
const modelRegistry = session.modelRegistry;
|
|
75
|
+
const ownerId = session.getAgentId() ?? MAIN_AGENT_ID;
|
|
76
|
+
const mcpManager = this.ctx.mcpManager;
|
|
77
|
+
const cwd = this.ctx.sessionManager.getCwd();
|
|
78
|
+
// Nest the clone inside the parent's artifact directory (like a subagent
|
|
79
|
+
// session) rather than as a top-level sibling, so it shares the parent's
|
|
80
|
+
// artifacts in place — no copy needed.
|
|
81
|
+
const sessionDir = parentFile.slice(0, -6);
|
|
82
|
+
const settings = createSubagentSettings(this.ctx.settings);
|
|
83
|
+
const customTools = mcpManager ? createMCPProxyTools(mcpManager) : undefined;
|
|
84
|
+
const enableLsp = this.ctx.settings.get("task.enableLsp") !== false;
|
|
85
|
+
const agentRegistry = AgentRegistry.global();
|
|
86
|
+
const cloneId = `Tan-${Snowflake.next()}`;
|
|
87
|
+
const label = `/tan ${previewWork(trimmedWork)}`;
|
|
88
|
+
|
|
89
|
+
await this.ctx.sessionManager.ensureOnDisk();
|
|
90
|
+
await this.ctx.sessionManager.flush();
|
|
91
|
+
|
|
92
|
+
let cloneFile = "";
|
|
93
|
+
let jobId = "";
|
|
94
|
+
try {
|
|
95
|
+
const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, {
|
|
96
|
+
suppressBreadcrumb: true,
|
|
97
|
+
});
|
|
98
|
+
cloneFile = cloneManager.getSessionFile() ?? "";
|
|
99
|
+
if (!cloneFile) throw new Error("Forked session did not create a session file.");
|
|
100
|
+
|
|
101
|
+
jobId = manager.register(
|
|
102
|
+
"task",
|
|
103
|
+
label,
|
|
104
|
+
async ({ signal }) => {
|
|
105
|
+
if (signal.aborted) throw new Error("Aborted before execution");
|
|
106
|
+
|
|
107
|
+
let clone: AgentSession | undefined;
|
|
108
|
+
try {
|
|
109
|
+
const created = await sdk.createAgentSession({
|
|
110
|
+
cwd,
|
|
111
|
+
sessionManager: cloneManager,
|
|
112
|
+
model,
|
|
113
|
+
thinkingLevel,
|
|
114
|
+
systemPrompt,
|
|
115
|
+
toolNames,
|
|
116
|
+
providerSessionId: `${parentSessionId}:tan:${Snowflake.next()}`,
|
|
117
|
+
providerPromptCacheKey: parentSessionId,
|
|
118
|
+
modelRegistry,
|
|
119
|
+
authStorage: modelRegistry.authStorage,
|
|
120
|
+
settings,
|
|
121
|
+
hasUI: false,
|
|
122
|
+
enableMCP: false,
|
|
123
|
+
customTools,
|
|
124
|
+
enableLsp,
|
|
125
|
+
agentId: cloneId,
|
|
126
|
+
agentDisplayName: "tan",
|
|
127
|
+
parentTaskPrefix: cloneId,
|
|
128
|
+
agentRegistry,
|
|
129
|
+
disableExtensionDiscovery: true,
|
|
130
|
+
});
|
|
131
|
+
clone = created.session;
|
|
132
|
+
const abortClone = () => {
|
|
133
|
+
void clone?.abort();
|
|
134
|
+
};
|
|
135
|
+
signal.addEventListener("abort", abortClone, { once: true });
|
|
136
|
+
try {
|
|
137
|
+
if (signal.aborted) {
|
|
138
|
+
abortClone();
|
|
139
|
+
throw new Error("Aborted before execution");
|
|
140
|
+
}
|
|
141
|
+
await clone.prompt(trimmedWork, { attribution: "user" });
|
|
142
|
+
await clone.waitForIdle();
|
|
143
|
+
return extractAssistantText(clone.getLastAssistantMessage()) || "(no output)";
|
|
144
|
+
} finally {
|
|
145
|
+
signal.removeEventListener("abort", abortClone);
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
await clone?.dispose();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{ ownerId },
|
|
152
|
+
);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (cloneFile) await removeCloneSession(cloneFile);
|
|
155
|
+
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const content = prompt.render(backgroundTanDispatchPrompt, { jobId, work: trimmedWork });
|
|
160
|
+
await session.sendCustomMessage(
|
|
161
|
+
{
|
|
162
|
+
customType: "background-tan-dispatch",
|
|
163
|
+
content,
|
|
164
|
+
display: true,
|
|
165
|
+
attribution: "user",
|
|
166
|
+
details: { jobId, work: trimmedWork, sessionFile: cloneFile },
|
|
167
|
+
},
|
|
168
|
+
{ triggerTurn: false },
|
|
169
|
+
);
|
|
170
|
+
this.ctx.rebuildChatFromMessages();
|
|
171
|
+
this.ctx.showStatus(`Dispatched background tan ${jobId}`);
|
|
172
|
+
}
|
|
173
|
+
}
|