@oh-my-pi/pi-tui 18.4.9 → 18.4.11
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 +37 -0
- package/dist/types/chat/assistant-message.d.ts +4 -3
- package/dist/types/chat/messages.d.ts +4 -0
- package/dist/types/chrome/transcript-container.d.ts +7 -0
- package/dist/types/components/markdown.d.ts +0 -4
- package/dist/types/overlays/advisor-config.d.ts +8 -0
- package/dist/types/overlays/ask-dialog.d.ts +1 -0
- package/dist/types/prompt/composer.d.ts +3 -1
- package/dist/types/status-line/context-usage.d.ts +2 -0
- package/dist/types/terminal-capabilities.d.ts +5 -0
- package/dist/types/tools/agent-tree.d.ts +2 -0
- package/dist/types/tools/task.d.ts +2 -0
- package/dist/types/tools/wait.d.ts +2 -0
- package/dist/types/tools/write.d.ts +11 -16
- package/dist/types/tui.d.ts +3 -0
- package/dist/types/windows-input-mode.d.ts +13 -2
- package/package.json +9 -9
- package/src/chat/advisor-message.ts +4 -2
- package/src/chat/assistant-message.ts +7 -9
- package/src/chat/chat-transcript-builder.ts +1 -1
- package/src/chat/messages.ts +4 -0
- package/src/chrome/transcript-container.ts +13 -0
- package/src/components/markdown.ts +90 -130
- package/src/keys.ts +2 -4
- package/src/native/encode.ts +2 -0
- package/src/overlays/advisor-config.ts +228 -2
- package/src/overlays/ask-dialog.ts +1 -0
- package/src/overlays/model-hub.ts +25 -4
- package/src/overlays/session-info-overlay.ts +55 -11
- package/src/prompt/composer.ts +32 -7
- package/src/status-line/context-usage.ts +18 -2
- package/src/terminal-capabilities.ts +8 -0
- package/src/terminal.ts +12 -10
- package/src/tools/agent-tree.ts +5 -0
- package/src/tools/eval.ts +14 -5
- package/src/tools/task.ts +4 -0
- package/src/tools/wait.ts +24 -22
- package/src/tools/write.ts +39 -4
- package/src/tui.ts +4 -0
- package/src/windows-input-mode.ts +26 -2
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
Lexer,
|
|
4
4
|
Marked,
|
|
5
5
|
type Token,
|
|
6
|
-
Tokenizer,
|
|
7
6
|
type TokenizerAndRendererExtension,
|
|
7
|
+
type TokenizerThis,
|
|
8
8
|
type Tokens,
|
|
9
9
|
} from "@oh-my-pi/pi-utils/marked";
|
|
10
|
-
import { mathBlockAt,
|
|
10
|
+
import { mathBlockAt, mathSpanInContext, mathStartIndex } from "@oh-my-pi/pi-utils/math-delimiters";
|
|
11
11
|
import { latexToBlock } from "../latex-block";
|
|
12
12
|
import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
13
13
|
import { plainText } from "../native/spans";
|
|
@@ -30,8 +30,6 @@ import {
|
|
|
30
30
|
wrapTextWithAnsi,
|
|
31
31
|
} from "../utils";
|
|
32
32
|
|
|
33
|
-
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
|
|
34
|
-
|
|
35
33
|
// Marked treats the backslash in an ST-terminated OSC 8 sequence (`ESC \\`) as
|
|
36
34
|
// Markdown punctuation when it is immediately followed by markup such as a
|
|
37
35
|
// codespan backtick. Normalize well-formed OSC 8 prefixes to the equivalent BEL
|
|
@@ -506,27 +504,85 @@ function hangWrapTreeGuideLines(text: string, width: number): string[] | undefin
|
|
|
506
504
|
return out;
|
|
507
505
|
}
|
|
508
506
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
|
|
512
|
-
if (!match) {
|
|
513
|
-
return undefined;
|
|
514
|
-
}
|
|
507
|
+
const WHITESPACE = /\s/;
|
|
508
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
515
509
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
510
|
+
/**
|
|
511
|
+
* GFM strikethrough as upstream marked's rule reads it, with `~~` only: an opener not followed by whitespace or `~`,
|
|
512
|
+
* text read in units of one character or a backslash and the character after it (not a line break), and the first
|
|
513
|
+
* `~~` that follows a unit other than whitespace, `~` or a lone `\` and is not followed by another `~`. One scanner
|
|
514
|
+
* serves one inline source and the link labels and emphasis inside it. A scan also answers for every opener it
|
|
515
|
+
* passed, which meets the same closer or none, so a line of unclosed openers is read once, not once per opener.
|
|
516
|
+
*/
|
|
517
|
+
class StrikethroughScanner {
|
|
518
|
+
readonly #source: string;
|
|
519
|
+
// The last opener whose scan ran, where that scan stopped, and whether a closer stopped it there.
|
|
520
|
+
#open = -1;
|
|
521
|
+
#stop = -1;
|
|
522
|
+
#closed = false;
|
|
523
|
+
|
|
524
|
+
constructor(source: string) {
|
|
525
|
+
this.#source = source;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The offset of the `~~` closing the strikethrough opened at `open`, or -1. */
|
|
529
|
+
closeAt(open: number): number {
|
|
530
|
+
const src = this.#source;
|
|
531
|
+
const first = src[open + 2];
|
|
532
|
+
if (!src.startsWith("~~", open) || first === undefined || first === "~" || WHITESPACE.test(first)) return -1;
|
|
533
|
+
// Past the opener's own "~~" its units are the last scan's, so they meet the same closer or stop.
|
|
534
|
+
if (this.#open < open && open < this.#stop) return this.#closed ? this.#stop : -1;
|
|
535
|
+
let at = open + 2;
|
|
536
|
+
let close = -1;
|
|
537
|
+
while (at < src.length) {
|
|
538
|
+
let end: number;
|
|
539
|
+
let final: boolean;
|
|
540
|
+
if (src.charCodeAt(at) === 0x5c /* \ */) {
|
|
541
|
+
if (at + 1 === src.length || LINE_TERMINATOR.test(src[at + 1]!)) break;
|
|
542
|
+
end = at + 2;
|
|
543
|
+
final = true;
|
|
544
|
+
} else {
|
|
545
|
+
end = at + 1;
|
|
546
|
+
final = src[at] !== "~" && !WHITESPACE.test(src[at]!);
|
|
547
|
+
}
|
|
548
|
+
if (final && src.startsWith("~~", end) && src[end + 2] !== "~") {
|
|
549
|
+
close = end;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
at = end;
|
|
553
|
+
}
|
|
554
|
+
this.#open = open;
|
|
555
|
+
this.#stop = close === -1 ? at : close;
|
|
556
|
+
this.#closed = close !== -1;
|
|
557
|
+
return close;
|
|
523
558
|
}
|
|
524
559
|
}
|
|
525
560
|
|
|
561
|
+
const strikethroughScanners = new WeakMap<TokenizerThis, StrikethroughScanner>();
|
|
562
|
+
|
|
563
|
+
// Registered after every other inline extension: at a `~~` no built-in rule before marked's `del` can match, so this
|
|
564
|
+
// gives the tokens a `del` override would, and the `del` override below keeps marked's own looser rule out.
|
|
565
|
+
const strikethroughExtension: TokenizerAndRendererExtension = {
|
|
566
|
+
name: "strictStrikethrough",
|
|
567
|
+
level: "inline",
|
|
568
|
+
// No start hint: inline text already stops at every `~`.
|
|
569
|
+
tokenizer(src) {
|
|
570
|
+
if (!src.startsWith("~~")) return undefined;
|
|
571
|
+
const source = this.source ?? src;
|
|
572
|
+
// Inside a link label or emphasis the text ends at `end`, before its closer (`]`, `*` or `_`), which is not a
|
|
573
|
+
// `~`, so a closer that ends there closes as it would at the end of the text.
|
|
574
|
+
const end = this.end ?? source.length;
|
|
575
|
+
let scanner = strikethroughScanners.get(this);
|
|
576
|
+
if (!scanner) strikethroughScanners.set(this, (scanner = new StrikethroughScanner(source)));
|
|
577
|
+
const open = end - src.length;
|
|
578
|
+
const close = scanner.closeAt(open);
|
|
579
|
+
if (close === -1 || close + 2 > end) return undefined;
|
|
580
|
+
const text = source.slice(open + 2, close);
|
|
581
|
+
return { type: "del", raw: source.slice(open, close + 2), text, tokens: this.lexer.inlineTokens(text) };
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
|
|
526
585
|
const markdownParser = new Marked();
|
|
527
|
-
markdownParser.setOptions({
|
|
528
|
-
tokenizer: new StrictStrikethroughTokenizer(),
|
|
529
|
-
});
|
|
530
586
|
|
|
531
587
|
// Math spans (`$$…$$`, `\[…\]`, `$…$`, `\(…\)`) are tokenized as a dedicated
|
|
532
588
|
// `math` inline token before markdown's escape/emphasis/link rules run, so
|
|
@@ -590,9 +646,9 @@ const customHrExtension: TokenizerAndRendererExtension = {
|
|
|
590
646
|
const mathExtension: TokenizerAndRendererExtension = {
|
|
591
647
|
name: "math",
|
|
592
648
|
level: "inline",
|
|
593
|
-
|
|
649
|
+
startFrom: mathStartIndex,
|
|
594
650
|
tokenizer(src) {
|
|
595
|
-
const span =
|
|
651
|
+
const span = mathSpanInContext(this, src);
|
|
596
652
|
if (!span) return undefined;
|
|
597
653
|
return { type: "math", raw: src.slice(0, span.end), text: span.body, display: span.display };
|
|
598
654
|
},
|
|
@@ -676,64 +732,12 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
|
|
|
676
732
|
// tokenizer at a valid start. Candidates at a legal boundary fall through
|
|
677
733
|
// (return undefined) to marked's own autolink handling unchanged.
|
|
678
734
|
const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
|
|
679
|
-
// Case-insensitive scheme scan replacing /www\.|https?:\/\/|ftp:\/\//i in
|
|
680
|
-
// boundedAutolinkExtension.start — like `mathStartIndex`, this runs on the
|
|
681
|
-
// remaining source at every inline position (part of a ~4.3% CPU start() scan
|
|
682
|
-
// tail in profiles). charCode-only: no allocation, no toLowerCase copies.
|
|
683
|
-
// `| 32` lower-cases ASCII letters; `.`/`:`/`/` are compared exactly, matching
|
|
684
|
-
// the regex's ASCII-only `i` semantics. charCodeAt past the end returns NaN,
|
|
685
|
-
// which fails every comparison, so no explicit bounds checks are needed.
|
|
686
|
-
function isAutolinkSchemeAt(src: string, i: number): boolean {
|
|
687
|
-
const c = src.charCodeAt(i) | 32;
|
|
688
|
-
if (c === 119 /* w */) {
|
|
689
|
-
// www.
|
|
690
|
-
return (
|
|
691
|
-
(src.charCodeAt(i + 1) | 32) === 119 &&
|
|
692
|
-
(src.charCodeAt(i + 2) | 32) === 119 &&
|
|
693
|
-
src.charCodeAt(i + 3) === 46 /* . */
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
if (c === 104 /* h */) {
|
|
697
|
-
// http:// | https://
|
|
698
|
-
if (
|
|
699
|
-
(src.charCodeAt(i + 1) | 32) !== 116 /* t */ ||
|
|
700
|
-
(src.charCodeAt(i + 2) | 32) !== 116 /* t */ ||
|
|
701
|
-
(src.charCodeAt(i + 3) | 32) !== 112 /* p */
|
|
702
|
-
) {
|
|
703
|
-
return false;
|
|
704
|
-
}
|
|
705
|
-
let j = i + 4;
|
|
706
|
-
if ((src.charCodeAt(j) | 32) === 115 /* s */) j++;
|
|
707
|
-
return src.charCodeAt(j) === 58 /* : */ && src.charCodeAt(j + 1) === 47 /* / */ && src.charCodeAt(j + 2) === 47;
|
|
708
|
-
}
|
|
709
|
-
if (c === 102 /* f */) {
|
|
710
|
-
// ftp://
|
|
711
|
-
return (
|
|
712
|
-
(src.charCodeAt(i + 1) | 32) === 116 /* t */ &&
|
|
713
|
-
(src.charCodeAt(i + 2) | 32) === 112 /* p */ &&
|
|
714
|
-
src.charCodeAt(i + 3) === 58 /* : */ &&
|
|
715
|
-
src.charCodeAt(i + 4) === 47 /* / */ &&
|
|
716
|
-
src.charCodeAt(i + 5) === 47 /* / */
|
|
717
|
-
);
|
|
718
|
-
}
|
|
719
|
-
return false;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
723
|
-
export function autolinkSchemeScanIndex(src: string): number | undefined {
|
|
724
|
-
for (let i = 0; i < src.length; i++) {
|
|
725
|
-
const c = src.charCodeAt(i) | 32;
|
|
726
|
-
if ((c === 119 || c === 104 || c === 102) && isAutolinkSchemeAt(src, i)) return i;
|
|
727
|
-
}
|
|
728
|
-
return undefined;
|
|
729
|
-
}
|
|
730
735
|
const VALID_AUTOLINK_LEFT_BOUNDARY = /[\s*_~(]/;
|
|
731
736
|
const boundedAutolinkExtension: TokenizerAndRendererExtension = {
|
|
732
737
|
name: "boundedAutolink",
|
|
733
738
|
level: "inline",
|
|
734
|
-
start
|
|
735
|
-
|
|
736
|
-
},
|
|
739
|
+
// No start hint: inline text already stops at every `www.`, `http://`, `https://` and `ftp://`, the only places
|
|
740
|
+
// this tokenizer matches.
|
|
737
741
|
tokenizer(src, tokens) {
|
|
738
742
|
const match = AUTOLINK_SCHEME_REGEX.exec(src);
|
|
739
743
|
if (!match) return undefined;
|
|
@@ -747,59 +751,16 @@ const boundedAutolinkExtension: TokenizerAndRendererExtension = {
|
|
|
747
751
|
},
|
|
748
752
|
};
|
|
749
753
|
markdownParser.use({
|
|
750
|
-
extensions: [
|
|
754
|
+
extensions: [
|
|
755
|
+
customHrExtension,
|
|
756
|
+
mathBlockExtension,
|
|
757
|
+
mathEnvBlockExtension,
|
|
758
|
+
mathExtension,
|
|
759
|
+
boundedAutolinkExtension,
|
|
760
|
+
strikethroughExtension,
|
|
761
|
+
],
|
|
751
762
|
});
|
|
752
763
|
|
|
753
|
-
// ---------------------------------------------------------------------------
|
|
754
|
-
// GFM `url` tokenizer gate
|
|
755
|
-
// ---------------------------------------------------------------------------
|
|
756
|
-
// marked tries the bundled GFM `url` tokenizer at every inline tokenization
|
|
757
|
-
// step, and its regex is expensive to FAIL: the email alternative
|
|
758
|
-
// `^[A-Za-z0-9._+-]+(@)…` linearly consumes an identifier run, then backtracks
|
|
759
|
-
// it one character at a time when no `@` follows. A 71414-sample / 1ms CPU
|
|
760
|
-
// profile of the TUI put 73.3% of total CPU (74.9s of a 102s capture) inside
|
|
761
|
-
// this single regex. The override below runs an O(bounded) charCode gate first
|
|
762
|
-
// and only falls through to the built-in tokenizer — by returning `false`,
|
|
763
|
-
// marked's tokenizer-override fallback contract — when a match is possible.
|
|
764
|
-
//
|
|
765
|
-
// Conservativeness argument. The built-in rule (no flags) is
|
|
766
|
-
// /^((?:[hH][tT][tT][pP][sS]?|[fF][tT][pP]):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*
|
|
767
|
-
// |^[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/
|
|
768
|
-
// Both alternatives are anchored, so any match constrains the head of src:
|
|
769
|
-
// • Branch 1 requires src to start with `http://`, `https://`, `ftp://`
|
|
770
|
-
// (scheme letters in any case) or lowercase `www.`. The gate accepts all of
|
|
771
|
-
// these via isAutolinkSchemeAt(src, 0); it also over-accepts `WWW.`, a
|
|
772
|
-
// harmless false positive (the built-in regex simply fails to match).
|
|
773
|
-
// • Branch 2 requires src to start with one-or-more chars from
|
|
774
|
-
// `[A-Za-z0-9._+-]` immediately followed by `@`. The gate scans that exact
|
|
775
|
-
// class: if the run ends within URL_GATE_EMAIL_SCAN_LIMIT chars it accepts
|
|
776
|
-
// iff the terminator is `@`; a run reaching the limit is accepted
|
|
777
|
-
// unconditionally. Every src branch 2 can match is therefore accepted —
|
|
778
|
-
// the gate never rejects a src the built-in regex would match.
|
|
779
|
-
const URL_GATE_EMAIL_SCAN_LIMIT = 320;
|
|
780
|
-
|
|
781
|
-
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
782
|
-
export function urlTokenPossible(src: string): boolean {
|
|
783
|
-
if (isAutolinkSchemeAt(src, 0)) return true;
|
|
784
|
-
let i = 0;
|
|
785
|
-
while (i < URL_GATE_EMAIL_SCAN_LIMIT) {
|
|
786
|
-
const c = src.charCodeAt(i);
|
|
787
|
-
const isLocalChar =
|
|
788
|
-
(c >= 97 && c <= 122) /* a-z */ ||
|
|
789
|
-
(c >= 65 && c <= 90) /* A-Z */ ||
|
|
790
|
-
(c >= 48 && c <= 57) /* 0-9 */ ||
|
|
791
|
-
c === 46 /* . */ ||
|
|
792
|
-
c === 95 /* _ */ ||
|
|
793
|
-
c === 43 /* + */ ||
|
|
794
|
-
c === 45; /* - */
|
|
795
|
-
if (!isLocalChar) break;
|
|
796
|
-
i++;
|
|
797
|
-
}
|
|
798
|
-
if (i === 0) return false;
|
|
799
|
-
if (i >= URL_GATE_EMAIL_SCAN_LIMIT) return true; // over-long run: give up conservatively
|
|
800
|
-
return src.charCodeAt(i) === 64; /* @ */
|
|
801
|
-
}
|
|
802
|
-
|
|
803
764
|
// Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
|
|
804
765
|
// `((?:.|\n(?!<block-start>))+?)` re-runs its block-start lookahead while
|
|
805
766
|
// expanding character by character, so even a FAILING attempt at offset 0
|
|
@@ -826,12 +787,11 @@ markdownParser.use({
|
|
|
826
787
|
tokenizer: {
|
|
827
788
|
// `false` → marked falls back to the built-in tokenizer;
|
|
828
789
|
// `undefined` → no token here, built-in never runs.
|
|
829
|
-
url(src: string): Tokens.Link | undefined | false {
|
|
830
|
-
return urlTokenPossible(src) ? false : undefined;
|
|
831
|
-
},
|
|
832
790
|
lheading(src: string): Tokens.Heading | undefined | false {
|
|
833
791
|
return lheadingPossible(src) ? false : undefined;
|
|
834
792
|
},
|
|
793
|
+
// Strikethrough is `strikethroughExtension`'s: marked's own looser rule never runs.
|
|
794
|
+
del: () => undefined,
|
|
835
795
|
},
|
|
836
796
|
});
|
|
837
797
|
|
package/src/keys.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
parseKey as parseKeyNative,
|
|
25
25
|
parseKittySequence as parseKittySequenceNative,
|
|
26
26
|
} from "@oh-my-pi/pi-natives";
|
|
27
|
-
import { isInsideTerminalMultiplexer } from "./terminal-capabilities";
|
|
27
|
+
import { isInsideTerminalMultiplexer, isSshSession } from "./terminal-capabilities";
|
|
28
28
|
|
|
29
29
|
// =============================================================================
|
|
30
30
|
// Platform Detection
|
|
@@ -32,9 +32,7 @@ import { isInsideTerminalMultiplexer } from "./terminal-capabilities";
|
|
|
32
32
|
|
|
33
33
|
/** Whether the local process is running directly under Windows Terminal. */
|
|
34
34
|
export function isWindowsTerminalSession(): boolean {
|
|
35
|
-
return (
|
|
36
|
-
Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY
|
|
37
|
-
);
|
|
35
|
+
return Boolean(process.env.WT_SESSION) && !isSshSession();
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/**
|
package/src/native/encode.ts
CHANGED
|
@@ -169,6 +169,8 @@ const EVENT_REQUIRED: Readonly<Record<string, Readonly<Record<string, "string" |
|
|
|
169
169
|
select: { id: "string", item: "string" },
|
|
170
170
|
activate: { id: "string", item: "string" },
|
|
171
171
|
action: { id: "string", act: "string" },
|
|
172
|
+
// `value` varies by control (boolean, number, string, string[], null): the handler checks it.
|
|
173
|
+
change: { id: "string", item: "string" },
|
|
172
174
|
edit: { id: "string", from: "number", to: "number", text: "string", cursor: "number", len: "number" },
|
|
173
175
|
focus: { id: "string" },
|
|
174
176
|
error: { msg: "string" },
|
|
@@ -44,6 +44,12 @@ import { col, node, span, text } from "../native/describe";
|
|
|
44
44
|
import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
|
|
45
45
|
import { actionHint, hintsRow, type NativeHint, overlayCard } from "../native/overlay";
|
|
46
46
|
|
|
47
|
+
export const ADVISOR_REVIEW_MODES = ["turn", "agent-end"] as const;
|
|
48
|
+
export const ADVISOR_SYNC_BACKLOG_MODES = ["off", "1", "3", "5", "strict"] as const;
|
|
49
|
+
|
|
50
|
+
export type AdvisorReviewMode = (typeof ADVISOR_REVIEW_MODES)[number];
|
|
51
|
+
export type AdvisorSyncBacklog = (typeof ADVISOR_SYNC_BACKLOG_MODES)[number];
|
|
52
|
+
|
|
47
53
|
/** One advisor declared in `WATCHDOG.yml`; its instructions specialize the shared baseline. */
|
|
48
54
|
export interface AdvisorConfig {
|
|
49
55
|
name: string;
|
|
@@ -56,6 +62,9 @@ export interface AdvisorConfig {
|
|
|
56
62
|
enabled?: boolean;
|
|
57
63
|
/** Maximum non-blocker notes per advisor prompt update (default 4); blockers are exempt. */
|
|
58
64
|
maxNotesPerUpdate?: number;
|
|
65
|
+
reviewMode?: AdvisorReviewMode;
|
|
66
|
+
reviewInterval?: number;
|
|
67
|
+
syncBacklog?: AdvisorSyncBacklog;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
/** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
|
|
@@ -115,6 +124,7 @@ export interface AdvisorConfigDeps {
|
|
|
115
124
|
externalEditor?: (text: string) => Promise<string | null>;
|
|
116
125
|
scopedModels: ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }>;
|
|
117
126
|
availableToolNames: string[];
|
|
127
|
+
syncBacklog?: AdvisorSyncBacklog;
|
|
118
128
|
/** Formatted advisor-role model shown on the seeded default row (e.g. "anthropic/claude-..."). */
|
|
119
129
|
defaultModelLabel?: string;
|
|
120
130
|
}
|
|
@@ -192,13 +202,44 @@ function formatAdvisorTools(tools: readonly string[] | undefined, emptyLabel: st
|
|
|
192
202
|
return tools.length > 0 ? tools.join(", ") : emptyLabel;
|
|
193
203
|
}
|
|
194
204
|
|
|
205
|
+
/** Choice value for "no per-advisor override"; matches no {@link ADVISOR_SYNC_BACKLOG_MODES} entry. */
|
|
206
|
+
const SYNC_BACKLOG_INHERIT = "inherit";
|
|
207
|
+
|
|
208
|
+
/** One-line cadence label: mode plus interval. */
|
|
209
|
+
function formatReviewCadence(advisor: AdvisorConfig): string {
|
|
210
|
+
const reviewInterval = advisor.reviewInterval ?? 1;
|
|
211
|
+
return `${advisor.reviewMode ?? "turn"} · every ${reviewInterval} eligible update${reviewInterval === 1 ? "" : "s"}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Picker description for one explicit catch-up policy value. */
|
|
215
|
+
function describeSyncBacklogMode(mode: AdvisorSyncBacklog): string {
|
|
216
|
+
switch (mode) {
|
|
217
|
+
case "off":
|
|
218
|
+
return "Never wait for this advisor's backlog.";
|
|
219
|
+
case "strict":
|
|
220
|
+
return "Wait for all of this advisor's scheduled reviews; no wall-clock cap.";
|
|
221
|
+
default:
|
|
222
|
+
return `Wait until fewer than ${mode} scheduled reviews remain (30s cap).`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
195
226
|
/** Soft-wrap plain text to `width`, returning at least one (possibly empty) line. */
|
|
196
227
|
function wrap(text: string, width: number): string[] {
|
|
197
228
|
if (!text) return [""];
|
|
198
229
|
return Bun.wrapAnsi(text, Math.max(1, width), { trim: false }).split("\n");
|
|
199
230
|
}
|
|
200
231
|
|
|
201
|
-
type Screen =
|
|
232
|
+
type Screen =
|
|
233
|
+
| "list"
|
|
234
|
+
| "detail"
|
|
235
|
+
| "name"
|
|
236
|
+
| "model"
|
|
237
|
+
| "tools"
|
|
238
|
+
| "thinking"
|
|
239
|
+
| "review-mode"
|
|
240
|
+
| "review-interval"
|
|
241
|
+
| "sync-backlog"
|
|
242
|
+
| "instructions";
|
|
202
243
|
|
|
203
244
|
/** Footer key hints per screen for the described overlay (the ANSI footer strings carry the same keys). */
|
|
204
245
|
function screenHints(screen: Screen): (NativeHint | undefined)[] {
|
|
@@ -220,7 +261,11 @@ function screenHints(screen: Screen): (NativeHint | undefined)[] {
|
|
|
220
261
|
actionHint("tui.select.cancel", "back"),
|
|
221
262
|
];
|
|
222
263
|
case "thinking":
|
|
264
|
+
case "review-mode":
|
|
265
|
+
case "sync-backlog":
|
|
223
266
|
return [actionHint("tui.select.confirm", "pick"), actionHint("tui.select.cancel", "back")];
|
|
267
|
+
case "review-interval":
|
|
268
|
+
return [actionHint("tui.input.submit", "save"), actionHint("tui.select.cancel", "cancel")];
|
|
224
269
|
case "tools":
|
|
225
270
|
return [
|
|
226
271
|
actionHint("tui.select.confirm", "toggle"),
|
|
@@ -432,6 +477,44 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
432
477
|
defaultLabel: model ? (this.#defaultModelLabel ?? "advisor role default") : undefined,
|
|
433
478
|
control: { k: "action", label: model || this.#defaultModelLabel || "advisor role default", act: "edit" },
|
|
434
479
|
},
|
|
480
|
+
{
|
|
481
|
+
id: "reviewMode",
|
|
482
|
+
label: "Review mode",
|
|
483
|
+
hint: "turn reviews every primary turn; agent-end reviews only final yields.",
|
|
484
|
+
changed: advisor.reviewMode !== undefined ? true : undefined,
|
|
485
|
+
control: {
|
|
486
|
+
k: "choice",
|
|
487
|
+
value: advisor.reviewMode ?? "turn",
|
|
488
|
+
options: ADVISOR_REVIEW_MODES.map(mode => ({ value: mode, label: mode })),
|
|
489
|
+
style: "segmented",
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
id: "reviewInterval",
|
|
494
|
+
label: "Review interval",
|
|
495
|
+
hint: "Review every Nth eligible update; skipped updates join the next review.",
|
|
496
|
+
changed: advisor.reviewInterval !== undefined ? true : undefined,
|
|
497
|
+
control: { k: "number", value: advisor.reviewInterval ?? 1, min: 1, step: 1 },
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
id: "syncBacklog",
|
|
501
|
+
label: "Sync backlog",
|
|
502
|
+
hint: `Catch-up policy; inherit follows advisor.syncBacklog (currently ${this.#deps.syncBacklog ?? "off"}).`,
|
|
503
|
+
changed: advisor.syncBacklog !== undefined ? true : undefined,
|
|
504
|
+
control: {
|
|
505
|
+
k: "choice",
|
|
506
|
+
value: advisor.syncBacklog ?? SYNC_BACKLOG_INHERIT,
|
|
507
|
+
options: [
|
|
508
|
+
{ value: SYNC_BACKLOG_INHERIT, label: "inherit" },
|
|
509
|
+
...ADVISOR_SYNC_BACKLOG_MODES.map(mode => ({
|
|
510
|
+
value: mode,
|
|
511
|
+
label: mode,
|
|
512
|
+
detail: describeSyncBacklogMode(mode),
|
|
513
|
+
})),
|
|
514
|
+
],
|
|
515
|
+
style: "menu",
|
|
516
|
+
},
|
|
517
|
+
},
|
|
435
518
|
{
|
|
436
519
|
id: "tools",
|
|
437
520
|
label: "Tools",
|
|
@@ -499,6 +582,9 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
499
582
|
const overlay =
|
|
500
583
|
this.#screen === "model" ||
|
|
501
584
|
this.#screen === "thinking" ||
|
|
585
|
+
this.#screen === "review-mode" ||
|
|
586
|
+
this.#screen === "review-interval" ||
|
|
587
|
+
this.#screen === "sync-backlog" ||
|
|
502
588
|
this.#screen === "tools" ||
|
|
503
589
|
this.#screen === "instructions";
|
|
504
590
|
const editor = overlay ? this.#active : undefined;
|
|
@@ -573,6 +659,24 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
573
659
|
advisor.name = event.value.trim();
|
|
574
660
|
this.#dirty = true;
|
|
575
661
|
this.#showDetail(index);
|
|
662
|
+
} else if (event.item === "reviewMode" && (typeof event.value === "string" || event.value === null)) {
|
|
663
|
+
// `turn` is the default, stored as an omitted field; `null` resets to it.
|
|
664
|
+
const mode = event.value === null ? "turn" : ADVISOR_REVIEW_MODES.find(m => m === event.value);
|
|
665
|
+
if (!mode) return;
|
|
666
|
+
advisor.reviewMode = mode === "turn" ? undefined : mode;
|
|
667
|
+
this.#dirty = true;
|
|
668
|
+
this.#showDetail(index);
|
|
669
|
+
} else if (event.item === "reviewInterval" && (typeof event.value === "number" || event.value === null)) {
|
|
670
|
+
const interval = event.value ?? 1;
|
|
671
|
+
if (!Number.isSafeInteger(interval) || interval < 1) return;
|
|
672
|
+
advisor.reviewInterval = interval === 1 ? undefined : interval;
|
|
673
|
+
this.#dirty = true;
|
|
674
|
+
this.#showDetail(index);
|
|
675
|
+
} else if (event.item === "syncBacklog" && (typeof event.value === "string" || event.value === null)) {
|
|
676
|
+
// The inherit sentinel and `null` match no mode, so the override clears.
|
|
677
|
+
advisor.syncBacklog = ADVISOR_SYNC_BACKLOG_MODES.find(mode => mode === event.value);
|
|
678
|
+
this.#dirty = true;
|
|
679
|
+
this.#showDetail(index);
|
|
576
680
|
}
|
|
577
681
|
return;
|
|
578
682
|
}
|
|
@@ -689,6 +793,8 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
689
793
|
{ k: "Enabled", v: advisor.enabled === false ? "○ off" : "● on" },
|
|
690
794
|
{ k: "Model", v: model },
|
|
691
795
|
{ k: "Tools", v: formatAdvisorTools(advisor.tools, "no tools") },
|
|
796
|
+
{ k: "Review", v: formatReviewCadence(advisor) },
|
|
797
|
+
{ k: "Sync backlog", v: this.#syncBacklogLabel(advisor) },
|
|
692
798
|
],
|
|
693
799
|
}),
|
|
694
800
|
text([span("Instructions:", "dim")]),
|
|
@@ -822,12 +928,15 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
822
928
|
#advisorPreview(advisor: AdvisorConfig, bodyWidth: number): string[] {
|
|
823
929
|
const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
|
|
824
930
|
const tools = formatAdvisorTools(advisor.tools, "no tools");
|
|
931
|
+
const syncBacklog = this.#syncBacklogLabel(advisor);
|
|
825
932
|
const lines = [
|
|
826
933
|
theme.bold(advisor.name || "(unnamed)"),
|
|
827
934
|
"",
|
|
828
935
|
`${theme.fg("dim", "Enabled:")} ${advisor.enabled === false ? "○ off" : "● on"}`,
|
|
829
936
|
`${theme.fg("dim", "Model:")} ${model}`,
|
|
830
937
|
`${theme.fg("dim", "Tools:")} ${tools}`,
|
|
938
|
+
`${theme.fg("dim", "Review:")} ${formatReviewCadence(advisor)}`,
|
|
939
|
+
`${theme.fg("dim", "Sync backlog:")} ${syncBacklog}`,
|
|
831
940
|
"",
|
|
832
941
|
theme.fg("dim", "Instructions:"),
|
|
833
942
|
];
|
|
@@ -893,6 +1002,9 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
893
1002
|
advisor?.name === "default" &&
|
|
894
1003
|
!advisor.model?.trim() &&
|
|
895
1004
|
advisor.tools === undefined &&
|
|
1005
|
+
advisor.reviewMode === undefined &&
|
|
1006
|
+
advisor.reviewInterval === undefined &&
|
|
1007
|
+
advisor.syncBacklog === undefined &&
|
|
896
1008
|
!advisor.instructions?.trim() &&
|
|
897
1009
|
advisor.enabled !== false &&
|
|
898
1010
|
advisor.maxNotesPerUpdate === undefined
|
|
@@ -902,7 +1014,12 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
902
1014
|
#advisorSummary(advisor: AdvisorConfig): string {
|
|
903
1015
|
const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
|
|
904
1016
|
const tools = formatAdvisorTools(advisor.tools, "no tools");
|
|
905
|
-
return `${model} · ${tools}`;
|
|
1017
|
+
return `${model} · ${tools} · review ${advisor.reviewMode ?? "turn"}/${advisor.reviewInterval ?? 1}`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** The advisor's explicit catch-up override, else the inherited global policy. */
|
|
1021
|
+
#syncBacklogLabel(advisor: AdvisorConfig): string {
|
|
1022
|
+
return advisor.syncBacklog ?? `${this.#deps.syncBacklog ?? "off"} (inherited)`;
|
|
906
1023
|
}
|
|
907
1024
|
|
|
908
1025
|
#showList(): void {
|
|
@@ -1000,6 +1117,8 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
1000
1117
|
this.#detailIndex = index;
|
|
1001
1118
|
const modelDescription = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
|
|
1002
1119
|
const toolsDescription = formatAdvisorTools(advisor.tools, "no tools");
|
|
1120
|
+
const reviewMode = advisor.reviewMode ?? "turn";
|
|
1121
|
+
const reviewInterval = advisor.reviewInterval ?? 1;
|
|
1003
1122
|
const items: SelectItem[] = [
|
|
1004
1123
|
{ value: "name", label: "Name", description: advisor.name },
|
|
1005
1124
|
{
|
|
@@ -1008,6 +1127,13 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
1008
1127
|
description: advisor.enabled === false ? "○ off" : "● on",
|
|
1009
1128
|
},
|
|
1010
1129
|
{ value: "model", label: "Model", description: modelDescription },
|
|
1130
|
+
{ value: "reviewMode", label: "Review mode", description: reviewMode },
|
|
1131
|
+
{ value: "reviewInterval", label: "Review interval", description: String(reviewInterval) },
|
|
1132
|
+
{
|
|
1133
|
+
value: "syncBacklog",
|
|
1134
|
+
label: "Sync backlog",
|
|
1135
|
+
description: this.#syncBacklogLabel(advisor),
|
|
1136
|
+
},
|
|
1011
1137
|
];
|
|
1012
1138
|
if (advisor.model?.trim()) {
|
|
1013
1139
|
items.push({ value: "resetModel", label: "Reset model to advisor-role default" });
|
|
@@ -1043,6 +1169,15 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
1043
1169
|
case "model":
|
|
1044
1170
|
this.#showModelPicker(index);
|
|
1045
1171
|
return;
|
|
1172
|
+
case "reviewMode":
|
|
1173
|
+
this.#showReviewModePicker(index);
|
|
1174
|
+
return;
|
|
1175
|
+
case "reviewInterval":
|
|
1176
|
+
this.#showReviewIntervalEditor(index);
|
|
1177
|
+
return;
|
|
1178
|
+
case "syncBacklog":
|
|
1179
|
+
this.#showSyncBacklogPicker(index);
|
|
1180
|
+
return;
|
|
1046
1181
|
case "tools":
|
|
1047
1182
|
this.#showToolsEditor(index, new Set(this.#doc.advisors[index].tools ?? this.#deps.defaultToolNames), 0);
|
|
1048
1183
|
return;
|
|
@@ -1137,6 +1272,97 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
1137
1272
|
`Thinking effort for ${selector} · ${editorKey("tui.select.confirm")} / click pick · ${editorKey("tui.select.cancel")} back`,
|
|
1138
1273
|
);
|
|
1139
1274
|
}
|
|
1275
|
+
#showReviewModePicker(index: number): void {
|
|
1276
|
+
const advisor = this.#doc.advisors[index];
|
|
1277
|
+
if (!advisor) {
|
|
1278
|
+
this.#showList();
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const current = advisor.reviewMode ?? ADVISOR_REVIEW_MODES[0];
|
|
1282
|
+
const items: SelectItem[] = ADVISOR_REVIEW_MODES.map(mode => ({
|
|
1283
|
+
value: mode,
|
|
1284
|
+
label: mode === current ? `${mode} (current)` : mode,
|
|
1285
|
+
description:
|
|
1286
|
+
mode === "turn"
|
|
1287
|
+
? "Review every primary turn (tool-call round)."
|
|
1288
|
+
: "Review only at agent end (once per run).",
|
|
1289
|
+
}));
|
|
1290
|
+
const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme());
|
|
1291
|
+
list.setSelectedIndex(ADVISOR_REVIEW_MODES.indexOf(current));
|
|
1292
|
+
list.onSelect = item => {
|
|
1293
|
+
advisor.reviewMode = item.value === "turn" ? undefined : "agent-end";
|
|
1294
|
+
this.#dirty = true;
|
|
1295
|
+
this.#showDetail(index);
|
|
1296
|
+
};
|
|
1297
|
+
list.onCancel = () => this.#showDetail(index);
|
|
1298
|
+
this.#setScreen(
|
|
1299
|
+
"review-mode",
|
|
1300
|
+
list,
|
|
1301
|
+
`${editorKey("tui.select.confirm")} / click choose review mode · ${editorKey("tui.select.cancel")} back`,
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
#showReviewIntervalEditor(index: number): void {
|
|
1306
|
+
const advisor = this.#doc.advisors[index];
|
|
1307
|
+
if (!advisor) {
|
|
1308
|
+
this.#showList();
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const input = new Input();
|
|
1312
|
+
input.setValue(String(advisor.reviewInterval ?? 1));
|
|
1313
|
+
input.onSubmit = value => {
|
|
1314
|
+
const interval = Number(value.trim());
|
|
1315
|
+
if (!Number.isSafeInteger(interval) || interval < 1) {
|
|
1316
|
+
this.#cb.notify("Review interval must be a positive integer.");
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
advisor.reviewInterval = interval === 1 ? undefined : interval;
|
|
1320
|
+
this.#dirty = true;
|
|
1321
|
+
this.#showDetail(index);
|
|
1322
|
+
};
|
|
1323
|
+
input.onEscape = () => this.#showDetail(index);
|
|
1324
|
+
this.#setScreen(
|
|
1325
|
+
"review-interval",
|
|
1326
|
+
input,
|
|
1327
|
+
`Type a positive integer · ${editorKey("tui.input.submit")} save · ${editorKey("tui.select.cancel")} cancel`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
#showSyncBacklogPicker(index: number): void {
|
|
1332
|
+
const advisor = this.#doc.advisors[index];
|
|
1333
|
+
if (!advisor) {
|
|
1334
|
+
this.#showList();
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const global = this.#deps.syncBacklog ?? "off";
|
|
1338
|
+
const current = advisor.syncBacklog;
|
|
1339
|
+
const items: SelectItem[] = [
|
|
1340
|
+
{
|
|
1341
|
+
value: SYNC_BACKLOG_INHERIT,
|
|
1342
|
+
label: current === undefined ? "inherit (current)" : "inherit",
|
|
1343
|
+
description: `Follow the global advisor.syncBacklog setting (currently "${global}").`,
|
|
1344
|
+
},
|
|
1345
|
+
...ADVISOR_SYNC_BACKLOG_MODES.map(mode => ({
|
|
1346
|
+
value: mode,
|
|
1347
|
+
label: mode === current ? `${mode} (current)` : mode,
|
|
1348
|
+
description: describeSyncBacklogMode(mode),
|
|
1349
|
+
})),
|
|
1350
|
+
];
|
|
1351
|
+
const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme());
|
|
1352
|
+
list.setSelectedIndex(current === undefined ? 0 : ADVISOR_SYNC_BACKLOG_MODES.indexOf(current) + 1);
|
|
1353
|
+
list.onSelect = item => {
|
|
1354
|
+
// The inherit sentinel matches no mode, so `find` yields undefined — the override clears.
|
|
1355
|
+
advisor.syncBacklog = ADVISOR_SYNC_BACKLOG_MODES.find(mode => mode === item.value);
|
|
1356
|
+
this.#dirty = true;
|
|
1357
|
+
this.#showDetail(index);
|
|
1358
|
+
};
|
|
1359
|
+
list.onCancel = () => this.#showDetail(index);
|
|
1360
|
+
this.#setScreen(
|
|
1361
|
+
"sync-backlog",
|
|
1362
|
+
list,
|
|
1363
|
+
`${editorKey("tui.select.confirm")} / click choose catch-up policy · ${editorKey("tui.select.cancel")} back`,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1140
1366
|
|
|
1141
1367
|
#showToolsEditor(index: number, selected: Set<string>, cursor: number): void {
|
|
1142
1368
|
const all = this.#availableToolNames;
|
|
@@ -528,6 +528,7 @@ export function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]
|
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
export class AskDialogComponent implements Component {
|
|
531
|
+
readonly retireDisplacedTranscript = true;
|
|
531
532
|
#states: QuestionState[];
|
|
532
533
|
#activeTabIndex = 0;
|
|
533
534
|
#submitScrollOffset = 0;
|