scream-code 0.5.2 → 0.5.3-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.
Files changed (2) hide show
  1. package/dist/main.mjs +1600 -1649
  2. package/package.json +1 -1
package/dist/main.mjs CHANGED
@@ -122350,605 +122350,19 @@ function dismissGoalPanel(host) {
122350
122350
  }
122351
122351
  }
122352
122352
  //#endregion
122353
- //#region src/utils/git/git-status.ts
122354
- /**
122355
- * Cached git branch + working-tree status for the footer/statusline.
122356
- *
122357
- * Branch name refreshes every 5s, porcelain status every 15s. Branch
122358
- * and status reads stay synchronous with short timeouts. Pull request
122359
- * lookup uses an async cache so a slow `gh pr view` never blocks
122360
- * footer rendering.
122361
- */
122362
- const BRANCH_TTL_MS = 5e3;
122363
- const STATUS_TTL_MS = 15e3;
122364
- const PULL_REQUEST_TTL_MS = 6e4;
122365
- const SPAWN_TIMEOUT_MS = 500;
122366
- const PR_SPAWN_TIMEOUT_MS = 5e3;
122367
- const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/;
122368
- function createGitStatusCache(workDir, options = {}) {
122369
- const isRepo = detectGitRepo(workDir);
122370
- let branch = {
122371
- value: null,
122372
- fetchedAt: 0
122373
- };
122374
- let status = {
122375
- dirty: false,
122376
- ahead: 0,
122377
- behind: 0,
122378
- diffAdded: 0,
122379
- diffDeleted: 0,
122380
- fetchedAt: 0
122381
- };
122382
- let pullRequest = {
122383
- value: null,
122384
- branch: null,
122385
- fetchedAt: 0,
122386
- pendingBranch: null,
122387
- requestId: 0
122388
- };
122389
- return { getStatus: () => {
122390
- if (!isRepo) return null;
122391
- const now = Date.now();
122392
- if (now - branch.fetchedAt >= BRANCH_TTL_MS) branch = {
122393
- value: readBranch(workDir),
122394
- fetchedAt: now
122395
- };
122396
- if (branch.value === null) return null;
122397
- if (now - status.fetchedAt >= STATUS_TTL_MS) status = {
122398
- ...readStatus(workDir),
122399
- fetchedAt: now
122400
- };
122401
- refreshPullRequestIfNeeded(branch.value, now);
122402
- return {
122403
- branch: branch.value,
122404
- dirty: status.dirty,
122405
- ahead: status.ahead,
122406
- behind: status.behind,
122407
- diffAdded: status.diffAdded,
122408
- diffDeleted: status.diffDeleted,
122409
- pullRequest: pullRequest.branch === branch.value ? pullRequest.value : null
122410
- };
122411
- } };
122412
- function refreshPullRequestIfNeeded(branchName, now) {
122413
- if (pullRequest.pendingBranch === branchName) return;
122414
- const fetchedAt = pullRequest.branch === branchName ? pullRequest.fetchedAt : 0;
122415
- if (now - fetchedAt < PULL_REQUEST_TTL_MS) return;
122416
- const requestId = pullRequest.requestId + 1;
122417
- pullRequest = {
122418
- value: pullRequest.branch === branchName ? pullRequest.value : null,
122419
- branch: branchName,
122420
- fetchedAt,
122421
- pendingBranch: branchName,
122422
- requestId
122423
- };
122424
- readPullRequest(workDir).then((value) => {
122425
- if (pullRequest.requestId !== requestId) return;
122426
- const changed = !samePullRequest(pullRequest.branch === branchName ? pullRequest.value : null, value);
122427
- pullRequest = {
122428
- value,
122429
- branch: branchName,
122430
- fetchedAt: Date.now(),
122431
- pendingBranch: null,
122432
- requestId
122433
- };
122434
- if (changed) options.onChange?.();
122435
- });
122436
- }
122437
- }
122438
- function detectGitRepo(workDir) {
122439
- try {
122440
- const result = spawnSync("git", [
122441
- "-C",
122442
- workDir,
122443
- "rev-parse",
122444
- "--is-inside-work-tree"
122445
- ], {
122446
- encoding: "utf8",
122447
- timeout: SPAWN_TIMEOUT_MS
122448
- });
122449
- return result.status === 0 && result.stdout.trim() === "true";
122450
- } catch {
122451
- return false;
122452
- }
122453
- }
122454
- function readBranch(workDir) {
122455
- try {
122456
- const result = spawnSync("git", [
122457
- "-C",
122458
- workDir,
122459
- "branch",
122460
- "--show-current"
122461
- ], {
122462
- encoding: "utf8",
122463
- timeout: SPAWN_TIMEOUT_MS
122464
- });
122465
- if (result.status !== 0) return null;
122466
- const name = result.stdout.trim();
122467
- return name.length > 0 ? name : null;
122468
- } catch {
122469
- return null;
122470
- }
122471
- }
122472
- function readStatus(workDir) {
122473
- try {
122474
- const result = spawnSync("git", [
122475
- "-C",
122476
- workDir,
122477
- "status",
122478
- "--porcelain",
122479
- "-b"
122480
- ], {
122481
- encoding: "utf8",
122482
- timeout: SPAWN_TIMEOUT_MS,
122483
- maxBuffer: 4 * 1024 * 1024
122484
- });
122485
- if (result.status !== 0) return {
122486
- dirty: false,
122487
- ahead: 0,
122488
- behind: 0,
122489
- diffAdded: 0,
122490
- diffDeleted: 0
122491
- };
122492
- let dirty = false;
122493
- let ahead = 0;
122494
- let behind = 0;
122495
- for (const line of result.stdout.split("\n")) if (line.startsWith("## ")) {
122496
- const m = AHEAD_BEHIND_RE.exec(line);
122497
- if (m) {
122498
- ahead = Number.parseInt(m[1] ?? "0", 10) || 0;
122499
- behind = Number.parseInt(m[2] ?? "0", 10) || 0;
122500
- }
122501
- } else if (line.trim().length > 0) dirty = true;
122502
- const diff = dirty ? readDiffStats(workDir) : {
122503
- added: 0,
122504
- deleted: 0
122505
- };
122506
- return {
122507
- dirty,
122508
- ahead,
122509
- behind,
122510
- diffAdded: diff.added,
122511
- diffDeleted: diff.deleted
122512
- };
122513
- } catch {
122514
- return {
122515
- dirty: false,
122516
- ahead: 0,
122517
- behind: 0,
122518
- diffAdded: 0,
122519
- diffDeleted: 0
122520
- };
122521
- }
122522
- }
122523
- function readDiffStats(workDir) {
122524
- try {
122525
- const result = spawnSync("git", [
122526
- "-C",
122527
- workDir,
122528
- "diff",
122529
- "--numstat",
122530
- "HEAD",
122531
- "--"
122532
- ], {
122533
- encoding: "utf8",
122534
- timeout: SPAWN_TIMEOUT_MS,
122535
- maxBuffer: 4 * 1024 * 1024
122536
- });
122537
- if (result.status !== 0) return {
122538
- added: 0,
122539
- deleted: 0
122540
- };
122541
- let added = 0;
122542
- let deleted = 0;
122543
- for (const line of result.stdout.split("\n")) {
122544
- if (!line) continue;
122545
- const [addedText, deletedText] = line.split(" ");
122546
- added += parseDiffNumstatCount(addedText);
122547
- deleted += parseDiffNumstatCount(deletedText);
122548
- }
122549
- return {
122550
- added,
122551
- deleted
122552
- };
122553
- } catch {
122554
- return {
122555
- added: 0,
122556
- deleted: 0
122557
- };
122558
- }
122559
- }
122560
- function parseDiffNumstatCount(value) {
122561
- if (value === void 0 || value === "-") return 0;
122562
- const n = Number.parseInt(value, 10);
122563
- return Number.isFinite(n) && n > 0 ? n : 0;
122564
- }
122565
- function readPullRequest(workDir) {
122566
- return new Promise((resolve) => {
122567
- try {
122568
- execFile("gh", [
122569
- "pr",
122570
- "view",
122571
- "--json",
122572
- "number,url"
122573
- ], {
122574
- cwd: workDir,
122575
- encoding: "utf8",
122576
- env: {
122577
- ...process.env,
122578
- GH_NO_UPDATE_NOTIFIER: "1",
122579
- GH_PROMPT_DISABLED: "1"
122580
- },
122581
- timeout: PR_SPAWN_TIMEOUT_MS,
122582
- maxBuffer: 256 * 1024
122583
- }, (error, stdout) => {
122584
- if (error !== null) {
122585
- resolve(null);
122586
- return;
122587
- }
122588
- resolve(parsePullRequest(stdout));
122589
- });
122590
- } catch {
122591
- resolve(null);
122592
- }
122593
- });
122594
- }
122595
- function samePullRequest(a, b) {
122596
- if (a === null || b === null) return a === b;
122597
- return a.number === b.number && a.url === b.url;
122598
- }
122599
- function parsePullRequest(stdout) {
122600
- try {
122601
- const raw = JSON.parse(stdout);
122602
- if (typeof raw !== "object" || raw === null) return null;
122603
- const record = raw;
122604
- const number = record["number"];
122605
- const url = record["url"];
122606
- if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) return null;
122607
- if (typeof url !== "string" || !isSafeHttpUrl(url)) return null;
122608
- return {
122609
- number,
122610
- url
122611
- };
122612
- } catch {
122613
- return null;
122614
- }
122615
- }
122616
- function isSafeHttpUrl(value) {
122617
- if (hasControlChars(value)) return false;
122618
- try {
122619
- const url = new URL(value);
122620
- return url.protocol === "https:" || url.protocol === "http:";
122621
- } catch {
122622
- return false;
122623
- }
122624
- }
122625
- function hasControlChars(value) {
122626
- for (const char of value) {
122627
- const code = char.codePointAt(0) ?? 0;
122628
- if (code <= 31 || code === 127) return true;
122629
- }
122630
- return false;
122631
- }
122632
- function formatGitBadgeBase(status) {
122633
- const parts = [];
122634
- const diff = formatDiffStats(status);
122635
- if (diff) parts.push(diff);
122636
- let sync = "";
122637
- if (status.ahead > 0) sync += `↑${status.ahead}`;
122638
- if (status.behind > 0) sync += `↓${status.behind}`;
122639
- if (sync) parts.push(sync);
122640
- return parts.length === 0 ? status.branch : `${status.branch} [${parts.join(" ")}]`;
122641
- }
122642
- function formatPullRequestBadge(pullRequest, options = {}) {
122643
- const prText = `[PR#${String(pullRequest.number)}]`;
122644
- return options.linkPullRequest ? toTerminalHyperlink(prText, pullRequest.url) : prText;
122645
- }
122646
- function formatDiffStats(status) {
122647
- const parts = [];
122648
- if (status.diffAdded > 0) parts.push(`+${String(status.diffAdded)}`);
122649
- if (status.diffDeleted > 0) parts.push(`-${String(status.diffDeleted)}`);
122650
- if (parts.length > 0) return parts.join(" ");
122651
- return status.dirty ? "±" : null;
122652
- }
122653
- function toTerminalHyperlink(text, url) {
122654
- if (!isSafeHttpUrl(url)) return text;
122655
- return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
122656
- }
122657
- //#endregion
122658
- //#region src/tui/components/chrome/footer.ts
122659
- const MAX_CWD_SEGMENTS = 3;
122660
- const TIP_ROTATE_INTERVAL_MS = 1e4;
122661
- const TOOLBAR_TIPS = [
122662
- { text: "shift+tab: 计划模式" },
122663
- { text: "/model: 切换模型" },
122664
- {
122665
- text: "ctrl+s: 中途干预",
122666
- priority: 2
122667
- },
122668
- {
122669
- text: "/compact: 压缩上下文",
122670
- priority: 2
122671
- },
122672
- { text: "ctrl+o: 展开工具输出" },
122673
- { text: "/tasks: 后台任务" },
122674
- { text: "shift+enter: 换行" },
122675
- {
122676
- text: "/init: 生成 AGENTS.md",
122677
- priority: 2
122678
- },
122679
- { text: "@: 提及文件" },
122680
- { text: "ctrl+c: 取消" },
122681
- { text: "/theme: 切换主题" },
122682
- { text: "/auto: 自动权限模式" },
122683
- { text: "/yes: 自动批准" },
122684
- { text: "/help: 显示命令" },
122685
- {
122686
- text: "/config: 选择并配置你常用的模型商",
122687
- solo: true,
122688
- priority: 3
122689
- },
122690
- {
122691
- text: "让 Scream 安排任务,例如 \"2个小时后提醒我去拿快递\"",
122692
- solo: true,
122693
- priority: 3
122694
- }
122695
- ];
122696
- /**
122697
- * Expand tips into a rotation sequence using smooth weighted round-robin
122698
- * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while
122699
- * staying evenly spread, so a tip generally does not land next to its own
122700
- * duplicate. Deterministic and computed once at module load. Exported for
122701
- * unit testing.
122702
- */
122703
- function buildWeightedTips(tips) {
122704
- const items = tips.map((t) => ({
122705
- tip: t,
122706
- weight: Math.max(1, Math.trunc(t.priority ?? 1)),
122707
- current: 0
122708
- }));
122709
- const total = items.reduce((sum, it) => sum + it.weight, 0);
122710
- const seq = [];
122711
- for (let n = 0; n < total; n++) {
122712
- let best = items[0];
122713
- for (const it of items) {
122714
- it.current += it.weight;
122715
- if (it.current > best.current) best = it;
122716
- }
122717
- best.current -= total;
122718
- seq.push(best.tip);
122719
- }
122720
- return seq;
122721
- }
122722
- const ROTATION = buildWeightedTips(TOOLBAR_TIPS);
122723
- function currentTipIndex() {
122724
- return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS);
122725
- }
122726
- /**
122727
- * Pick the tip(s) for a rotation index over the weighted ROTATION sequence.
122728
- * `primary` is always shown when it fits; `pair` (primary + next tip joined
122729
- * by the separator) is offered for wide terminals. Pairing is skipped when
122730
- * the current/next tip is `solo` or when the neighbour is a duplicate of the
122731
- * current tip (which can happen at the wrap boundary), keeping long/important
122732
- * tips on their own and avoiding "X | X".
122733
- */
122734
- function tipsForIndex(index) {
122735
- const n = ROTATION.length;
122736
- if (n === 0) return {
122737
- primary: "",
122738
- pair: null
122739
- };
122740
- const offset = (index % n + n) % n;
122741
- const current = ROTATION[offset];
122742
- if (n === 1 || current.solo) return {
122743
- primary: current.text,
122744
- pair: null
122745
- };
122746
- const next = ROTATION[(offset + 1) % n];
122747
- if (next.solo || next.text === current.text) return {
122748
- primary: current.text,
122749
- pair: null
122750
- };
122751
- return {
122752
- primary: current.text,
122753
- pair: current.text + " | " + next.text
122754
- };
122755
- }
122756
- function shortenModel(model) {
122757
- if (!model) return model;
122758
- const slash = model.lastIndexOf("/");
122759
- return slash >= 0 ? model.slice(slash + 1) : model;
122760
- }
122761
- function modelDisplayName(state) {
122762
- const model = state.availableModels[state.model];
122763
- return model?.displayName ?? model?.model ?? state.model;
122764
- }
122765
- function shortenCwd(path) {
122766
- if (!path) return path;
122767
- const home = process.env["HOME"] ?? "";
122768
- let work = path;
122769
- if (home && path === home) return "~";
122770
- if (home && path.startsWith(home + "/")) work = "~" + path.slice(home.length);
122771
- const segments = work.split("/").filter((s) => s.length > 0);
122772
- if (segments.length <= MAX_CWD_SEGMENTS) return work;
122773
- return `…/${segments.slice(-3).join("/")}`;
122774
- }
122775
- function formatTokenCount(n) {
122776
- if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
122777
- if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
122778
- return String(n);
122779
- }
122780
- function safeUsage(usage) {
122781
- return safeUsageRatio(usage);
122782
- }
122783
- function formatContextStatus(usage, tokens, maxTokens) {
122784
- const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`;
122785
- if (maxTokens && maxTokens > 0 && tokens !== void 0) return `上下文:${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
122786
- return `上下文:${pct}`;
122787
- }
122788
- const BRAND_COLORS = [
122789
- "#72A4E9",
122790
- "#A78BFA",
122791
- "#34D399"
122792
- ];
122793
- const GRADIENT_CYCLE_MS = 4e3;
122794
- const SPINNER_FRAMES$1 = [
122795
- "●",
122796
- "◉",
122797
- "◎",
122798
- "◌",
122799
- "○",
122800
- "◌",
122801
- "◎",
122802
- "◉"
122803
- ];
122804
- const SPINNER_TICK_MS = 120;
122805
- function hexToRgb$1(hex) {
122806
- const v = parseInt(hex.slice(1), 16);
122807
- return [
122808
- v >> 16 & 255,
122809
- v >> 8 & 255,
122810
- v & 255
122811
- ];
122812
- }
122813
- function lerpGradient(t) {
122814
- const count = BRAND_COLORS.length;
122815
- const segment = Math.min(t * count, count - 1);
122816
- const idx = Math.floor(segment);
122817
- const localT = segment - idx;
122818
- const nextIdx = (idx + 1) % count;
122819
- const [r0, g0, b0] = hexToRgb$1(BRAND_COLORS[idx]);
122820
- const [r1, g1, b1] = hexToRgb$1(BRAND_COLORS[nextIdx]);
122821
- const r = Math.round(r0 + (r1 - r0) * localT);
122822
- const g = Math.round(g0 + (g1 - g0) * localT);
122823
- const b = Math.round(b0 + (b1 - b0) * localT);
122824
- return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
122825
- }
122826
- function buildStatusLine(streamingPhase, livePaneMode, streamingStartTime) {
122827
- if (streamingPhase === "idle" && livePaneMode !== "tool") return "○ 空闲";
122828
- let label;
122829
- if (livePaneMode === "tool") label = "执行中";
122830
- else if (streamingPhase === "waiting") label = "等待响应";
122831
- else if (streamingPhase === "thinking") label = "思考中";
122832
- else if (streamingPhase === "composing") label = "输出中";
122833
- else label = "";
122834
- const elapsed = Date.now() - streamingStartTime;
122835
- const totalSeconds = Math.floor(elapsed / 1e3);
122836
- const elapsedStr = totalSeconds < 60 ? `${totalSeconds}s` : `${Math.floor(totalSeconds / 60)}m${totalSeconds % 60}s`;
122837
- const now = Date.now();
122838
- const frame = SPINNER_FRAMES$1[Math.floor(now / SPINNER_TICK_MS) % SPINNER_FRAMES$1.length];
122839
- const gradientColor = lerpGradient(now % GRADIENT_CYCLE_MS / GRADIENT_CYCLE_MS);
122840
- return chalk.hex(gradientColor).bold(frame) + " " + label + " " + elapsedStr;
122841
- }
122842
- function formatFooterGitBadge(status, colors) {
122843
- const base = chalk.hex(colors.status)(formatGitBadgeBase(status));
122844
- if (status.pullRequest === null) return base;
122845
- return `${base} ${chalk.hex(colors.primary)(formatPullRequestBadge(status.pullRequest, { linkPullRequest: true }))}`;
122846
- }
122847
- var FooterComponent = class {
122848
- state;
122849
- colors;
122850
- onGitStatusChange;
122851
- gitCache;
122852
- gitCacheWorkDir;
122853
- transientHint = null;
122854
- /**
122855
- * Non-terminal background-task counts split by kind so the footer can
122856
- * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks
122857
- * spawned via `Shell run_in_background=true`; `agentTasks` covers
122858
- * `agent-*` BPM tasks (background subagents). Either zero hides its
122859
- * respective badge.
122860
- */
122861
- backgroundBashTaskCount = 0;
122862
- backgroundAgentCount = 0;
122863
- constructor(state, colors, onGitStatusChange = () => {}) {
122864
- this.state = state;
122865
- this.colors = colors;
122866
- this.onGitStatusChange = onGitStatusChange;
122867
- this.gitCacheWorkDir = state.workDir;
122868
- this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
122869
- }
122870
- setState(state) {
122871
- if (state.workDir !== this.gitCacheWorkDir) {
122872
- this.gitCacheWorkDir = state.workDir;
122873
- this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
122874
- }
122875
- this.state = state;
122876
- }
122877
- setColors(colors) {
122878
- this.colors = colors;
122879
- }
122880
- /**
122881
- * Short-lived hint that replaces the rotating toolbar tips on line 1.
122882
- * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C
122883
- * again to exit" without requiring a toast/overlay subsystem.
122884
- * Pass `null` to clear.
122885
- */
122886
- setTransientHint(hint) {
122887
- this.transientHint = hint;
122888
- }
122889
- /**
122890
- * Sync both background-task badges with live counts. Each non-zero
122891
- * count produces its own bracketed badge on line 1; zeros hide them
122892
- * independently.
122893
- */
122894
- setBackgroundCounts(counts) {
122895
- this.backgroundBashTaskCount = Math.max(0, counts.bashTasks);
122896
- this.backgroundAgentCount = Math.max(0, counts.agentTasks);
122897
- }
122898
- invalidate() {}
122899
- render(width) {
122900
- const colors = this.colors;
122901
- const state = this.state;
122902
- const left = [];
122903
- if (state.permissionMode === "auto") left.push(chalk.hex(colors.warning).bold("auto"));
122904
- if (state.permissionMode === "yolo") left.push(chalk.hex(colors.warning).bold("YES"));
122905
- if (state.planMode) left.push(chalk.hex(colors.primary).bold("plan"));
122906
- if (state.wolfpackMode) left.push(chalk.hex(colors.primary).bold("wolfpack"));
122907
- if (state.goalActive) left.push(chalk.hex(colors.primary).bold("goal"));
122908
- const model = shortenModel(modelDisplayName(state));
122909
- if (model) {
122910
- const thinkingLabel = state.thinking ? " 思考中" : "";
122911
- left.push(chalk.hex(colors.text)(`${model}${thinkingLabel}`));
122912
- }
122913
- if (this.backgroundBashTaskCount > 0) {
122914
- const noun = this.backgroundBashTaskCount === 1 ? "个任务" : "个任务";
122915
- left.push(chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)}${noun} 运行中]`));
122916
- }
122917
- if (this.backgroundAgentCount > 0) {
122918
- const noun = this.backgroundAgentCount === 1 ? "个代理" : "个代理";
122919
- left.push(chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)}${noun} 运行中]`));
122920
- }
122921
- const cwd = shortenCwd(state.workDir);
122922
- if (cwd) left.push(chalk.hex(colors.status)(cwd));
122923
- const git = this.gitCache.getStatus();
122924
- if (git !== null) left.push(formatFooterGitBadge(git, colors));
122925
- const leftLine = left.join(" ");
122926
- const leftWidth = visibleWidth(leftLine);
122927
- let rightText;
122928
- if (this.transientHint) rightText = chalk.hex(colors.warning).bold(this.transientHint);
122929
- else {
122930
- const statusLine = buildStatusLine(state.streamingPhase, state.livePaneMode, state.streamingStartTime);
122931
- const ccDot = state.ccConnectActive ? chalk.hex(colors.success)("●") : chalk.hex(colors.textDim)("●");
122932
- rightText = chalk.hex(colors.textDim)(ccDot + " " + formatContextStatus(state.contextUsage, state.contextTokens, state.maxContextTokens) + " " + statusLine);
122933
- }
122934
- const rightWidth = visibleWidth(rightText);
122935
- const gap = 3;
122936
- let line1;
122937
- if (leftWidth + gap + rightWidth <= width) {
122938
- const pad = width - leftWidth - rightWidth;
122939
- line1 = leftLine + " ".repeat(pad) + rightText;
122940
- } else if (leftWidth <= width) line1 = leftLine;
122941
- else line1 = truncateToWidth(leftLine, width, "…");
122942
- return [truncateToWidth(line1, width)];
122943
- }
122944
- };
122945
- //#endregion
122946
122353
  //#region src/tui/components/chrome/welcome.ts
122947
122354
  const HUE_STOPS = 24;
122948
122355
  const SUB_STEPS = 5;
122949
122356
  const BREATHE_STEPS = HUE_STOPS * SUB_STEPS;
122950
122357
  const BREATHE_INTERVAL_MS = 40;
122951
- function hexToRgb(hex) {
122358
+ const LOGO_FRAMES = [
122359
+ ["██▄▄▄██", "▐█▄▀▄█▌"],
122360
+ ["██▄▄▄██", "▐▄▄▀▄▄▌"],
122361
+ ["██▄▄▄██", "▐▄▀▄▄▄▌"],
122362
+ ["██▄▄▄██", "▐▄▄▄▀▄▌"],
122363
+ ["██▄▄▄██", "▐█▄▀▄█▌"]
122364
+ ];
122365
+ function hexToRgb$1(hex) {
122952
122366
  return [
122953
122367
  parseInt(hex.slice(1, 3), 16),
122954
122368
  parseInt(hex.slice(3, 5), 16),
@@ -123013,12 +122427,12 @@ function rgbToHex(r, g, b) {
123013
122427
  * it sweeps through all 24 hue stops with smooth sub-step interpolation.
123014
122428
  */
123015
122429
  function buildBreathingPalette(primaryHex, hueStops, subSteps) {
123016
- const [r, g, b] = hexToRgb(primaryHex);
123017
- const [baseHue, sat, lit] = rgbToHsl(r, g, b);
122430
+ const [r, g, b] = hexToRgb$1(primaryHex);
122431
+ const [baseHue] = rgbToHsl(r, g, b);
123018
122432
  const steps = hueStops * subSteps;
123019
122433
  const palette = [];
123020
122434
  for (let i = 0; i < steps; i++) {
123021
- const [rr, gg, bb] = hslToRgb((baseHue + i / steps * 360) % 360, sat, lit);
122435
+ const [rr, gg, bb] = hslToRgb((baseHue + i / steps * 360) % 360, 90, 70);
123022
122436
  palette.push(rgbToHex(rr, gg, bb));
123023
122437
  }
123024
122438
  return palette;
@@ -123058,53 +122472,52 @@ var WelcomeComponent = class {
123058
122472
  render(width) {
123059
122473
  const breatheColor = this.breathePalette[this.breatheFrame] ?? this.colors.primary;
123060
122474
  const logoColor = (s) => chalk.hex(breatheColor)(s);
123061
- const primary = (s) => chalk.hex(this.colors.primary)(s);
123062
- const innerWidth = Math.max(10, width - 4);
123063
- const pad = " ";
123064
- const logo = ["░▒▓██▄▄▄██", "░▒▓▐█▄▀▄█▌"];
123065
- const logoWidth = Math.max(...logo.map((row) => visibleWidth(row)));
123066
- const gap = " ";
123067
- const textWidth = Math.max(4, innerWidth - logoWidth - 2);
123068
- const rightRow0 = truncateToWidth(chalk.bold.hex(this.colors.primary)("欢迎使用Scream 您的中文Ai助手"), textWidth, "…");
123069
- const isLoggedOut = !this.state.model;
123070
122475
  const dim = chalk.hex(this.colors.textDim);
123071
122476
  const labelStyle = chalk.bold.hex(this.colors.textDim);
123072
- const rightRow1 = truncateToWidth(dim(isLoggedOut ? "运行 /config 开始配置。" : "发送 / 进入快捷菜单,/exit 保存并退出"), textWidth, "…");
123073
- const headerLines = [logoColor(logo[0].padEnd(logoWidth)) + gap + rightRow0, logoColor(logo[1].padEnd(logoWidth)) + gap + rightRow1];
122477
+ const innerWidth = Math.max(10, width - 4);
122478
+ const isLoggedOut = !this.state.model;
122479
+ const frame = LOGO_FRAMES[this.breatheTimer !== null ? Math.floor(this.breatheFrame / 24) % LOGO_FRAMES.length : 0];
122480
+ const logo = [logoColor(frame[0]), logoColor(frame[1])];
123074
122481
  const activeModel = this.state.availableModels[this.state.model];
123075
122482
  const modelValue = isLoggedOut ? chalk.hex(this.colors.warning)("未设置,运行 /config") : activeModel?.displayName ?? activeModel?.model ?? this.state.model;
123076
122483
  let versionValue;
123077
122484
  if (this.state.hasNewVersion && this.state.latestVersion !== null) versionValue = chalk.hex(this.colors.warning)(this.state.version) + " " + chalk.hex(this.colors.textDim)("有新版本(" + this.state.latestVersion + ")");
123078
122485
  else versionValue = this.state.version;
123079
- const infoLines = [
123080
- labelStyle("目录: ") + this.state.workDir,
123081
- labelStyle("模型: ") + modelValue,
123082
- labelStyle("版本: ") + versionValue
123083
- ];
123084
- const { primary: tipPrimary, pair: tipPair } = tipsForIndex(currentTipIndex());
123085
- const tip = tipPair && visibleWidth(tipPair) <= innerWidth ? tipPair : tipPrimary;
123086
- const tipLine = chalk.hex(this.colors.textMuted)("Tips: " + tip);
122486
+ const hintText = isLoggedOut ? "运行 /config 开始配置" : "发送 / 进入快捷菜单,/exit 保存并退出";
123087
122487
  const contentLines = [
123088
- ...headerLines,
122488
+ ...logo,
123089
122489
  "",
123090
- ...infoLines,
122490
+ labelStyle("版本:") + " " + versionValue,
122491
+ labelStyle("模型:") + " " + modelValue,
122492
+ labelStyle("目录:") + " " + this.state.workDir,
123091
122493
  "",
123092
- tipLine
122494
+ dim(hintText)
123093
122495
  ];
123094
- const borderTitle = this.borderTitle;
122496
+ const borderTitle = this.borderTitle ?? "";
122497
+ const contentWidth = width - 2;
122498
+ let topBorder;
122499
+ if (borderTitle) {
122500
+ const centerPos = Math.floor(contentWidth / 2);
122501
+ const titleText = `─ ${borderTitle} ─`;
122502
+ const titleStart = centerPos - Math.floor(visibleWidth(titleText) / 2);
122503
+ const leftDash = Math.max(0, titleStart);
122504
+ const rightDash = Math.max(0, contentWidth - leftDash - visibleWidth(titleText));
122505
+ topBorder = logoColor("╭" + "─".repeat(leftDash) + titleText + "─".repeat(rightDash) + "╮");
122506
+ } else topBorder = logoColor("╭" + "─".repeat(contentWidth) + "╮");
123095
122507
  const lines = [
123096
122508
  "",
123097
- borderTitle ? primary("╭─ " + borderTitle + " " + "─".repeat(Math.max(0, width - 5 - visibleWidth(borderTitle))) + "╮") : primary("╭" + "─".repeat(width - 2) + "╮"),
123098
- primary("│") + " ".repeat(width - 2) + primary("│")
122509
+ topBorder,
122510
+ logoColor("│") + " ".repeat(width - 2) + logoColor("│")
123099
122511
  ];
123100
122512
  for (const content of contentLines) {
123101
122513
  const truncated = truncateToWidth(content, innerWidth, "…");
123102
122514
  const vis = visibleWidth(truncated);
123103
- const rightPad = Math.max(0, innerWidth - vis);
123104
- lines.push(primary("│") + pad + truncated + " ".repeat(rightPad) + primary("│"));
122515
+ const centerPad = Math.floor((width - 1 - vis) / 2);
122516
+ const rightPad = width - 2 - vis - centerPad;
122517
+ lines.push(logoColor("│") + " ".repeat(centerPad) + truncated + " ".repeat(rightPad) + logoColor("│"));
123105
122518
  }
123106
- lines.push(primary("│") + " ".repeat(width - 2) + primary("│"));
123107
- lines.push(primary("╰" + "─".repeat(width - 2) + "╯"));
122519
+ lines.push(logoColor("│") + " ".repeat(width - 2) + logoColor("│"));
122520
+ lines.push(logoColor("╰" + "─".repeat(width - 2) + "╯"));
123108
122521
  lines.push("");
123109
122522
  return lines;
123110
122523
  }
@@ -127152,7 +126565,7 @@ async function confirmUninstall(host, label) {
127152
126565
  * restores the editor. The question and answer are never recorded in the
127153
126566
  * main conversation history.
127154
126567
  */
127155
- const SPINNER_FRAMES = [
126568
+ const SPINNER_FRAMES$1 = [
127156
126569
  "⠋",
127157
126570
  "⠙",
127158
126571
  "⠹",
@@ -127212,7 +126625,7 @@ var BtwOverlayComponent = class extends Container {
127212
126625
  startSpinner() {
127213
126626
  this.spinnerFrame = 0;
127214
126627
  this.spinnerInterval = setInterval(() => {
127215
- this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
126628
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES$1.length;
127216
126629
  this.requestRender();
127217
126630
  }, 80);
127218
126631
  }
@@ -127232,7 +126645,7 @@ var BtwOverlayComponent = class extends Container {
127232
126645
  lines.push(chalk.hex(c.primary)("/btw") + chalk.hex(c.textMuted)(" — ") + chalk.hex(c.text)(truncated));
127233
126646
  lines.push("");
127234
126647
  if (this.status === "loading") {
127235
- const spinner = SPINNER_FRAMES[this.spinnerFrame];
126648
+ const spinner = SPINNER_FRAMES$1[this.spinnerFrame];
127236
126649
  lines.push(chalk.hex(c.textMuted)(`${spinner} Answering…`));
127237
126650
  } else if (this.status === "done" && this.markdown !== void 0) {
127238
126651
  const contentWidth = Math.max(20, width - 2);
@@ -131265,1121 +130678,1680 @@ var TasksBrowserApp = class extends Container {
131265
130678
  const task = next.tasks.find((t) => t.taskId === this.pendingStopTaskId);
131266
130679
  if (task === void 0 || isTerminal(task.status)) this.clearPendingStop();
131267
130680
  }
131268
- this.invalidate();
130681
+ this.invalidate();
130682
+ }
130683
+ syncSelectionFromProps() {
130684
+ if (this.sortedVisible.length === 0) {
130685
+ this.selectedIndex = 0;
130686
+ this.listScroll = 0;
130687
+ return;
130688
+ }
130689
+ if (this.props.selectedTaskId !== void 0) {
130690
+ const idx = this.sortedVisible.findIndex((t) => t.taskId === this.props.selectedTaskId);
130691
+ if (idx !== -1) {
130692
+ this.selectedIndex = idx;
130693
+ return;
130694
+ }
130695
+ }
130696
+ if (this.selectedIndex >= this.sortedVisible.length) this.selectedIndex = this.sortedVisible.length - 1;
130697
+ }
130698
+ clearPendingStop() {
130699
+ this.pendingStopTaskId = void 0;
130700
+ if (this.pendingStopTimer !== void 0) {
130701
+ clearTimeout(this.pendingStopTimer);
130702
+ this.pendingStopTimer = void 0;
130703
+ }
130704
+ }
130705
+ emitSelect() {
130706
+ const task = this.sortedVisible[this.selectedIndex];
130707
+ if (task) this.props.onSelect(task.taskId);
130708
+ }
130709
+ handleInput(data) {
130710
+ const k = printableChar(data);
130711
+ if (this.pendingStopTaskId !== void 0) {
130712
+ if (k === "y" || k === "Y") {
130713
+ const taskId = this.pendingStopTaskId;
130714
+ this.clearPendingStop();
130715
+ this.props.onStopConfirmed(taskId);
130716
+ this.invalidate();
130717
+ return;
130718
+ }
130719
+ this.clearPendingStop();
130720
+ this.invalidate();
130721
+ return;
130722
+ }
130723
+ if (matchesKey(data, Key.escape) || k === "q" || k === "Q") {
130724
+ this.props.onCancel();
130725
+ return;
130726
+ }
130727
+ if (matchesKey(data, Key.up) || k === "k") {
130728
+ if (this.sortedVisible.length === 0) return;
130729
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
130730
+ this.emitSelect();
130731
+ this.invalidate();
130732
+ return;
130733
+ }
130734
+ if (matchesKey(data, Key.down) || k === "j") {
130735
+ if (this.sortedVisible.length === 0) return;
130736
+ this.selectedIndex = Math.min(this.sortedVisible.length - 1, this.selectedIndex + 1);
130737
+ this.emitSelect();
130738
+ this.invalidate();
130739
+ return;
130740
+ }
130741
+ if (matchesKey(data, Key.tab) || k === " ") {
130742
+ this.props.onToggleFilter();
130743
+ return;
130744
+ }
130745
+ if (k === "r" || k === "R") {
130746
+ this.props.onRefresh();
130747
+ return;
130748
+ }
130749
+ if (k === "s" || k === "S") {
130750
+ const task = this.sortedVisible[this.selectedIndex];
130751
+ if (task === void 0) return;
130752
+ if (isTerminal(task.status)) {
130753
+ this.props.onStopIgnored?.(task.taskId, "terminal");
130754
+ return;
130755
+ }
130756
+ this.pendingStopTaskId = task.taskId;
130757
+ this.pendingStopTimer = setTimeout(() => {
130758
+ this.clearPendingStop();
130759
+ this.invalidate();
130760
+ }, STOP_CONFIRM_TIMEOUT_MS);
130761
+ this.invalidate();
130762
+ return;
130763
+ }
130764
+ if (k === "o" || k === "O" || matchesKey(data, Key.enter)) {
130765
+ const task = this.sortedVisible[this.selectedIndex];
130766
+ if (task) this.props.onOpenOutput(task.taskId);
130767
+ return;
130768
+ }
130769
+ }
130770
+ /**
130771
+ * Render the entire screen as `terminal.rows` lines of `width` cols.
130772
+ * Layout: header(1) + body(rows-2) + footer(1).
130773
+ */
130774
+ render(width) {
130775
+ const rows = Math.max(1, this.terminal.rows);
130776
+ if (width < MIN_WIDTH || rows < MIN_HEIGHT) return this.renderTooSmall(width, rows);
130777
+ const header = this.renderHeader(width);
130778
+ const footer = this.renderFooter(width);
130779
+ const bodyHeight = rows - 2;
130780
+ const listWidth = Math.max(LIST_COL_MIN, Math.min(LIST_COL_MAX, Math.floor(width * LIST_COL_RATIO)));
130781
+ const rightWidth = width - listWidth;
130782
+ const listFrame = this.renderListFrame(listWidth, bodyHeight);
130783
+ const rightFrames = this.renderRightStack(rightWidth, bodyHeight);
130784
+ const lines = [header];
130785
+ for (let i = 0; i < bodyHeight; i++) lines.push((listFrame[i] ?? " ".repeat(listWidth)) + (rightFrames[i] ?? " ".repeat(rightWidth)));
130786
+ lines.push(footer);
130787
+ return lines;
130788
+ }
130789
+ renderHeader(width) {
130790
+ const colors = this.props.colors;
130791
+ const title = chalk.hex(colors.primary).bold(" TASK BROWSER ");
130792
+ const filterText = chalk.hex(colors.textMuted)(` filter=${this.props.filter === "all" ? "ALL" : "ACTIVE"} `);
130793
+ const counts = countByStatus(this.props.tasks);
130794
+ const countSegments = [];
130795
+ if (counts.running > 0) countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} 运行中 `));
130796
+ if (counts.awaiting > 0) countSegments.push(chalk.hex(colors.warning)(` ${String(counts.awaiting)} 等待中 `));
130797
+ if (counts.completed > 0) countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} 已完成 `));
130798
+ if (counts.terminalFailed > 0) countSegments.push(chalk.hex(colors.error)(` ${String(counts.terminalFailed)} 已中断 `));
130799
+ const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} 总计 `);
130800
+ return fitExactly$1(title + filterText + countSegments.join("") + totals, width);
130801
+ }
130802
+ renderFooter(width) {
130803
+ const colors = this.props.colors;
130804
+ const key = (text) => chalk.hex(colors.primary).bold(text);
130805
+ const dim = (text) => chalk.hex(colors.textMuted)(text);
130806
+ if (this.pendingStopTaskId !== void 0) {
130807
+ const warn = (text) => chalk.hex(colors.warning).bold(text);
130808
+ return fitExactly$1(` ${warn("停止")} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ${key("Y")} ${dim("确认")} ${key("N")} ${dim("取消")} `, width);
130809
+ }
130810
+ const left = [
130811
+ ` ${key("↑↓")} ${dim("选择")}`,
130812
+ `${key("Enter/O")} ${dim("输出")}`,
130813
+ `${key("S")} ${dim("停止")}`,
130814
+ `${key("R")} ${dim("刷新")}`,
130815
+ `${key("Tab")} ${dim("筛选")}`,
130816
+ `${key("Q/Esc")} ${dim("退出")} `
130817
+ ].join(" ");
130818
+ const flash = this.props.flashMessage;
130819
+ if (flash !== void 0 && flash.length > 0) {
130820
+ const flashStyled = chalk.hex(colors.warning)(` ${flash} `);
130821
+ const total = visibleWidth(left) + visibleWidth(flashStyled);
130822
+ if (total <= width) return left + " ".repeat(width - total) + flashStyled;
130823
+ }
130824
+ return fitExactly$1(left, width);
130825
+ }
130826
+ /**
130827
+ * Render a framed box: `┌─ Title ─┐` top, `│ <content> │` sides, `└─┘`
130828
+ * bottom. Result is exactly `width × height` cells. `content` is a
130829
+ * pre-rendered array of inner-width-sized lines; extra rows are padded.
130830
+ */
130831
+ renderFrame(title, content, width, height) {
130832
+ if (height < 2 || width < 4) {
130833
+ const out = [];
130834
+ for (let i = 0; i < height; i++) out.push(" ".repeat(width));
130835
+ return out;
130836
+ }
130837
+ const stroke = this.props.colors.primary;
130838
+ const innerWidth = width - 2;
130839
+ const innerHeight = height - 2;
130840
+ const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title);
130841
+ const titleWidth = visibleWidth(titleStyled);
130842
+ const titleSegmentWidth = visibleWidth(`─ ${titleStyled} `);
130843
+ const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth);
130844
+ const topMid = titleWidth > 0 && titleSegmentWidth <= innerWidth ? chalk.hex(stroke)("─ ") + titleStyled + " " + chalk.hex(stroke)("─".repeat(remainingDashes)) : chalk.hex(stroke)("─".repeat(innerWidth));
130845
+ const top = chalk.hex(stroke)("┌") + topMid + chalk.hex(stroke)("┐");
130846
+ const bottom = chalk.hex(stroke)("└" + "─".repeat(innerWidth) + "┘");
130847
+ const lines = [top];
130848
+ for (let i = 0; i < innerHeight; i++) {
130849
+ const inner = content[i] ?? "";
130850
+ lines.push(chalk.hex(stroke)("│") + fitExactly$1(inner, innerWidth) + chalk.hex(stroke)("│"));
130851
+ }
130852
+ lines.push(bottom);
130853
+ return lines;
130854
+ }
130855
+ renderListFrame(width, height) {
130856
+ const title = `Tasks [${this.props.filter}]`;
130857
+ const innerHeight = Math.max(0, height - 2);
130858
+ if (this.sortedVisible.length === 0) {
130859
+ const empty = this.props.filter === "active" ? "无活跃任务。Tab = 显示全部。" : "本会话无后台任务。";
130860
+ const lines = [chalk.hex(this.props.colors.textMuted)(empty)];
130861
+ while (lines.length < innerHeight) lines.push("");
130862
+ return this.renderFrame(title, lines, width, height);
130863
+ }
130864
+ this.adjustScroll(innerHeight);
130865
+ const start = this.listScroll;
130866
+ const window = this.sortedVisible.slice(start, start + innerHeight);
130867
+ const innerWidth = width - 2;
130868
+ const lines = [];
130869
+ for (const [vi, task] of window.entries()) {
130870
+ const index = start + vi;
130871
+ lines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth));
130872
+ }
130873
+ while (lines.length < innerHeight) lines.push("");
130874
+ return this.renderFrame(title, lines, width, height);
130875
+ }
130876
+ renderListRow(task, selected, innerWidth) {
130877
+ const colors = this.props.colors;
130878
+ const pointer = selected ? "> " : " ";
130879
+ const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer);
130880
+ const idColor = selected ? colors.primary : task.taskId.startsWith("agent-") ? colors.success : colors.accent;
130881
+ const idText = selected ? chalk.hex(idColor).bold(task.taskId) : chalk.hex(idColor)(task.taskId);
130882
+ const idPad = " ".repeat(Math.max(0, 17 - task.taskId.length));
130883
+ const status = STATUS_LABEL[task.status];
130884
+ const prefix = `${pointerStyled}${idText}${idPad} ${chalk.hex(statusColor(colors, task.status))(status)}`;
130885
+ const prefixWidth = visibleWidth(prefix);
130886
+ const descBudget = Math.max(0, innerWidth - prefixWidth - 1);
130887
+ if (descBudget < 4) return fitExactly$1(prefix, innerWidth);
130888
+ const desc = truncateToWidth(singleLine$2(task.description) || singleLine$2(task.command) || "(no description)", descBudget, ELLIPSIS$3);
130889
+ return fitExactly$1(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth);
130890
+ }
130891
+ adjustScroll(visibleRows) {
130892
+ if (visibleRows <= 0) {
130893
+ this.listScroll = 0;
130894
+ return;
130895
+ }
130896
+ if (this.selectedIndex < this.listScroll) this.listScroll = this.selectedIndex;
130897
+ else if (this.selectedIndex >= this.listScroll + visibleRows) this.listScroll = this.selectedIndex - visibleRows + 1;
130898
+ const maxScroll = Math.max(0, this.sortedVisible.length - visibleRows);
130899
+ if (this.listScroll < 0) this.listScroll = 0;
130900
+ if (this.listScroll > maxScroll) this.listScroll = maxScroll;
130901
+ }
130902
+ renderRightStack(width, height) {
130903
+ const detailHeight = Math.max(8, Math.min(Math.floor(height * .4), height - 5));
130904
+ const previewHeight = height - detailHeight;
130905
+ return [...this.renderDetailFrame(width, detailHeight), ...this.renderPreviewFrame(width, previewHeight)];
130906
+ }
130907
+ renderDetailFrame(width, height) {
130908
+ const colors = this.props.colors;
130909
+ const innerHeight = Math.max(0, height - 2);
130910
+ const task = this.sortedVisible[this.selectedIndex];
130911
+ if (task === void 0) {
130912
+ const lines = [chalk.hex(colors.textMuted)("从列表中选择一个任务。")];
130913
+ while (lines.length < innerHeight) lines.push("");
130914
+ return this.renderFrame("详情", lines, width, height);
130915
+ }
130916
+ const label = (text) => chalk.hex(colors.textMuted)(text.padEnd(14));
130917
+ const value = (text) => chalk.hex(colors.text)(text);
130918
+ const lines = [
130919
+ `${label("任务 ID:")}${value(task.taskId)}`,
130920
+ `${label("状态:")}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`,
130921
+ `${label("描述:")}${value(singleLine$2(task.description) || "—")}`
130922
+ ];
130923
+ if (task.command && task.command !== task.description) lines.push(`${label("命令:")}${value(singleLine$2(task.command))}`);
130924
+ const timing = task.status === "running" || task.status === "awaiting_approval" ? `运行中 ${formatRelativeTime$2(task.startedAt)}` : task.endedAt !== null && task.endedAt !== void 0 ? `已完成 ${formatRelativeTime$2(task.endedAt)}` : "";
130925
+ if (timing.length > 0) lines.push(`${label("时间:")}${chalk.hex(colors.textMuted)(timing)}`);
130926
+ if (task.pid > 0) lines.push(`${label("进程 ID:")}${chalk.hex(colors.textMuted)(String(task.pid))}`);
130927
+ if (task.exitCode !== null && task.exitCode !== void 0) lines.push(`${label("退出码:")}${chalk.hex(colors.textMuted)(String(task.exitCode))}`);
130928
+ if (task.stopReason !== void 0 && task.stopReason.length > 0) lines.push(`${label("停止原因:")}${chalk.hex(colors.textMuted)(task.stopReason)}`);
130929
+ if (task.timedOut === true) lines.push(`${label("已超时:")}${chalk.hex(colors.warning)("是")}`);
130930
+ if (task.approvalReason !== void 0 && task.approvalReason.length > 0) lines.push(`${label("等待中:")}${chalk.hex(colors.warning)(singleLine$2(task.approvalReason))}`);
130931
+ while (lines.length < innerHeight) lines.push("");
130932
+ return this.renderFrame("详情", lines, width, height);
130933
+ }
130934
+ renderPreviewFrame(width, height) {
130935
+ const colors = this.props.colors;
130936
+ const innerHeight = Math.max(0, height - 2);
130937
+ if (this.sortedVisible[this.selectedIndex] === void 0) {
130938
+ const lines = [chalk.hex(colors.textMuted)("No task selected.")];
130939
+ while (lines.length < innerHeight) lines.push("");
130940
+ return this.renderFrame("Preview Output", lines, width, height);
130941
+ }
130942
+ let body;
130943
+ if (this.props.tailLoading) body = "[loading…]";
130944
+ else if (this.props.tailOutput === void 0 || this.props.tailOutput.length === 0) body = "[no output captured]";
130945
+ else body = this.props.tailOutput;
130946
+ const styled = body.split("\n").slice(-innerHeight).map((line) => chalk.hex(colors.textDim)(line));
130947
+ while (styled.length < innerHeight) styled.push("");
130948
+ return this.renderFrame("Preview Output", styled, width, height);
131269
130949
  }
131270
- syncSelectionFromProps() {
131271
- if (this.sortedVisible.length === 0) {
131272
- this.selectedIndex = 0;
131273
- this.listScroll = 0;
130950
+ renderTooSmall(width, rows) {
130951
+ const lines = [];
130952
+ const msg = chalk.hex(this.props.colors.error)(`Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`);
130953
+ lines.push(fitExactly$1(msg, width));
130954
+ for (let i = 1; i < rows; i++) lines.push(" ".repeat(width));
130955
+ return lines;
130956
+ }
130957
+ };
130958
+ //#endregion
130959
+ //#region src/tui/controllers/tasks-browser.ts
130960
+ var TasksBrowserController = class {
130961
+ host;
130962
+ constructor(host) {
130963
+ this.host = host;
130964
+ }
130965
+ async show() {
130966
+ const { state } = this.host;
130967
+ if (state.tasksBrowser !== void 0) return;
130968
+ const session = this.host.session;
130969
+ if (session === void 0) {
130970
+ this.host.showError("没有活动会话。");
131274
130971
  return;
131275
130972
  }
131276
- if (this.props.selectedTaskId !== void 0) {
131277
- const idx = this.sortedVisible.findIndex((t) => t.taskId === this.props.selectedTaskId);
131278
- if (idx !== -1) {
131279
- this.selectedIndex = idx;
131280
- return;
131281
- }
130973
+ let tasks = [];
130974
+ try {
130975
+ tasks = await session.listBackgroundTasks({ activeOnly: false });
130976
+ } catch (error) {
130977
+ this.host.showError(`加载任务失败: ${error instanceof Error ? error.message : String(error)}`);
130978
+ return;
131282
130979
  }
131283
- if (this.selectedIndex >= this.sortedVisible.length) this.selectedIndex = this.sortedVisible.length - 1;
130980
+ if (state.tasksBrowser !== void 0) return;
130981
+ const filter = "all";
130982
+ const selectedTaskId = this.pickInitialSelection(tasks, filter);
130983
+ const component = new TasksBrowserApp({
130984
+ tasks,
130985
+ filter,
130986
+ selectedTaskId,
130987
+ tailOutput: void 0,
130988
+ tailLoading: false,
130989
+ flashMessage: void 0,
130990
+ colors: state.theme.colors,
130991
+ ...this.buildCallbacks()
130992
+ }, state.terminal);
130993
+ const savedChildren = [...state.ui.children];
130994
+ state.ui.clear();
130995
+ state.ui.addChild(component);
130996
+ state.ui.setFocus(component);
130997
+ state.ui.requestRender(true);
130998
+ const pollTimer = setInterval(() => {
130999
+ this.refresh({ silent: true });
131000
+ }, 1e3);
131001
+ this.host.setTasksBrowser({
131002
+ component,
131003
+ savedChildren,
131004
+ filter,
131005
+ selectedTaskId,
131006
+ tailOutput: void 0,
131007
+ tailLoading: false,
131008
+ tailRequestId: 0,
131009
+ flashMessage: void 0,
131010
+ flashTimer: void 0,
131011
+ pollTimer,
131012
+ viewer: void 0
131013
+ });
131014
+ if (selectedTaskId !== void 0) this.loadTail(selectedTaskId);
131284
131015
  }
131285
- clearPendingStop() {
131286
- this.pendingStopTaskId = void 0;
131287
- if (this.pendingStopTimer !== void 0) {
131288
- clearTimeout(this.pendingStopTimer);
131289
- this.pendingStopTimer = void 0;
131290
- }
131016
+ close() {
131017
+ const { state } = this.host;
131018
+ const browser = state.tasksBrowser;
131019
+ if (browser === void 0) return;
131020
+ if (browser.viewer !== void 0) this.closeOutputViewer();
131021
+ if (browser.pollTimer !== void 0) clearInterval(browser.pollTimer);
131022
+ if (browser.flashTimer !== void 0) clearTimeout(browser.flashTimer);
131023
+ state.ui.clear();
131024
+ for (const child of browser.savedChildren) state.ui.addChild(child);
131025
+ this.host.setTasksBrowser(void 0);
131026
+ state.ui.setFocus(state.editor);
131027
+ state.ui.requestRender(true);
131291
131028
  }
131292
- emitSelect() {
131293
- const task = this.sortedVisible[this.selectedIndex];
131294
- if (task) this.props.onSelect(task.taskId);
131029
+ repaint() {
131030
+ if (this.host.state.tasksBrowser === void 0) return;
131031
+ const tasks = [...this.host.backgroundTasks.values()];
131032
+ this.pushProps(tasks);
131295
131033
  }
131296
- handleInput(data) {
131297
- const k = printableChar(data);
131298
- if (this.pendingStopTaskId !== void 0) {
131299
- if (k === "y" || k === "Y") {
131300
- const taskId = this.pendingStopTaskId;
131301
- this.clearPendingStop();
131302
- this.props.onStopConfirmed(taskId);
131303
- this.invalidate();
131304
- return;
131034
+ async refreshOutputViewer(opts = {}) {
131035
+ const { state } = this.host;
131036
+ const browser = state.tasksBrowser;
131037
+ const viewer = browser?.viewer;
131038
+ if (browser === void 0 || viewer === void 0) return;
131039
+ const session = this.host.session;
131040
+ if (session === void 0) return;
131041
+ const myRefreshId = ++viewer.refreshId;
131042
+ let output;
131043
+ try {
131044
+ output = await session.getBackgroundTaskOutput(viewer.taskId);
131045
+ } catch (error) {
131046
+ if (!opts.silent) {
131047
+ const message = error instanceof Error ? error.message : String(error);
131048
+ this.flash(`输出刷新失败: ${message}`);
131305
131049
  }
131306
- this.clearPendingStop();
131307
- this.invalidate();
131308
131050
  return;
131309
131051
  }
131310
- if (matchesKey(data, Key.escape) || k === "q" || k === "Q") {
131311
- this.props.onCancel();
131052
+ const current = state.tasksBrowser?.viewer;
131053
+ if (current === void 0 || current !== viewer || current.refreshId !== myRefreshId) return;
131054
+ if (output === viewer.output) return;
131055
+ viewer.output = output;
131056
+ const info = this.host.backgroundTasks.get(viewer.taskId);
131057
+ viewer.component.setProps({
131058
+ taskId: viewer.taskId,
131059
+ info,
131060
+ output,
131061
+ colors: state.theme.colors,
131062
+ onClose: () => {
131063
+ this.closeOutputViewer();
131064
+ }
131065
+ });
131066
+ state.ui.requestRender();
131067
+ }
131068
+ pickInitialSelection(tasks, filter) {
131069
+ const candidates = filter === "all" ? tasks : tasks.filter((t) => t.status !== "completed" && t.status !== "failed" && t.status !== "killed" && t.status !== "lost");
131070
+ if (candidates.length === 0) return void 0;
131071
+ return candidates.find((t) => t.status === "running" || t.status === "awaiting_approval")?.taskId ?? candidates[0].taskId;
131072
+ }
131073
+ async refresh(opts = {}) {
131074
+ const { state } = this.host;
131075
+ const browser = state.tasksBrowser;
131076
+ if (browser === void 0) return;
131077
+ const session = this.host.session;
131078
+ if (session === void 0) return;
131079
+ let tasks;
131080
+ try {
131081
+ tasks = await session.listBackgroundTasks({ activeOnly: false });
131082
+ } catch (error) {
131083
+ if (!opts.silent) this.flash(`刷新失败: ${error instanceof Error ? error.message : String(error)}`);
131312
131084
  return;
131313
131085
  }
131314
- if (matchesKey(data, Key.up) || k === "k") {
131315
- if (this.sortedVisible.length === 0) return;
131316
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
131317
- this.emitSelect();
131318
- this.invalidate();
131086
+ if (state.tasksBrowser !== browser) return;
131087
+ this.pushProps(tasks);
131088
+ }
131089
+ pushProps(tasks) {
131090
+ const browser = this.host.state.tasksBrowser;
131091
+ if (browser === void 0) return;
131092
+ browser.component.setProps({
131093
+ tasks,
131094
+ filter: browser.filter,
131095
+ selectedTaskId: browser.selectedTaskId,
131096
+ tailOutput: browser.tailOutput,
131097
+ tailLoading: browser.tailLoading,
131098
+ flashMessage: browser.flashMessage,
131099
+ colors: this.host.state.theme.colors,
131100
+ ...this.buildCallbacks()
131101
+ });
131102
+ this.host.state.ui.requestRender();
131103
+ }
131104
+ buildCallbacks() {
131105
+ return {
131106
+ onSelect: (taskId) => {
131107
+ this.handleSelect(taskId);
131108
+ },
131109
+ onToggleFilter: () => {
131110
+ this.handleToggleFilter();
131111
+ },
131112
+ onRefresh: () => {
131113
+ this.handleRefresh();
131114
+ },
131115
+ onCancel: () => {
131116
+ this.close();
131117
+ },
131118
+ onStopConfirmed: (taskId) => {
131119
+ this.handleStop(taskId);
131120
+ },
131121
+ onOpenOutput: (taskId) => {
131122
+ this.handleOpenOutput(taskId);
131123
+ },
131124
+ onStopIgnored: (taskId, reason) => {
131125
+ if (reason === "terminal") this.flash(`${taskId} 已是终止状态 — 无需停止。`);
131126
+ }
131127
+ };
131128
+ }
131129
+ handleSelect(taskId) {
131130
+ const browser = this.host.state.tasksBrowser;
131131
+ if (browser === void 0) return;
131132
+ if (browser.selectedTaskId === taskId) return;
131133
+ browser.selectedTaskId = taskId;
131134
+ browser.tailOutput = void 0;
131135
+ browser.tailLoading = true;
131136
+ this.repaint();
131137
+ this.loadTail(taskId);
131138
+ }
131139
+ handleToggleFilter() {
131140
+ const browser = this.host.state.tasksBrowser;
131141
+ if (browser === void 0) return;
131142
+ browser.filter = browser.filter === "all" ? "active" : "all";
131143
+ this.repaint();
131144
+ }
131145
+ handleRefresh() {
131146
+ this.flash("正在刷新…", 600);
131147
+ this.refresh();
131148
+ }
131149
+ async handleStop(taskId) {
131150
+ if (this.host.state.tasksBrowser === void 0) return;
131151
+ const session = this.host.session;
131152
+ if (session === void 0) {
131153
+ this.flash("没有活动会话。");
131319
131154
  return;
131320
131155
  }
131321
- if (matchesKey(data, Key.down) || k === "j") {
131322
- if (this.sortedVisible.length === 0) return;
131323
- this.selectedIndex = Math.min(this.sortedVisible.length - 1, this.selectedIndex + 1);
131324
- this.emitSelect();
131325
- this.invalidate();
131326
- return;
131156
+ this.flash(`正在停止 ${taskId}…`, 1500);
131157
+ try {
131158
+ await session.stopBackgroundTask(taskId, { reason: "用户发起停止" });
131159
+ await this.refresh({ silent: true });
131160
+ } catch (error) {
131161
+ const message = error instanceof Error ? error.message : String(error);
131162
+ this.flash(`停止失败: ${message}`);
131327
131163
  }
131328
- if (matchesKey(data, Key.tab) || k === " ") {
131329
- this.props.onToggleFilter();
131164
+ }
131165
+ async handleOpenOutput(taskId) {
131166
+ const { state } = this.host;
131167
+ const browser = state.tasksBrowser;
131168
+ if (browser === void 0) return;
131169
+ if (browser.viewer !== void 0) return;
131170
+ const session = this.host.session;
131171
+ if (session === void 0) {
131172
+ this.flash("没有活动会话。");
131330
131173
  return;
131331
131174
  }
131332
- if (k === "r" || k === "R") {
131333
- this.props.onRefresh();
131175
+ let output;
131176
+ try {
131177
+ output = await session.getBackgroundTaskOutput(taskId);
131178
+ } catch (error) {
131179
+ const message = error instanceof Error ? error.message : String(error);
131180
+ this.flash(`无法打开输出: ${message}`);
131334
131181
  return;
131335
131182
  }
131336
- if (k === "s" || k === "S") {
131337
- const task = this.sortedVisible[this.selectedIndex];
131338
- if (task === void 0) return;
131339
- if (isTerminal(task.status)) {
131340
- this.props.onStopIgnored?.(task.taskId, "terminal");
131341
- return;
131183
+ const current = state.tasksBrowser;
131184
+ if (current === void 0 || current !== browser) return;
131185
+ const viewer = new TaskOutputViewer({
131186
+ taskId,
131187
+ info: this.host.backgroundTasks.get(taskId),
131188
+ output,
131189
+ colors: state.theme.colors,
131190
+ onClose: () => {
131191
+ this.closeOutputViewer();
131342
131192
  }
131343
- this.pendingStopTaskId = task.taskId;
131344
- this.pendingStopTimer = setTimeout(() => {
131345
- this.clearPendingStop();
131346
- this.invalidate();
131347
- }, STOP_CONFIRM_TIMEOUT_MS);
131348
- this.invalidate();
131349
- return;
131350
- }
131351
- if (k === "o" || k === "O" || matchesKey(data, Key.enter)) {
131352
- const task = this.sortedVisible[this.selectedIndex];
131353
- if (task) this.props.onOpenOutput(task.taskId);
131193
+ }, state.terminal);
131194
+ const savedBrowserChildren = [...state.ui.children];
131195
+ state.ui.clear();
131196
+ state.ui.addChild(viewer);
131197
+ state.ui.setFocus(viewer);
131198
+ state.ui.requestRender(true);
131199
+ const pollTimer = setInterval(() => {
131200
+ this.refreshOutputViewer({ silent: true });
131201
+ }, 1e3);
131202
+ browser.viewer = {
131203
+ component: viewer,
131204
+ savedChildren: savedBrowserChildren,
131205
+ taskId,
131206
+ output,
131207
+ refreshId: 0,
131208
+ pollTimer
131209
+ };
131210
+ }
131211
+ loadTail(taskId) {
131212
+ const { state } = this.host;
131213
+ const browser = state.tasksBrowser;
131214
+ if (browser === void 0) return;
131215
+ const session = this.host.session;
131216
+ if (session === void 0) {
131217
+ browser.tailLoading = false;
131218
+ this.repaint();
131354
131219
  return;
131355
131220
  }
131221
+ const requestId = ++browser.tailRequestId;
131222
+ session.getBackgroundTaskOutput(taskId, { tail: 4e3 }).then((output) => {
131223
+ const current = state.tasksBrowser;
131224
+ if (current === void 0) return;
131225
+ if (current !== browser || current.tailRequestId !== requestId) return;
131226
+ if (current.selectedTaskId !== taskId) return;
131227
+ current.tailOutput = output;
131228
+ current.tailLoading = false;
131229
+ this.repaint();
131230
+ }).catch(() => {
131231
+ const current = state.tasksBrowser;
131232
+ if (current === void 0) return;
131233
+ if (current !== browser || current.tailRequestId !== requestId) return;
131234
+ if (current.selectedTaskId !== taskId) return;
131235
+ current.tailOutput = "";
131236
+ current.tailLoading = false;
131237
+ this.repaint();
131238
+ });
131356
131239
  }
131357
- /**
131358
- * Render the entire screen as `terminal.rows` lines of `width` cols.
131359
- * Layout: header(1) + body(rows-2) + footer(1).
131360
- */
131361
- render(width) {
131362
- const rows = Math.max(1, this.terminal.rows);
131363
- if (width < MIN_WIDTH || rows < MIN_HEIGHT) return this.renderTooSmall(width, rows);
131364
- const header = this.renderHeader(width);
131365
- const footer = this.renderFooter(width);
131366
- const bodyHeight = rows - 2;
131367
- const listWidth = Math.max(LIST_COL_MIN, Math.min(LIST_COL_MAX, Math.floor(width * LIST_COL_RATIO)));
131368
- const rightWidth = width - listWidth;
131369
- const listFrame = this.renderListFrame(listWidth, bodyHeight);
131370
- const rightFrames = this.renderRightStack(rightWidth, bodyHeight);
131371
- const lines = [header];
131372
- for (let i = 0; i < bodyHeight; i++) lines.push((listFrame[i] ?? " ".repeat(listWidth)) + (rightFrames[i] ?? " ".repeat(rightWidth)));
131373
- lines.push(footer);
131374
- return lines;
131375
- }
131376
- renderHeader(width) {
131377
- const colors = this.props.colors;
131378
- const title = chalk.hex(colors.primary).bold(" TASK BROWSER ");
131379
- const filterText = chalk.hex(colors.textMuted)(` filter=${this.props.filter === "all" ? "ALL" : "ACTIVE"} `);
131380
- const counts = countByStatus(this.props.tasks);
131381
- const countSegments = [];
131382
- if (counts.running > 0) countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} 运行中 `));
131383
- if (counts.awaiting > 0) countSegments.push(chalk.hex(colors.warning)(` ${String(counts.awaiting)} 等待中 `));
131384
- if (counts.completed > 0) countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} 已完成 `));
131385
- if (counts.terminalFailed > 0) countSegments.push(chalk.hex(colors.error)(` ${String(counts.terminalFailed)} 已中断 `));
131386
- const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} 总计 `);
131387
- return fitExactly$1(title + filterText + countSegments.join("") + totals, width);
131240
+ flash(message, durationMs = 2500) {
131241
+ const browser = this.host.state.tasksBrowser;
131242
+ if (browser === void 0) return;
131243
+ if (browser.flashTimer !== void 0) clearTimeout(browser.flashTimer);
131244
+ browser.flashMessage = message;
131245
+ browser.flashTimer = setTimeout(() => {
131246
+ const current = this.host.state.tasksBrowser;
131247
+ if (current !== browser) return;
131248
+ current.flashMessage = void 0;
131249
+ current.flashTimer = void 0;
131250
+ this.repaint();
131251
+ }, durationMs);
131252
+ this.repaint();
131388
131253
  }
131389
- renderFooter(width) {
131390
- const colors = this.props.colors;
131391
- const key = (text) => chalk.hex(colors.primary).bold(text);
131392
- const dim = (text) => chalk.hex(colors.textMuted)(text);
131393
- if (this.pendingStopTaskId !== void 0) {
131394
- const warn = (text) => chalk.hex(colors.warning).bold(text);
131395
- return fitExactly$1(` ${warn("停止")} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ${key("Y")} ${dim("确认")} ${key("N")} ${dim("取消")} `, width);
131396
- }
131397
- const left = [
131398
- ` ${key("↑↓")} ${dim("选择")}`,
131399
- `${key("Enter/O")} ${dim("输出")}`,
131400
- `${key("S")} ${dim("停止")}`,
131401
- `${key("R")} ${dim("刷新")}`,
131402
- `${key("Tab")} ${dim("筛选")}`,
131403
- `${key("Q/Esc")} ${dim("退出")} `
131404
- ].join(" ");
131405
- const flash = this.props.flashMessage;
131406
- if (flash !== void 0 && flash.length > 0) {
131407
- const flashStyled = chalk.hex(colors.warning)(` ${flash} `);
131408
- const total = visibleWidth(left) + visibleWidth(flashStyled);
131409
- if (total <= width) return left + " ".repeat(width - total) + flashStyled;
131410
- }
131411
- return fitExactly$1(left, width);
131254
+ closeOutputViewer() {
131255
+ const browser = this.host.state.tasksBrowser;
131256
+ if (browser === void 0 || browser.viewer === void 0) return;
131257
+ const viewer = browser.viewer;
131258
+ clearInterval(viewer.pollTimer);
131259
+ browser.viewer = void 0;
131260
+ this.host.state.ui.clear();
131261
+ for (const child of viewer.savedChildren) this.host.state.ui.addChild(child);
131262
+ this.host.state.ui.setFocus(browser.component);
131263
+ this.host.state.ui.requestRender(true);
131412
131264
  }
131413
- /**
131414
- * Render a framed box: `┌─ Title ─┐` top, `│ <content> │` sides, `└─┘`
131415
- * bottom. Result is exactly `width × height` cells. `content` is a
131416
- * pre-rendered array of inner-width-sized lines; extra rows are padded.
131417
- */
131418
- renderFrame(title, content, width, height) {
131419
- if (height < 2 || width < 4) {
131420
- const out = [];
131421
- for (let i = 0; i < height; i++) out.push(" ".repeat(width));
131422
- return out;
131423
- }
131424
- const stroke = this.props.colors.primary;
131425
- const innerWidth = width - 2;
131426
- const innerHeight = height - 2;
131427
- const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title);
131428
- const titleWidth = visibleWidth(titleStyled);
131429
- const titleSegmentWidth = visibleWidth(`─ ${titleStyled} `);
131430
- const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth);
131431
- const topMid = titleWidth > 0 && titleSegmentWidth <= innerWidth ? chalk.hex(stroke)("─ ") + titleStyled + " " + chalk.hex(stroke)("─".repeat(remainingDashes)) : chalk.hex(stroke)("─".repeat(innerWidth));
131432
- const top = chalk.hex(stroke)("┌") + topMid + chalk.hex(stroke)("┐");
131433
- const bottom = chalk.hex(stroke)("└" + "─".repeat(innerWidth) + "┘");
131434
- const lines = [top];
131435
- for (let i = 0; i < innerHeight; i++) {
131436
- const inner = content[i] ?? "";
131437
- lines.push(chalk.hex(stroke)("│") + fitExactly$1(inner, innerWidth) + chalk.hex(stroke)("│"));
131438
- }
131439
- lines.push(bottom);
131440
- return lines;
131265
+ };
131266
+ //#endregion
131267
+ //#region src/tui/components/editor/file-mention-provider.ts
131268
+ /**
131269
+ * `@file` autocomplete provider for the input box.
131270
+ *
131271
+ * pi-tui's `CombinedAutocompleteProvider` handles the mechanical parts
131272
+ * (extract `@…` prefix, insert completion with the right quoting). This
131273
+ * wrapper adds scream-specific ranking + filtering so the default "empty
131274
+ * `@`" list surfaces files the user actually wants, not alphabetical
131275
+ * noise from `.agents/skills/*` et al.
131276
+ *
131277
+ * Sort order empty query:
131278
+ * 1. recently edited (from `git log --name-only`)
131279
+ * 2. recent fs mtime
131280
+ * 3. basename alphabetical
131281
+ * (first 15, not 50 pi-tui's menu height is ~6-10 lines anyway)
131282
+ *
131283
+ * Sort order non-empty query (strict to fuzzy):
131284
+ * cat 0: basename starts-with query
131285
+ * cat 1: basename contains query
131286
+ * cat 2: fuzzyMatch succeeds on full path
131287
+ * tie-break within each cat: recency rank mtime basename length
131288
+ * (first 50)
131289
+ *
131290
+ * Filter — dot directories are hidden by default. User can opt in by starting the query
131291
+ * with `.` (e.g. `@.github/`), since those paths rarely need
131292
+ * completion.
131293
+ *
131294
+ * When `fd` is available the inner pi-tui provider owns the `@` branch
131295
+ * verbatim — its fd invocation respects `.gitignore` and is strictly
131296
+ * better than anything we can cheaply reproduce in TS. We only kick in
131297
+ * when `fd` is missing AND we're in a git repo.
131298
+ */
131299
+ const MAX_SUGGESTIONS_WHEN_QUERY = 50;
131300
+ const MAX_SUGGESTIONS_WHEN_EMPTY = 15;
131301
+ const PATH_DELIMITERS = new Set([
131302
+ " ",
131303
+ " ",
131304
+ "\"",
131305
+ "'",
131306
+ "="
131307
+ ]);
131308
+ var FileMentionProvider = class {
131309
+ fdPath;
131310
+ gitCache;
131311
+ inner;
131312
+ slashCommandItems;
131313
+ constructor(slashCommands, workDir, fdPath, gitCache) {
131314
+ this.fdPath = fdPath;
131315
+ this.gitCache = gitCache;
131316
+ this.slashCommandItems = slashCommands.map((cmd) => {
131317
+ const ac = cmd;
131318
+ if (ac.label !== void 0 && ac.label.length > 0) return {
131319
+ value: ac.value ?? ac.name ?? "",
131320
+ label: ac.label
131321
+ };
131322
+ const name = ac.value ?? ac.name ?? "";
131323
+ const desc = ac.description ?? "";
131324
+ return {
131325
+ value: name,
131326
+ label: `/${name}${desc ? ` — ${desc}` : ""}`
131327
+ };
131328
+ });
131329
+ this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath);
131441
131330
  }
131442
- renderListFrame(width, height) {
131443
- const title = `Tasks [${this.props.filter}]`;
131444
- const innerHeight = Math.max(0, height - 2);
131445
- if (this.sortedVisible.length === 0) {
131446
- const empty = this.props.filter === "active" ? "无活跃任务。Tab = 显示全部。" : "本会话无后台任务。";
131447
- const lines = [chalk.hex(this.props.colors.textMuted)(empty)];
131448
- while (lines.length < innerHeight) lines.push("");
131449
- return this.renderFrame(title, lines, width, height);
131331
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
131332
+ const textBeforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
131333
+ const atPrefix = extractAtPrefix(textBeforeCursor);
131334
+ if (!options.force && atPrefix === null && textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
131335
+ const query = textBeforeCursor.slice(1);
131336
+ const filtered = fuzzyFilter(this.slashCommandItems, query, (item) => item.value).slice(0, MAX_SUGGESTIONS_WHEN_QUERY);
131337
+ if (filtered.length === 0) return null;
131338
+ return {
131339
+ items: filtered,
131340
+ prefix: textBeforeCursor
131341
+ };
131450
131342
  }
131451
- this.adjustScroll(innerHeight);
131452
- const start = this.listScroll;
131453
- const window = this.sortedVisible.slice(start, start + innerHeight);
131454
- const innerWidth = width - 2;
131455
- const lines = [];
131456
- for (const [vi, task] of window.entries()) {
131457
- const index = start + vi;
131458
- lines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth));
131343
+ if (atPrefix === null) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131344
+ if (this.fdPath !== null) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131345
+ const snapshot = this.gitCache.getSnapshot();
131346
+ if (snapshot === null || snapshot.files.length === 0) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131347
+ const query = atPrefix.slice(1);
131348
+ const candidates = query.startsWith(".") ? snapshot.files : snapshot.files.filter((p) => !containsDotSegment(p));
131349
+ const items = query.length === 0 ? rankForEmptyQuery(candidates, snapshot) : rankForQuery(candidates, query, snapshot);
131350
+ if (items.length === 0) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131351
+ return {
131352
+ items,
131353
+ prefix: atPrefix
131354
+ };
131355
+ }
131356
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
131357
+ if (prefix.startsWith("/")) {
131358
+ const line = lines[cursorLine] ?? "";
131359
+ const before = line.slice(0, cursorCol - prefix.length);
131360
+ const after = line.slice(cursorCol);
131361
+ const replacement = `/${item.value} `;
131362
+ const newLine = `${before}${replacement}${after}`;
131363
+ return {
131364
+ lines: [
131365
+ ...lines.slice(0, cursorLine),
131366
+ newLine,
131367
+ ...lines.slice(cursorLine + 1)
131368
+ ],
131369
+ cursorLine,
131370
+ cursorCol: before.length + replacement.length
131371
+ };
131459
131372
  }
131460
- while (lines.length < innerHeight) lines.push("");
131461
- return this.renderFrame(title, lines, width, height);
131373
+ return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
131462
131374
  }
131463
- renderListRow(task, selected, innerWidth) {
131464
- const colors = this.props.colors;
131465
- const pointer = selected ? "> " : " ";
131466
- const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer);
131467
- const idColor = selected ? colors.primary : task.taskId.startsWith("agent-") ? colors.success : colors.accent;
131468
- const idText = selected ? chalk.hex(idColor).bold(task.taskId) : chalk.hex(idColor)(task.taskId);
131469
- const idPad = " ".repeat(Math.max(0, 17 - task.taskId.length));
131470
- const status = STATUS_LABEL[task.status];
131471
- const prefix = `${pointerStyled}${idText}${idPad} ${chalk.hex(statusColor(colors, task.status))(status)}`;
131472
- const prefixWidth = visibleWidth(prefix);
131473
- const descBudget = Math.max(0, innerWidth - prefixWidth - 1);
131474
- if (descBudget < 4) return fitExactly$1(prefix, innerWidth);
131475
- const desc = truncateToWidth(singleLine$2(task.description) || singleLine$2(task.command) || "(no description)", descBudget, ELLIPSIS$3);
131476
- return fitExactly$1(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth);
131375
+ };
131376
+ /**
131377
+ * Return the `@…` token ending at the cursor, or `null` if we're not in
131378
+ * an `@` mention. Mirrors pi-tui's `extractAtPrefix` — the token
131379
+ * boundary is the last PATH_DELIMITER before the cursor, and the token
131380
+ * must start with `@`.
131381
+ */
131382
+ function extractAtPrefix(text) {
131383
+ let tokenStart = 0;
131384
+ for (let i = text.length - 1; i >= 0; i -= 1) if (PATH_DELIMITERS.has(text[i] ?? "")) {
131385
+ tokenStart = i + 1;
131386
+ break;
131477
131387
  }
131478
- adjustScroll(visibleRows) {
131479
- if (visibleRows <= 0) {
131480
- this.listScroll = 0;
131481
- return;
131482
- }
131483
- if (this.selectedIndex < this.listScroll) this.listScroll = this.selectedIndex;
131484
- else if (this.selectedIndex >= this.listScroll + visibleRows) this.listScroll = this.selectedIndex - visibleRows + 1;
131485
- const maxScroll = Math.max(0, this.sortedVisible.length - visibleRows);
131486
- if (this.listScroll < 0) this.listScroll = 0;
131487
- if (this.listScroll > maxScroll) this.listScroll = maxScroll;
131388
+ if (text[tokenStart] !== "@") return null;
131389
+ return text.slice(tokenStart);
131390
+ }
131391
+ /** True when any path segment starts with a dot (e.g. `.github/x.yml`). */
131392
+ function containsDotSegment(path) {
131393
+ for (const segment of path.split("/")) if (segment.startsWith(".")) return true;
131394
+ return false;
131395
+ }
131396
+ /**
131397
+ * Empty-query ranking: stratified by signal strength.
131398
+ *
131399
+ * Layer 1: files touched in the last RECENT_COMMIT_DEPTH commits,
131400
+ * ordered by how recently. Strongest signal — if the user
131401
+ * just worked on it, they probably want to mention it.
131402
+ * Layer 2: files with the newest fs mtime (covers uncommitted edits
131403
+ * and files edited but not yet added to git).
131404
+ * Layer 3: everything else, alphabetical by basename so
131405
+ * README/package.json-style top-level files bubble up
131406
+ * relative to deeply-nested alphabetical paths.
131407
+ *
131408
+ * Cap at MAX_SUGGESTIONS_WHEN_EMPTY. Layers fill in order; dedup by
131409
+ * path so a recently-edited file isn't also listed in layer 2.
131410
+ */
131411
+ function rankForEmptyQuery(files, snapshot) {
131412
+ const picked = /* @__PURE__ */ new Set();
131413
+ const result = [];
131414
+ const cap = MAX_SUGGESTIONS_WHEN_EMPTY;
131415
+ const inFiles = new Set(files);
131416
+ const byRecency = [...snapshot.recencyOrder.entries()].filter(([path]) => inFiles.has(path)).toSorted((a, b) => a[1] - b[1]);
131417
+ for (const [path] of byRecency) {
131418
+ if (result.length >= cap) break;
131419
+ if (picked.has(path)) continue;
131420
+ picked.add(path);
131421
+ result.push(path);
131488
131422
  }
131489
- renderRightStack(width, height) {
131490
- const detailHeight = Math.max(8, Math.min(Math.floor(height * .4), height - 5));
131491
- const previewHeight = height - detailHeight;
131492
- return [...this.renderDetailFrame(width, detailHeight), ...this.renderPreviewFrame(width, previewHeight)];
131423
+ if (result.length < cap) {
131424
+ const byMtime = files.filter((p) => !picked.has(p) && snapshot.mtimeByPath.has(p)).toSorted((a, b) => (snapshot.mtimeByPath.get(b) ?? 0) - (snapshot.mtimeByPath.get(a) ?? 0));
131425
+ for (const path of byMtime) {
131426
+ if (result.length >= cap) break;
131427
+ picked.add(path);
131428
+ result.push(path);
131429
+ }
131493
131430
  }
131494
- renderDetailFrame(width, height) {
131495
- const colors = this.props.colors;
131496
- const innerHeight = Math.max(0, height - 2);
131497
- const task = this.sortedVisible[this.selectedIndex];
131498
- if (task === void 0) {
131499
- const lines = [chalk.hex(colors.textMuted)("从列表中选择一个任务。")];
131500
- while (lines.length < innerHeight) lines.push("");
131501
- return this.renderFrame("详情", lines, width, height);
131431
+ if (result.length < cap) {
131432
+ const rest = files.filter((p) => !picked.has(p)).toSorted((a, b) => basename(a).localeCompare(basename(b)) || a.localeCompare(b));
131433
+ for (const path of rest) {
131434
+ if (result.length >= cap) break;
131435
+ result.push(path);
131502
131436
  }
131503
- const label = (text) => chalk.hex(colors.textMuted)(text.padEnd(14));
131504
- const value = (text) => chalk.hex(colors.text)(text);
131505
- const lines = [
131506
- `${label("任务 ID:")}${value(task.taskId)}`,
131507
- `${label("状态:")}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`,
131508
- `${label("描述:")}${value(singleLine$2(task.description) || "—")}`
131509
- ];
131510
- if (task.command && task.command !== task.description) lines.push(`${label("命令:")}${value(singleLine$2(task.command))}`);
131511
- const timing = task.status === "running" || task.status === "awaiting_approval" ? `运行中 ${formatRelativeTime$2(task.startedAt)}` : task.endedAt !== null && task.endedAt !== void 0 ? `已完成 ${formatRelativeTime$2(task.endedAt)}` : "";
131512
- if (timing.length > 0) lines.push(`${label("时间:")}${chalk.hex(colors.textMuted)(timing)}`);
131513
- if (task.pid > 0) lines.push(`${label("进程 ID:")}${chalk.hex(colors.textMuted)(String(task.pid))}`);
131514
- if (task.exitCode !== null && task.exitCode !== void 0) lines.push(`${label("退出码:")}${chalk.hex(colors.textMuted)(String(task.exitCode))}`);
131515
- if (task.stopReason !== void 0 && task.stopReason.length > 0) lines.push(`${label("停止原因:")}${chalk.hex(colors.textMuted)(task.stopReason)}`);
131516
- if (task.timedOut === true) lines.push(`${label("已超时:")}${chalk.hex(colors.warning)("是")}`);
131517
- if (task.approvalReason !== void 0 && task.approvalReason.length > 0) lines.push(`${label("等待中:")}${chalk.hex(colors.warning)(singleLine$2(task.approvalReason))}`);
131518
- while (lines.length < innerHeight) lines.push("");
131519
- return this.renderFrame("详情", lines, width, height);
131520
131437
  }
131521
- renderPreviewFrame(width, height) {
131522
- const colors = this.props.colors;
131523
- const innerHeight = Math.max(0, height - 2);
131524
- if (this.sortedVisible[this.selectedIndex] === void 0) {
131525
- const lines = [chalk.hex(colors.textMuted)("No task selected.")];
131526
- while (lines.length < innerHeight) lines.push("");
131527
- return this.renderFrame("Preview Output", lines, width, height);
131438
+ return result.map(toItem);
131439
+ }
131440
+ /**
131441
+ * Non-empty-query ranking: three strictness tiers, with recency /
131442
+ * mtime as tie-breakers inside each tier so "the readme you just
131443
+ * edited" beats "a readme deep in a vendor dir".
131444
+ */
131445
+ function rankForQuery(files, query, snapshot) {
131446
+ const lowerQuery = query.toLowerCase();
131447
+ const scored = [];
131448
+ for (const path of files) {
131449
+ const base = basename(path).toLowerCase();
131450
+ if (base.startsWith(lowerQuery)) {
131451
+ scored.push({
131452
+ path,
131453
+ cat: 0,
131454
+ fuzzyScore: 0
131455
+ });
131456
+ continue;
131528
131457
  }
131529
- let body;
131530
- if (this.props.tailLoading) body = "[loading…]";
131531
- else if (this.props.tailOutput === void 0 || this.props.tailOutput.length === 0) body = "[no output captured]";
131532
- else body = this.props.tailOutput;
131533
- const styled = body.split("\n").slice(-innerHeight).map((line) => chalk.hex(colors.textDim)(line));
131534
- while (styled.length < innerHeight) styled.push("");
131535
- return this.renderFrame("Preview Output", styled, width, height);
131458
+ if (base.includes(lowerQuery)) {
131459
+ scored.push({
131460
+ path,
131461
+ cat: 1,
131462
+ fuzzyScore: 0
131463
+ });
131464
+ continue;
131465
+ }
131466
+ const fuzzy = fuzzyMatch(query, path);
131467
+ if (fuzzy.matches) scored.push({
131468
+ path,
131469
+ cat: 2,
131470
+ fuzzyScore: fuzzy.score
131471
+ });
131472
+ }
131473
+ if (scored.length === 0) return fuzzyFilter([...files], query, (p) => p).slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map(toItem);
131474
+ scored.sort((a, b) => {
131475
+ if (a.cat !== b.cat) return a.cat - b.cat;
131476
+ if (a.cat === 2 && a.fuzzyScore !== b.fuzzyScore) return a.fuzzyScore - b.fuzzyScore;
131477
+ const ra = snapshot.recencyOrder.get(a.path);
131478
+ const rb = snapshot.recencyOrder.get(b.path);
131479
+ if (ra !== void 0 && rb !== void 0 && ra !== rb) return ra - rb;
131480
+ if (ra !== void 0 && rb === void 0) return -1;
131481
+ if (ra === void 0 && rb !== void 0) return 1;
131482
+ const ma = snapshot.mtimeByPath.get(a.path) ?? 0;
131483
+ const mb = snapshot.mtimeByPath.get(b.path) ?? 0;
131484
+ if (ma !== mb) return mb - ma;
131485
+ const baseLenDiff = basename(a.path).length - basename(b.path).length;
131486
+ if (baseLenDiff !== 0) return baseLenDiff;
131487
+ return a.path.localeCompare(b.path);
131488
+ });
131489
+ return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path));
131490
+ }
131491
+ function toItem(path) {
131492
+ return {
131493
+ value: `@${path}`,
131494
+ label: basename(path),
131495
+ description: path
131496
+ };
131497
+ }
131498
+ //#endregion
131499
+ //#region src/tui/components/messages/cron-message.ts
131500
+ var CronMessageComponent = class {
131501
+ colors;
131502
+ spacer = new Spacer(1);
131503
+ title;
131504
+ detail;
131505
+ titleColor;
131506
+ promptText;
131507
+ constructor(prompt, data, colors) {
131508
+ this.colors = colors;
131509
+ const missed = data.missedCount !== void 0;
131510
+ this.title = missed ? "错过的定时提醒" : "定时提醒触发";
131511
+ this.detail = cronDetail(data);
131512
+ this.titleColor = data.stale === true || missed ? colors.warning : colors.accent;
131513
+ this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0);
131536
131514
  }
131537
- renderTooSmall(width, rows) {
131515
+ invalidate() {
131516
+ this.promptText.invalidate();
131517
+ }
131518
+ render(width) {
131519
+ const bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET);
131520
+ const bulletWidth = visibleWidth(bullet);
131521
+ const contentWidth = Math.max(1, width - bulletWidth);
131538
131522
  const lines = [];
131539
- const msg = chalk.hex(this.props.colors.error)(`Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`);
131540
- lines.push(fitExactly$1(msg, width));
131541
- for (let i = 1; i < rows; i++) lines.push(" ".repeat(width));
131523
+ for (const line of this.spacer.render(width)) lines.push(line);
131524
+ const title = chalk.hex(this.titleColor).bold(this.title);
131525
+ lines.push(`${bullet}${title}`);
131526
+ if (this.detail !== void 0) lines.push(`${" ".repeat(bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`);
131527
+ const promptLines = this.promptText.render(contentWidth);
131528
+ for (const line of promptLines) lines.push(`${" ".repeat(bulletWidth)}${line}`);
131542
131529
  return lines;
131543
131530
  }
131544
131531
  };
131532
+ function cronDetail(data) {
131533
+ const parts = [];
131534
+ if (data.cron !== void 0 && data.cron.length > 0) parts.push(data.cron);
131535
+ if (data.jobId !== void 0 && data.jobId.length > 0) parts.push(`job ${data.jobId}`);
131536
+ if (data.recurring === false) parts.push("一次性");
131537
+ if (data.coalescedCount !== void 0 && data.coalescedCount > 1) parts.push(`${String(data.coalescedCount)} 次合并触发`);
131538
+ if (data.missedCount !== void 0) parts.push(`${String(data.missedCount)} 次错过`);
131539
+ if (data.stale === true) parts.push("最终投递");
131540
+ return parts.length > 0 ? parts.join(" | ") : void 0;
131541
+ }
131545
131542
  //#endregion
131546
- //#region src/tui/controllers/tasks-browser.ts
131547
- var TasksBrowserController = class {
131548
- host;
131549
- constructor(host) {
131550
- this.host = host;
131551
- }
131552
- async show() {
131553
- const { state } = this.host;
131554
- if (state.tasksBrowser !== void 0) return;
131555
- const session = this.host.session;
131556
- if (session === void 0) {
131557
- this.host.showError("没有活动会话。");
131543
+ //#region src/tui/components/panes/activity-pane.ts
131544
+ var ActivityPaneComponent = class extends Container {
131545
+ constructor(options) {
131546
+ super();
131547
+ if (options.mode === "waiting" || options.mode === "tool") {
131548
+ if (options.spinner !== void 0) {
131549
+ this.addChild(new Spacer(1));
131550
+ this.addChild(options.spinner);
131551
+ }
131552
+ if (options.pulseWave !== void 0) {
131553
+ this.addChild(new Spacer(1));
131554
+ this.addChild(options.pulseWave);
131555
+ }
131558
131556
  return;
131559
131557
  }
131560
- let tasks = [];
131561
- try {
131562
- tasks = await session.listBackgroundTasks({ activeOnly: false });
131563
- } catch (error) {
131564
- this.host.showError(`加载任务失败: ${error instanceof Error ? error.message : String(error)}`);
131565
- return;
131558
+ if (options.mode === "composing" && options.spinner !== void 0) {
131559
+ this.addChild(new Spacer(1));
131560
+ this.addChild(options.spinner);
131561
+ if (options.pulseWave !== void 0) {
131562
+ this.addChild(new Spacer(1));
131563
+ this.addChild(options.pulseWave);
131564
+ }
131566
131565
  }
131567
- if (state.tasksBrowser !== void 0) return;
131568
- const filter = "all";
131569
- const selectedTaskId = this.pickInitialSelection(tasks, filter);
131570
- const component = new TasksBrowserApp({
131571
- tasks,
131572
- filter,
131573
- selectedTaskId,
131574
- tailOutput: void 0,
131575
- tailLoading: false,
131576
- flashMessage: void 0,
131577
- colors: state.theme.colors,
131578
- ...this.buildCallbacks()
131579
- }, state.terminal);
131580
- const savedChildren = [...state.ui.children];
131581
- state.ui.clear();
131582
- state.ui.addChild(component);
131583
- state.ui.setFocus(component);
131584
- state.ui.requestRender(true);
131585
- const pollTimer = setInterval(() => {
131586
- this.refresh({ silent: true });
131587
- }, 1e3);
131588
- this.host.setTasksBrowser({
131589
- component,
131590
- savedChildren,
131591
- filter,
131592
- selectedTaskId,
131593
- tailOutput: void 0,
131594
- tailLoading: false,
131595
- tailRequestId: 0,
131596
- flashMessage: void 0,
131597
- flashTimer: void 0,
131598
- pollTimer,
131599
- viewer: void 0
131600
- });
131601
- if (selectedTaskId !== void 0) this.loadTail(selectedTaskId);
131602
131566
  }
131603
- close() {
131604
- const { state } = this.host;
131605
- const browser = state.tasksBrowser;
131606
- if (browser === void 0) return;
131607
- if (browser.viewer !== void 0) this.closeOutputViewer();
131608
- if (browser.pollTimer !== void 0) clearInterval(browser.pollTimer);
131609
- if (browser.flashTimer !== void 0) clearTimeout(browser.flashTimer);
131610
- state.ui.clear();
131611
- for (const child of browser.savedChildren) state.ui.addChild(child);
131612
- this.host.setTasksBrowser(void 0);
131613
- state.ui.setFocus(state.editor);
131614
- state.ui.requestRender(true);
131567
+ };
131568
+ //#endregion
131569
+ //#region src/tui/components/panes/queue-pane.ts
131570
+ var QueuePaneComponent = class extends Container {
131571
+ constructor(options) {
131572
+ super();
131573
+ const accent = chalk.hex(options.colors.accent);
131574
+ const dim = chalk.hex(options.colors.textDim);
131575
+ for (const item of options.messages) this.addChild(new Text(accent(` ❯ ${item.text}`), 0, 0));
131576
+ if (options.messages.length > 0) {
131577
+ const hint = options.isCompacting && !options.isStreaming ? " ↑ to edit · will send after compaction" : !options.canSteerImmediately ? " ↑ to edit · will send after current task" : " ↑ to edit · ctrl-s to steer immediately";
131578
+ this.addChild(new Text(dim(hint), 0, 0));
131579
+ }
131615
131580
  }
131616
- repaint() {
131617
- if (this.host.state.tasksBrowser === void 0) return;
131618
- const tasks = [...this.host.backgroundTasks.values()];
131619
- this.pushProps(tasks);
131581
+ };
131582
+ //#endregion
131583
+ //#region src/tui/reverse-rpc/base-controller.ts
131584
+ var ReverseRpcController = class {
131585
+ uiHooks = null;
131586
+ current = null;
131587
+ queue = [];
131588
+ setUIHooks(hooks) {
131589
+ this.uiHooks = hooks;
131620
131590
  }
131621
- async refreshOutputViewer(opts = {}) {
131622
- const { state } = this.host;
131623
- const browser = state.tasksBrowser;
131624
- const viewer = browser?.viewer;
131625
- if (browser === void 0 || viewer === void 0) return;
131626
- const session = this.host.session;
131627
- if (session === void 0) return;
131628
- const myRefreshId = ++viewer.refreshId;
131629
- let output;
131630
- try {
131631
- output = await session.getBackgroundTaskOutput(viewer.taskId);
131632
- } catch (error) {
131633
- if (!opts.silent) {
131634
- const message = error instanceof Error ? error.message : String(error);
131635
- this.flash(`输出刷新失败: ${message}`);
131636
- }
131637
- return;
131638
- }
131639
- const current = state.tasksBrowser?.viewer;
131640
- if (current === void 0 || current !== viewer || current.refreshId !== myRefreshId) return;
131641
- if (output === viewer.output) return;
131642
- viewer.output = output;
131643
- const info = this.host.backgroundTasks.get(viewer.taskId);
131644
- viewer.component.setProps({
131645
- taskId: viewer.taskId,
131646
- info,
131647
- output,
131648
- colors: state.theme.colors,
131649
- onClose: () => {
131650
- this.closeOutputViewer();
131651
- }
131591
+ /**
131592
+ * Called when a reverse RPC request arrives from core. The returned promise
131593
+ * resolves after the user responds or `cancelAll` forces cancellation.
131594
+ */
131595
+ show(payload) {
131596
+ return new Promise((resolve) => {
131597
+ const entry = {
131598
+ payload,
131599
+ resolve
131600
+ };
131601
+ if (this.current === null) {
131602
+ this.current = entry;
131603
+ this.uiHooks?.showPanel(payload);
131604
+ } else this.queue.push(entry);
131652
131605
  });
131653
- state.ui.requestRender();
131654
131606
  }
131655
- pickInitialSelection(tasks, filter) {
131656
- const candidates = filter === "all" ? tasks : tasks.filter((t) => t.status !== "completed" && t.status !== "failed" && t.status !== "killed" && t.status !== "lost");
131657
- if (candidates.length === 0) return void 0;
131658
- return candidates.find((t) => t.status === "running" || t.status === "awaiting_approval")?.taskId ?? candidates[0].taskId;
131607
+ /** Called by the UI after the user makes a panel choice. */
131608
+ respond(data) {
131609
+ const pending = this.current;
131610
+ this.current = null;
131611
+ pending?.resolve(data);
131612
+ if (pending !== null) this.drainAutoResolved(pending.payload, data);
131613
+ this.advanceOrHide();
131659
131614
  }
131660
- async refresh(opts = {}) {
131661
- const { state } = this.host;
131662
- const browser = state.tasksBrowser;
131663
- if (browser === void 0) return;
131664
- const session = this.host.session;
131665
- if (session === void 0) return;
131666
- let tasks;
131667
- try {
131668
- tasks = await session.listBackgroundTasks({ activeOnly: false });
131669
- } catch (error) {
131670
- if (!opts.silent) this.flash(`刷新失败: ${error instanceof Error ? error.message : String(error)}`);
131615
+ /** Cancels all pending requests during shutdown or session switches. */
131616
+ cancelAll(reason) {
131617
+ const all = [...this.current === null ? [] : [this.current], ...this.queue];
131618
+ this.current = null;
131619
+ this.queue = [];
131620
+ this.uiHooks?.hidePanel();
131621
+ for (const entry of all) entry.resolve(this.createCancelResponse(reason));
131622
+ }
131623
+ hasPending() {
131624
+ return this.current !== null || this.queue.length > 0;
131625
+ }
131626
+ advanceOrHide() {
131627
+ const next = this.queue.shift();
131628
+ if (next === void 0) {
131629
+ this.uiHooks?.hidePanel();
131671
131630
  return;
131672
131631
  }
131673
- if (state.tasksBrowser !== browser) return;
131674
- this.pushProps(tasks);
131632
+ this.current = next;
131633
+ this.uiHooks?.showPanel(next.payload);
131675
131634
  }
131676
- pushProps(tasks) {
131677
- const browser = this.host.state.tasksBrowser;
131678
- if (browser === void 0) return;
131679
- browser.component.setProps({
131680
- tasks,
131681
- filter: browser.filter,
131682
- selectedTaskId: browser.selectedTaskId,
131683
- tailOutput: browser.tailOutput,
131684
- tailLoading: browser.tailLoading,
131685
- flashMessage: browser.flashMessage,
131686
- colors: this.host.state.theme.colors,
131687
- ...this.buildCallbacks()
131688
- });
131689
- this.host.state.ui.requestRender();
131635
+ drainAutoResolved(resolvedPayload, response) {
131636
+ const remaining = [];
131637
+ for (const entry of this.queue) {
131638
+ const auto = this.autoResolveFor(resolvedPayload, response, entry.payload);
131639
+ if (auto === void 0) remaining.push(entry);
131640
+ else entry.resolve(auto);
131641
+ }
131642
+ this.queue = remaining;
131643
+ }
131644
+ /**
131645
+ * Subclasses override to short-circuit queued requests when an answer to the
131646
+ * just-resolved one (e.g. an approve-for-session) implies the same answer
131647
+ * for matching queued requests. Return `undefined` to leave the queued
131648
+ * request waiting for its own panel turn.
131649
+ */
131650
+ autoResolveFor(_resolvedPayload, _response, _queuedPayload) {}
131651
+ };
131652
+ //#endregion
131653
+ //#region src/tui/reverse-rpc/approval/controller.ts
131654
+ var ApprovalController = class extends ReverseRpcController {
131655
+ createCancelResponse(reason) {
131656
+ return {
131657
+ decision: "cancelled",
131658
+ feedback: reason
131659
+ };
131690
131660
  }
131691
- buildCallbacks() {
131661
+ autoResolveFor(resolvedPayload, response, queuedPayload) {
131662
+ if (response.decision !== "approved") return void 0;
131663
+ if (response.scope !== "session") return void 0;
131664
+ if (resolvedPayload.action !== queuedPayload.action) return void 0;
131692
131665
  return {
131693
- onSelect: (taskId) => {
131694
- this.handleSelect(taskId);
131695
- },
131696
- onToggleFilter: () => {
131697
- this.handleToggleFilter();
131698
- },
131699
- onRefresh: () => {
131700
- this.handleRefresh();
131701
- },
131702
- onCancel: () => {
131703
- this.close();
131704
- },
131705
- onStopConfirmed: (taskId) => {
131706
- this.handleStop(taskId);
131707
- },
131708
- onOpenOutput: (taskId) => {
131709
- this.handleOpenOutput(taskId);
131710
- },
131711
- onStopIgnored: (taskId, reason) => {
131712
- if (reason === "terminal") this.flash(`${taskId} 已是终止状态 — 无需停止。`);
131713
- }
131666
+ decision: "approved",
131667
+ scope: "session"
131714
131668
  };
131715
131669
  }
131716
- handleSelect(taskId) {
131717
- const browser = this.host.state.tasksBrowser;
131718
- if (browser === void 0) return;
131719
- if (browser.selectedTaskId === taskId) return;
131720
- browser.selectedTaskId = taskId;
131721
- browser.tailOutput = void 0;
131722
- browser.tailLoading = true;
131723
- this.repaint();
131724
- this.loadTail(taskId);
131670
+ };
131671
+ //#endregion
131672
+ //#region src/tui/reverse-rpc/modal-coordinator.ts
131673
+ var ReverseRpcModalCoordinator = class {
131674
+ hooks;
131675
+ active = null;
131676
+ queued = [];
131677
+ constructor(hooks) {
131678
+ this.hooks = hooks;
131725
131679
  }
131726
- handleToggleFilter() {
131727
- const browser = this.host.state.tasksBrowser;
131728
- if (browser === void 0) return;
131729
- browser.filter = browser.filter === "all" ? "active" : "all";
131730
- this.repaint();
131680
+ showApproval(payload) {
131681
+ this.show({
131682
+ owner: "approval",
131683
+ show: () => {
131684
+ this.hooks.showApprovalPanel(payload);
131685
+ },
131686
+ hide: () => {
131687
+ this.hooks.hideApprovalPanel();
131688
+ }
131689
+ });
131731
131690
  }
131732
- handleRefresh() {
131733
- this.flash("正在刷新…", 600);
131734
- this.refresh();
131691
+ showQuestion(payload) {
131692
+ this.show({
131693
+ owner: "question",
131694
+ show: () => {
131695
+ this.hooks.showQuestionDialog(payload);
131696
+ },
131697
+ hide: () => {
131698
+ this.hooks.hideQuestionDialog();
131699
+ }
131700
+ });
131735
131701
  }
131736
- async handleStop(taskId) {
131737
- if (this.host.state.tasksBrowser === void 0) return;
131738
- const session = this.host.session;
131739
- if (session === void 0) {
131740
- this.flash("没有活动会话。");
131702
+ hide(owner) {
131703
+ if (this.active?.owner === owner) {
131704
+ const active = this.active;
131705
+ this.active = null;
131706
+ active.hide();
131707
+ this.showNext();
131741
131708
  return;
131742
131709
  }
131743
- this.flash(`正在停止 ${taskId}…`, 1500);
131744
- try {
131745
- await session.stopBackgroundTask(taskId, { reason: "用户发起停止" });
131746
- await this.refresh({ silent: true });
131747
- } catch (error) {
131748
- const message = error instanceof Error ? error.message : String(error);
131749
- this.flash(`停止失败: ${message}`);
131750
- }
131710
+ const queuedIndex = this.queued.findIndex((entry) => entry.owner === owner);
131711
+ if (queuedIndex >= 0) this.queued.splice(queuedIndex, 1);
131751
131712
  }
131752
- async handleOpenOutput(taskId) {
131753
- const { state } = this.host;
131754
- const browser = state.tasksBrowser;
131755
- if (browser === void 0) return;
131756
- if (browser.viewer !== void 0) return;
131757
- const session = this.host.session;
131758
- if (session === void 0) {
131759
- this.flash("没有活动会话。");
131713
+ clear() {
131714
+ const active = this.active;
131715
+ this.active = null;
131716
+ this.queued.length = 0;
131717
+ active?.hide();
131718
+ }
131719
+ show(entry) {
131720
+ const active = this.active;
131721
+ if (active === null) {
131722
+ this.active = entry;
131723
+ entry.show();
131760
131724
  return;
131761
131725
  }
131762
- let output;
131763
- try {
131764
- output = await session.getBackgroundTaskOutput(taskId);
131765
- } catch (error) {
131766
- const message = error instanceof Error ? error.message : String(error);
131767
- this.flash(`无法打开输出: ${message}`);
131726
+ if (active.owner === entry.owner) {
131727
+ this.active = entry;
131728
+ entry.show();
131768
131729
  return;
131769
131730
  }
131770
- const current = state.tasksBrowser;
131771
- if (current === void 0 || current !== browser) return;
131772
- const viewer = new TaskOutputViewer({
131773
- taskId,
131774
- info: this.host.backgroundTasks.get(taskId),
131775
- output,
131776
- colors: state.theme.colors,
131777
- onClose: () => {
131778
- this.closeOutputViewer();
131779
- }
131780
- }, state.terminal);
131781
- const savedBrowserChildren = [...state.ui.children];
131782
- state.ui.clear();
131783
- state.ui.addChild(viewer);
131784
- state.ui.setFocus(viewer);
131785
- state.ui.requestRender(true);
131786
- const pollTimer = setInterval(() => {
131787
- this.refreshOutputViewer({ silent: true });
131788
- }, 1e3);
131789
- browser.viewer = {
131790
- component: viewer,
131791
- savedChildren: savedBrowserChildren,
131792
- taskId,
131793
- output,
131794
- refreshId: 0,
131795
- pollTimer
131796
- };
131797
- }
131798
- loadTail(taskId) {
131799
- const { state } = this.host;
131800
- const browser = state.tasksBrowser;
131801
- if (browser === void 0) return;
131802
- const session = this.host.session;
131803
- if (session === void 0) {
131804
- browser.tailLoading = false;
131805
- this.repaint();
131731
+ const queuedIndex = this.queued.findIndex((queued) => queued.owner === entry.owner);
131732
+ if (queuedIndex >= 0) {
131733
+ this.queued[queuedIndex] = entry;
131806
131734
  return;
131807
131735
  }
131808
- const requestId = ++browser.tailRequestId;
131809
- session.getBackgroundTaskOutput(taskId, { tail: 4e3 }).then((output) => {
131810
- const current = state.tasksBrowser;
131811
- if (current === void 0) return;
131812
- if (current !== browser || current.tailRequestId !== requestId) return;
131813
- if (current.selectedTaskId !== taskId) return;
131814
- current.tailOutput = output;
131815
- current.tailLoading = false;
131816
- this.repaint();
131817
- }).catch(() => {
131818
- const current = state.tasksBrowser;
131819
- if (current === void 0) return;
131820
- if (current !== browser || current.tailRequestId !== requestId) return;
131821
- if (current.selectedTaskId !== taskId) return;
131822
- current.tailOutput = "";
131823
- current.tailLoading = false;
131824
- this.repaint();
131825
- });
131736
+ this.queued.push(entry);
131826
131737
  }
131827
- flash(message, durationMs = 2500) {
131828
- const browser = this.host.state.tasksBrowser;
131829
- if (browser === void 0) return;
131830
- if (browser.flashTimer !== void 0) clearTimeout(browser.flashTimer);
131831
- browser.flashMessage = message;
131832
- browser.flashTimer = setTimeout(() => {
131833
- const current = this.host.state.tasksBrowser;
131834
- if (current !== browser) return;
131835
- current.flashMessage = void 0;
131836
- current.flashTimer = void 0;
131837
- this.repaint();
131838
- }, durationMs);
131839
- this.repaint();
131738
+ showNext() {
131739
+ const next = this.queued.shift();
131740
+ if (next === void 0) return;
131741
+ this.active = next;
131742
+ next.show();
131840
131743
  }
131841
- closeOutputViewer() {
131842
- const browser = this.host.state.tasksBrowser;
131843
- if (browser === void 0 || browser.viewer === void 0) return;
131844
- const viewer = browser.viewer;
131845
- clearInterval(viewer.pollTimer);
131846
- browser.viewer = void 0;
131847
- this.host.state.ui.clear();
131848
- for (const child of viewer.savedChildren) this.host.state.ui.addChild(child);
131849
- this.host.state.ui.setFocus(browser.component);
131850
- this.host.state.ui.requestRender(true);
131744
+ };
131745
+ //#endregion
131746
+ //#region src/tui/reverse-rpc/index.ts
131747
+ function registerReverseRPCHandlers(approvalController, questionController, uiHooks) {
131748
+ const modalCoordinator = new ReverseRpcModalCoordinator(uiHooks);
131749
+ approvalController.setUIHooks({
131750
+ showPanel: (payload) => {
131751
+ modalCoordinator.showApproval(payload);
131752
+ },
131753
+ hidePanel: () => {
131754
+ modalCoordinator.hide("approval");
131755
+ }
131756
+ });
131757
+ questionController.setUIHooks({
131758
+ showPanel: (payload) => {
131759
+ modalCoordinator.showQuestion(payload);
131760
+ },
131761
+ hidePanel: () => {
131762
+ modalCoordinator.hide("question");
131763
+ }
131764
+ });
131765
+ return [() => {
131766
+ modalCoordinator.clear();
131767
+ }];
131768
+ }
131769
+ //#endregion
131770
+ //#region src/tui/reverse-rpc/question/controller.ts
131771
+ var QuestionController = class extends ReverseRpcController {
131772
+ createCancelResponse(_reason) {
131773
+ return { answers: [] };
131851
131774
  }
131852
131775
  };
131853
131776
  //#endregion
131854
- //#region src/tui/components/editor/file-mention-provider.ts
131777
+ //#region src/tui/theme/bundle.ts
131778
+ function createScreamTUIThemeBundle(theme, resolvedTheme) {
131779
+ const actualTheme = resolvedTheme ?? resolveThemeSync(theme);
131780
+ const colors = { ...getColorPalette(actualTheme) };
131781
+ return {
131782
+ resolvedTheme: actualTheme,
131783
+ colors,
131784
+ styles: createThemeStyles(colors),
131785
+ markdownTheme: createMarkdownTheme(colors)
131786
+ };
131787
+ }
131788
+ //#endregion
131789
+ //#region src/tui/types.ts
131790
+ const INITIAL_LIVE_PANE = {
131791
+ mode: "idle",
131792
+ pendingApproval: null,
131793
+ pendingQuestion: null
131794
+ };
131795
+ //#endregion
131796
+ //#region src/utils/git/git-status.ts
131855
131797
  /**
131856
- * `@file` autocomplete provider for the input box.
131857
- *
131858
- * pi-tui's `CombinedAutocompleteProvider` handles the mechanical parts
131859
- * (extract `@…` prefix, insert completion with the right quoting). This
131860
- * wrapper adds scream-specific ranking + filtering so the default "empty
131861
- * `@`" list surfaces files the user actually wants, not alphabetical
131862
- * noise from `.agents/skills/*` et al.
131863
- *
131864
- * Sort order — empty query:
131865
- * 1. recently edited (from `git log --name-only`)
131866
- * 2. recent fs mtime
131867
- * 3. basename alphabetical
131868
- * (first 15, not 50 — pi-tui's menu height is ~6-10 lines anyway)
131869
- *
131870
- * Sort order — non-empty query (strict to fuzzy):
131871
- * cat 0: basename starts-with query
131872
- * cat 1: basename contains query
131873
- * cat 2: fuzzyMatch succeeds on full path
131874
- * tie-break within each cat: recency rank → mtime → basename length
131875
- * (first 50)
131876
- *
131877
- * Filter — dot directories are hidden by default. User can opt in by starting the query
131878
- * with `.` (e.g. `@.github/`), since those paths rarely need
131879
- * completion.
131798
+ * Cached git branch + working-tree status for the footer/statusline.
131880
131799
  *
131881
- * When `fd` is available the inner pi-tui provider owns the `@` branch
131882
- * verbatim its fd invocation respects `.gitignore` and is strictly
131883
- * better than anything we can cheaply reproduce in TS. We only kick in
131884
- * when `fd` is missing AND we're in a git repo.
131800
+ * Branch name refreshes every 5s, porcelain status every 15s. Branch
131801
+ * and status reads stay synchronous with short timeouts. Pull request
131802
+ * lookup uses an async cache so a slow `gh pr view` never blocks
131803
+ * footer rendering.
131885
131804
  */
131886
- const MAX_SUGGESTIONS_WHEN_QUERY = 50;
131887
- const MAX_SUGGESTIONS_WHEN_EMPTY = 15;
131888
- const PATH_DELIMITERS = new Set([
131889
- " ",
131890
- " ",
131891
- "\"",
131892
- "'",
131893
- "="
131894
- ]);
131895
- var FileMentionProvider = class {
131896
- fdPath;
131897
- gitCache;
131898
- inner;
131899
- slashCommandItems;
131900
- constructor(slashCommands, workDir, fdPath, gitCache) {
131901
- this.fdPath = fdPath;
131902
- this.gitCache = gitCache;
131903
- this.slashCommandItems = slashCommands.map((cmd) => {
131904
- const ac = cmd;
131905
- if (ac.label !== void 0 && ac.label.length > 0) return {
131906
- value: ac.value ?? ac.name ?? "",
131907
- label: ac.label
131908
- };
131909
- const name = ac.value ?? ac.name ?? "";
131910
- const desc = ac.description ?? "";
131911
- return {
131912
- value: name,
131913
- label: `/${name}${desc ? ` ${desc}` : ""}`
131805
+ const BRANCH_TTL_MS = 5e3;
131806
+ const STATUS_TTL_MS = 15e3;
131807
+ const PULL_REQUEST_TTL_MS = 6e4;
131808
+ const SPAWN_TIMEOUT_MS = 500;
131809
+ const PR_SPAWN_TIMEOUT_MS = 5e3;
131810
+ const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/;
131811
+ function createGitStatusCache(workDir, options = {}) {
131812
+ const isRepo = detectGitRepo(workDir);
131813
+ let branch = {
131814
+ value: null,
131815
+ fetchedAt: 0
131816
+ };
131817
+ let status = {
131818
+ dirty: false,
131819
+ ahead: 0,
131820
+ behind: 0,
131821
+ diffAdded: 0,
131822
+ diffDeleted: 0,
131823
+ fetchedAt: 0
131824
+ };
131825
+ let pullRequest = {
131826
+ value: null,
131827
+ branch: null,
131828
+ fetchedAt: 0,
131829
+ pendingBranch: null,
131830
+ requestId: 0
131831
+ };
131832
+ return { getStatus: () => {
131833
+ if (!isRepo) return null;
131834
+ const now = Date.now();
131835
+ if (now - branch.fetchedAt >= BRANCH_TTL_MS) branch = {
131836
+ value: readBranch(workDir),
131837
+ fetchedAt: now
131838
+ };
131839
+ if (branch.value === null) return null;
131840
+ if (now - status.fetchedAt >= STATUS_TTL_MS) status = {
131841
+ ...readStatus(workDir),
131842
+ fetchedAt: now
131843
+ };
131844
+ refreshPullRequestIfNeeded(branch.value, now);
131845
+ return {
131846
+ branch: branch.value,
131847
+ dirty: status.dirty,
131848
+ ahead: status.ahead,
131849
+ behind: status.behind,
131850
+ diffAdded: status.diffAdded,
131851
+ diffDeleted: status.diffDeleted,
131852
+ pullRequest: pullRequest.branch === branch.value ? pullRequest.value : null
131853
+ };
131854
+ } };
131855
+ function refreshPullRequestIfNeeded(branchName, now) {
131856
+ if (pullRequest.pendingBranch === branchName) return;
131857
+ const fetchedAt = pullRequest.branch === branchName ? pullRequest.fetchedAt : 0;
131858
+ if (now - fetchedAt < PULL_REQUEST_TTL_MS) return;
131859
+ const requestId = pullRequest.requestId + 1;
131860
+ pullRequest = {
131861
+ value: pullRequest.branch === branchName ? pullRequest.value : null,
131862
+ branch: branchName,
131863
+ fetchedAt,
131864
+ pendingBranch: branchName,
131865
+ requestId
131866
+ };
131867
+ readPullRequest(workDir).then((value) => {
131868
+ if (pullRequest.requestId !== requestId) return;
131869
+ const changed = !samePullRequest(pullRequest.branch === branchName ? pullRequest.value : null, value);
131870
+ pullRequest = {
131871
+ value,
131872
+ branch: branchName,
131873
+ fetchedAt: Date.now(),
131874
+ pendingBranch: null,
131875
+ requestId
131914
131876
  };
131877
+ if (changed) options.onChange?.();
131915
131878
  });
131916
- this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath);
131917
131879
  }
131918
- async getSuggestions(lines, cursorLine, cursorCol, options) {
131919
- const textBeforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
131920
- const atPrefix = extractAtPrefix(textBeforeCursor);
131921
- if (!options.force && atPrefix === null && textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
131922
- const query = textBeforeCursor.slice(1);
131923
- const filtered = fuzzyFilter(this.slashCommandItems, query, (item) => item.value).slice(0, MAX_SUGGESTIONS_WHEN_QUERY);
131924
- if (filtered.length === 0) return null;
131925
- return {
131926
- items: filtered,
131927
- prefix: textBeforeCursor
131928
- };
131929
- }
131930
- if (atPrefix === null) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131931
- if (this.fdPath !== null) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131932
- const snapshot = this.gitCache.getSnapshot();
131933
- if (snapshot === null || snapshot.files.length === 0) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131934
- const query = atPrefix.slice(1);
131935
- const candidates = query.startsWith(".") ? snapshot.files : snapshot.files.filter((p) => !containsDotSegment(p));
131936
- const items = query.length === 0 ? rankForEmptyQuery(candidates, snapshot) : rankForQuery(candidates, query, snapshot);
131937
- if (items.length === 0) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131880
+ }
131881
+ function detectGitRepo(workDir) {
131882
+ try {
131883
+ const result = spawnSync("git", [
131884
+ "-C",
131885
+ workDir,
131886
+ "rev-parse",
131887
+ "--is-inside-work-tree"
131888
+ ], {
131889
+ encoding: "utf8",
131890
+ timeout: SPAWN_TIMEOUT_MS
131891
+ });
131892
+ return result.status === 0 && result.stdout.trim() === "true";
131893
+ } catch {
131894
+ return false;
131895
+ }
131896
+ }
131897
+ function readBranch(workDir) {
131898
+ try {
131899
+ const result = spawnSync("git", [
131900
+ "-C",
131901
+ workDir,
131902
+ "branch",
131903
+ "--show-current"
131904
+ ], {
131905
+ encoding: "utf8",
131906
+ timeout: SPAWN_TIMEOUT_MS
131907
+ });
131908
+ if (result.status !== 0) return null;
131909
+ const name = result.stdout.trim();
131910
+ return name.length > 0 ? name : null;
131911
+ } catch {
131912
+ return null;
131913
+ }
131914
+ }
131915
+ function readStatus(workDir) {
131916
+ try {
131917
+ const result = spawnSync("git", [
131918
+ "-C",
131919
+ workDir,
131920
+ "status",
131921
+ "--porcelain",
131922
+ "-b"
131923
+ ], {
131924
+ encoding: "utf8",
131925
+ timeout: SPAWN_TIMEOUT_MS,
131926
+ maxBuffer: 4 * 1024 * 1024
131927
+ });
131928
+ if (result.status !== 0) return {
131929
+ dirty: false,
131930
+ ahead: 0,
131931
+ behind: 0,
131932
+ diffAdded: 0,
131933
+ diffDeleted: 0
131934
+ };
131935
+ let dirty = false;
131936
+ let ahead = 0;
131937
+ let behind = 0;
131938
+ for (const line of result.stdout.split("\n")) if (line.startsWith("## ")) {
131939
+ const m = AHEAD_BEHIND_RE.exec(line);
131940
+ if (m) {
131941
+ ahead = Number.parseInt(m[1] ?? "0", 10) || 0;
131942
+ behind = Number.parseInt(m[2] ?? "0", 10) || 0;
131943
+ }
131944
+ } else if (line.trim().length > 0) dirty = true;
131945
+ const diff = dirty ? readDiffStats(workDir) : {
131946
+ added: 0,
131947
+ deleted: 0
131948
+ };
131938
131949
  return {
131939
- items,
131940
- prefix: atPrefix
131950
+ dirty,
131951
+ ahead,
131952
+ behind,
131953
+ diffAdded: diff.added,
131954
+ diffDeleted: diff.deleted
131955
+ };
131956
+ } catch {
131957
+ return {
131958
+ dirty: false,
131959
+ ahead: 0,
131960
+ behind: 0,
131961
+ diffAdded: 0,
131962
+ diffDeleted: 0
131941
131963
  };
131942
131964
  }
131943
- applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
131944
- if (prefix.startsWith("/")) {
131945
- const line = lines[cursorLine] ?? "";
131946
- const before = line.slice(0, cursorCol - prefix.length);
131947
- const after = line.slice(cursorCol);
131948
- const replacement = `/${item.value} `;
131949
- const newLine = `${before}${replacement}${after}`;
131950
- return {
131951
- lines: [
131952
- ...lines.slice(0, cursorLine),
131953
- newLine,
131954
- ...lines.slice(cursorLine + 1)
131955
- ],
131956
- cursorLine,
131957
- cursorCol: before.length + replacement.length
131958
- };
131959
- }
131960
- return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
131961
- }
131962
- };
131963
- /**
131964
- * Return the `@…` token ending at the cursor, or `null` if we're not in
131965
- * an `@` mention. Mirrors pi-tui's `extractAtPrefix` — the token
131966
- * boundary is the last PATH_DELIMITER before the cursor, and the token
131967
- * must start with `@`.
131968
- */
131969
- function extractAtPrefix(text) {
131970
- let tokenStart = 0;
131971
- for (let i = text.length - 1; i >= 0; i -= 1) if (PATH_DELIMITERS.has(text[i] ?? "")) {
131972
- tokenStart = i + 1;
131973
- break;
131974
- }
131975
- if (text[tokenStart] !== "@") return null;
131976
- return text.slice(tokenStart);
131977
- }
131978
- /** True when any path segment starts with a dot (e.g. `.github/x.yml`). */
131979
- function containsDotSegment(path) {
131980
- for (const segment of path.split("/")) if (segment.startsWith(".")) return true;
131981
- return false;
131982
131965
  }
131983
- /**
131984
- * Empty-query ranking: stratified by signal strength.
131985
- *
131986
- * Layer 1: files touched in the last RECENT_COMMIT_DEPTH commits,
131987
- * ordered by how recently. Strongest signal — if the user
131988
- * just worked on it, they probably want to mention it.
131989
- * Layer 2: files with the newest fs mtime (covers uncommitted edits
131990
- * and files edited but not yet added to git).
131991
- * Layer 3: everything else, alphabetical by basename so
131992
- * README/package.json-style top-level files bubble up
131993
- * relative to deeply-nested alphabetical paths.
131994
- *
131995
- * Cap at MAX_SUGGESTIONS_WHEN_EMPTY. Layers fill in order; dedup by
131996
- * path so a recently-edited file isn't also listed in layer 2.
131997
- */
131998
- function rankForEmptyQuery(files, snapshot) {
131999
- const picked = /* @__PURE__ */ new Set();
132000
- const result = [];
132001
- const cap = MAX_SUGGESTIONS_WHEN_EMPTY;
132002
- const inFiles = new Set(files);
132003
- const byRecency = [...snapshot.recencyOrder.entries()].filter(([path]) => inFiles.has(path)).toSorted((a, b) => a[1] - b[1]);
132004
- for (const [path] of byRecency) {
132005
- if (result.length >= cap) break;
132006
- if (picked.has(path)) continue;
132007
- picked.add(path);
132008
- result.push(path);
132009
- }
132010
- if (result.length < cap) {
132011
- const byMtime = files.filter((p) => !picked.has(p) && snapshot.mtimeByPath.has(p)).toSorted((a, b) => (snapshot.mtimeByPath.get(b) ?? 0) - (snapshot.mtimeByPath.get(a) ?? 0));
132012
- for (const path of byMtime) {
132013
- if (result.length >= cap) break;
132014
- picked.add(path);
132015
- result.push(path);
132016
- }
132017
- }
132018
- if (result.length < cap) {
132019
- const rest = files.filter((p) => !picked.has(p)).toSorted((a, b) => basename(a).localeCompare(basename(b)) || a.localeCompare(b));
132020
- for (const path of rest) {
132021
- if (result.length >= cap) break;
132022
- result.push(path);
131966
+ function readDiffStats(workDir) {
131967
+ try {
131968
+ const result = spawnSync("git", [
131969
+ "-C",
131970
+ workDir,
131971
+ "diff",
131972
+ "--numstat",
131973
+ "HEAD",
131974
+ "--"
131975
+ ], {
131976
+ encoding: "utf8",
131977
+ timeout: SPAWN_TIMEOUT_MS,
131978
+ maxBuffer: 4 * 1024 * 1024
131979
+ });
131980
+ if (result.status !== 0) return {
131981
+ added: 0,
131982
+ deleted: 0
131983
+ };
131984
+ let added = 0;
131985
+ let deleted = 0;
131986
+ for (const line of result.stdout.split("\n")) {
131987
+ if (!line) continue;
131988
+ const [addedText, deletedText] = line.split(" ");
131989
+ added += parseDiffNumstatCount(addedText);
131990
+ deleted += parseDiffNumstatCount(deletedText);
132023
131991
  }
131992
+ return {
131993
+ added,
131994
+ deleted
131995
+ };
131996
+ } catch {
131997
+ return {
131998
+ added: 0,
131999
+ deleted: 0
132000
+ };
132024
132001
  }
132025
- return result.map(toItem);
132026
132002
  }
132027
- /**
132028
- * Non-empty-query ranking: three strictness tiers, with recency /
132029
- * mtime as tie-breakers inside each tier so "the readme you just
132030
- * edited" beats "a readme deep in a vendor dir".
132031
- */
132032
- function rankForQuery(files, query, snapshot) {
132033
- const lowerQuery = query.toLowerCase();
132034
- const scored = [];
132035
- for (const path of files) {
132036
- const base = basename(path).toLowerCase();
132037
- if (base.startsWith(lowerQuery)) {
132038
- scored.push({
132039
- path,
132040
- cat: 0,
132041
- fuzzyScore: 0
132042
- });
132043
- continue;
132044
- }
132045
- if (base.includes(lowerQuery)) {
132046
- scored.push({
132047
- path,
132048
- cat: 1,
132049
- fuzzyScore: 0
132003
+ function parseDiffNumstatCount(value) {
132004
+ if (value === void 0 || value === "-") return 0;
132005
+ const n = Number.parseInt(value, 10);
132006
+ return Number.isFinite(n) && n > 0 ? n : 0;
132007
+ }
132008
+ function readPullRequest(workDir) {
132009
+ return new Promise((resolve) => {
132010
+ try {
132011
+ execFile("gh", [
132012
+ "pr",
132013
+ "view",
132014
+ "--json",
132015
+ "number,url"
132016
+ ], {
132017
+ cwd: workDir,
132018
+ encoding: "utf8",
132019
+ env: {
132020
+ ...process.env,
132021
+ GH_NO_UPDATE_NOTIFIER: "1",
132022
+ GH_PROMPT_DISABLED: "1"
132023
+ },
132024
+ timeout: PR_SPAWN_TIMEOUT_MS,
132025
+ maxBuffer: 256 * 1024
132026
+ }, (error, stdout) => {
132027
+ if (error !== null) {
132028
+ resolve(null);
132029
+ return;
132030
+ }
132031
+ resolve(parsePullRequest(stdout));
132050
132032
  });
132051
- continue;
132033
+ } catch {
132034
+ resolve(null);
132052
132035
  }
132053
- const fuzzy = fuzzyMatch(query, path);
132054
- if (fuzzy.matches) scored.push({
132055
- path,
132056
- cat: 2,
132057
- fuzzyScore: fuzzy.score
132058
- });
132059
- }
132060
- if (scored.length === 0) return fuzzyFilter([...files], query, (p) => p).slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map(toItem);
132061
- scored.sort((a, b) => {
132062
- if (a.cat !== b.cat) return a.cat - b.cat;
132063
- if (a.cat === 2 && a.fuzzyScore !== b.fuzzyScore) return a.fuzzyScore - b.fuzzyScore;
132064
- const ra = snapshot.recencyOrder.get(a.path);
132065
- const rb = snapshot.recencyOrder.get(b.path);
132066
- if (ra !== void 0 && rb !== void 0 && ra !== rb) return ra - rb;
132067
- if (ra !== void 0 && rb === void 0) return -1;
132068
- if (ra === void 0 && rb !== void 0) return 1;
132069
- const ma = snapshot.mtimeByPath.get(a.path) ?? 0;
132070
- const mb = snapshot.mtimeByPath.get(b.path) ?? 0;
132071
- if (ma !== mb) return mb - ma;
132072
- const baseLenDiff = basename(a.path).length - basename(b.path).length;
132073
- if (baseLenDiff !== 0) return baseLenDiff;
132074
- return a.path.localeCompare(b.path);
132075
132036
  });
132076
- return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path));
132077
132037
  }
132078
- function toItem(path) {
132079
- return {
132080
- value: `@${path}`,
132081
- label: basename(path),
132082
- description: path
132083
- };
132038
+ function samePullRequest(a, b) {
132039
+ if (a === null || b === null) return a === b;
132040
+ return a.number === b.number && a.url === b.url;
132084
132041
  }
132085
- //#endregion
132086
- //#region src/tui/components/messages/cron-message.ts
132087
- var CronMessageComponent = class {
132088
- colors;
132089
- spacer = new Spacer(1);
132090
- title;
132091
- detail;
132092
- titleColor;
132093
- promptText;
132094
- constructor(prompt, data, colors) {
132095
- this.colors = colors;
132096
- const missed = data.missedCount !== void 0;
132097
- this.title = missed ? "错过的定时提醒" : "定时提醒触发";
132098
- this.detail = cronDetail(data);
132099
- this.titleColor = data.stale === true || missed ? colors.warning : colors.accent;
132100
- this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0);
132042
+ function parsePullRequest(stdout) {
132043
+ try {
132044
+ const raw = JSON.parse(stdout);
132045
+ if (typeof raw !== "object" || raw === null) return null;
132046
+ const record = raw;
132047
+ const number = record["number"];
132048
+ const url = record["url"];
132049
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) return null;
132050
+ if (typeof url !== "string" || !isSafeHttpUrl(url)) return null;
132051
+ return {
132052
+ number,
132053
+ url
132054
+ };
132055
+ } catch {
132056
+ return null;
132101
132057
  }
132102
- invalidate() {
132103
- this.promptText.invalidate();
132058
+ }
132059
+ function isSafeHttpUrl(value) {
132060
+ if (hasControlChars(value)) return false;
132061
+ try {
132062
+ const url = new URL(value);
132063
+ return url.protocol === "https:" || url.protocol === "http:";
132064
+ } catch {
132065
+ return false;
132104
132066
  }
132105
- render(width) {
132106
- const bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET);
132107
- const bulletWidth = visibleWidth(bullet);
132108
- const contentWidth = Math.max(1, width - bulletWidth);
132109
- const lines = [];
132110
- for (const line of this.spacer.render(width)) lines.push(line);
132111
- const title = chalk.hex(this.titleColor).bold(this.title);
132112
- lines.push(`${bullet}${title}`);
132113
- if (this.detail !== void 0) lines.push(`${" ".repeat(bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`);
132114
- const promptLines = this.promptText.render(contentWidth);
132115
- for (const line of promptLines) lines.push(`${" ".repeat(bulletWidth)}${line}`);
132116
- return lines;
132067
+ }
132068
+ function hasControlChars(value) {
132069
+ for (const char of value) {
132070
+ const code = char.codePointAt(0) ?? 0;
132071
+ if (code <= 31 || code === 127) return true;
132117
132072
  }
132118
- };
132119
- function cronDetail(data) {
132073
+ return false;
132074
+ }
132075
+ function formatGitBadgeBase(status) {
132120
132076
  const parts = [];
132121
- if (data.cron !== void 0 && data.cron.length > 0) parts.push(data.cron);
132122
- if (data.jobId !== void 0 && data.jobId.length > 0) parts.push(`job ${data.jobId}`);
132123
- if (data.recurring === false) parts.push("一次性");
132124
- if (data.coalescedCount !== void 0 && data.coalescedCount > 1) parts.push(`${String(data.coalescedCount)} 次合并触发`);
132125
- if (data.missedCount !== void 0) parts.push(`${String(data.missedCount)} 次错过`);
132126
- if (data.stale === true) parts.push("最终投递");
132127
- return parts.length > 0 ? parts.join(" | ") : void 0;
132077
+ const diff = formatDiffStats(status);
132078
+ if (diff) parts.push(diff);
132079
+ let sync = "";
132080
+ if (status.ahead > 0) sync += `↑${status.ahead}`;
132081
+ if (status.behind > 0) sync += `↓${status.behind}`;
132082
+ if (sync) parts.push(sync);
132083
+ return parts.length === 0 ? status.branch : `${status.branch} [${parts.join(" ")}]`;
132084
+ }
132085
+ function formatPullRequestBadge(pullRequest, options = {}) {
132086
+ const prText = `[PR#${String(pullRequest.number)}]`;
132087
+ return options.linkPullRequest ? toTerminalHyperlink(prText, pullRequest.url) : prText;
132088
+ }
132089
+ function formatDiffStats(status) {
132090
+ const parts = [];
132091
+ if (status.diffAdded > 0) parts.push(`+${String(status.diffAdded)}`);
132092
+ if (status.diffDeleted > 0) parts.push(`-${String(status.diffDeleted)}`);
132093
+ if (parts.length > 0) return parts.join(" ");
132094
+ return status.dirty ? "±" : null;
132095
+ }
132096
+ function toTerminalHyperlink(text, url) {
132097
+ if (!isSafeHttpUrl(url)) return text;
132098
+ return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
132128
132099
  }
132129
132100
  //#endregion
132130
- //#region src/tui/components/panes/activity-pane.ts
132131
- var ActivityPaneComponent = class extends Container {
132132
- constructor(options) {
132133
- super();
132134
- if (options.mode === "waiting" || options.mode === "tool") {
132135
- if (options.spinner !== void 0) {
132136
- this.addChild(new Spacer(1));
132137
- this.addChild(options.spinner);
132138
- }
132139
- if (options.pulseWave !== void 0) {
132140
- this.addChild(new Spacer(1));
132141
- this.addChild(options.pulseWave);
132142
- }
132143
- return;
132144
- }
132145
- if (options.mode === "composing" && options.spinner !== void 0) {
132146
- this.addChild(new Spacer(1));
132147
- this.addChild(options.spinner);
132148
- if (options.pulseWave !== void 0) {
132149
- this.addChild(new Spacer(1));
132150
- this.addChild(options.pulseWave);
132151
- }
132152
- }
132101
+ //#region src/tui/components/chrome/footer.ts
132102
+ const MAX_CWD_SEGMENTS = 3;
132103
+ const TOOLBAR_TIPS = [
132104
+ { text: "shift+tab: 计划模式" },
132105
+ { text: "/model: 切换模型" },
132106
+ {
132107
+ text: "ctrl+s: 中途干预",
132108
+ priority: 2
132109
+ },
132110
+ {
132111
+ text: "/compact: 压缩上下文",
132112
+ priority: 2
132113
+ },
132114
+ { text: "ctrl+o: 展开工具输出" },
132115
+ { text: "/tasks: 后台任务" },
132116
+ { text: "shift+enter: 换行" },
132117
+ {
132118
+ text: "/init: 生成 AGENTS.md",
132119
+ priority: 2
132120
+ },
132121
+ { text: "@: 提及文件" },
132122
+ { text: "ctrl+c: 取消" },
132123
+ { text: "/theme: 切换主题" },
132124
+ { text: "/auto: 自动权限模式" },
132125
+ { text: "/yes: 自动批准" },
132126
+ { text: "/help: 显示命令" },
132127
+ {
132128
+ text: "/config: 选择并配置你常用的模型商",
132129
+ solo: true,
132130
+ priority: 3
132131
+ },
132132
+ {
132133
+ text: "让 Scream 安排任务,例如 \"2个小时后提醒我去拿快递\"",
132134
+ solo: true,
132135
+ priority: 3
132153
132136
  }
132154
- };
132155
- //#endregion
132156
- //#region src/tui/components/panes/queue-pane.ts
132157
- var QueuePaneComponent = class extends Container {
132158
- constructor(options) {
132159
- super();
132160
- const accent = chalk.hex(options.colors.accent);
132161
- const dim = chalk.hex(options.colors.textDim);
132162
- for (const item of options.messages) this.addChild(new Text(accent(` ❯ ${item.text}`), 0, 0));
132163
- if (options.messages.length > 0) {
132164
- const hint = options.isCompacting && !options.isStreaming ? " ↑ to edit · will send after compaction" : !options.canSteerImmediately ? " ↑ to edit · will send after current task" : " ↑ to edit · ctrl-s to steer immediately";
132165
- this.addChild(new Text(dim(hint), 0, 0));
132137
+ ];
132138
+ /**
132139
+ * Expand tips into a rotation sequence using smooth weighted round-robin
132140
+ * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while
132141
+ * staying evenly spread, so a tip generally does not land next to its own
132142
+ * duplicate. Deterministic and computed once at module load. Exported for
132143
+ * unit testing.
132144
+ */
132145
+ function buildWeightedTips(tips) {
132146
+ const items = tips.map((t) => ({
132147
+ tip: t,
132148
+ weight: Math.max(1, Math.trunc(t.priority ?? 1)),
132149
+ current: 0
132150
+ }));
132151
+ const total = items.reduce((sum, it) => sum + it.weight, 0);
132152
+ const seq = [];
132153
+ for (let n = 0; n < total; n++) {
132154
+ let best = items[0];
132155
+ for (const it of items) {
132156
+ it.current += it.weight;
132157
+ if (it.current > best.current) best = it;
132166
132158
  }
132159
+ best.current -= total;
132160
+ seq.push(best.tip);
132167
132161
  }
132168
- };
132169
- //#endregion
132170
- //#region src/tui/reverse-rpc/base-controller.ts
132171
- var ReverseRpcController = class {
132172
- uiHooks = null;
132173
- current = null;
132174
- queue = [];
132175
- setUIHooks(hooks) {
132176
- this.uiHooks = hooks;
132177
- }
132178
- /**
132179
- * Called when a reverse RPC request arrives from core. The returned promise
132180
- * resolves after the user responds or `cancelAll` forces cancellation.
132181
- */
132182
- show(payload) {
132183
- return new Promise((resolve) => {
132184
- const entry = {
132185
- payload,
132186
- resolve
132187
- };
132188
- if (this.current === null) {
132189
- this.current = entry;
132190
- this.uiHooks?.showPanel(payload);
132191
- } else this.queue.push(entry);
132192
- });
132193
- }
132194
- /** Called by the UI after the user makes a panel choice. */
132195
- respond(data) {
132196
- const pending = this.current;
132197
- this.current = null;
132198
- pending?.resolve(data);
132199
- if (pending !== null) this.drainAutoResolved(pending.payload, data);
132200
- this.advanceOrHide();
132201
- }
132202
- /** Cancels all pending requests during shutdown or session switches. */
132203
- cancelAll(reason) {
132204
- const all = [...this.current === null ? [] : [this.current], ...this.queue];
132205
- this.current = null;
132206
- this.queue = [];
132207
- this.uiHooks?.hidePanel();
132208
- for (const entry of all) entry.resolve(this.createCancelResponse(reason));
132209
- }
132210
- hasPending() {
132211
- return this.current !== null || this.queue.length > 0;
132162
+ return seq;
132163
+ }
132164
+ buildWeightedTips(TOOLBAR_TIPS);
132165
+ function shortenModel(model) {
132166
+ if (!model) return model;
132167
+ const slash = model.lastIndexOf("/");
132168
+ return slash >= 0 ? model.slice(slash + 1) : model;
132169
+ }
132170
+ function modelDisplayName(state) {
132171
+ const model = state.availableModels[state.model];
132172
+ return model?.displayName ?? model?.model ?? state.model;
132173
+ }
132174
+ function shortenCwd(path) {
132175
+ if (!path) return path;
132176
+ const home = process.env["HOME"] ?? "";
132177
+ let work = path;
132178
+ if (home && path === home) return "~";
132179
+ if (home && path.startsWith(home + "/")) work = "~" + path.slice(home.length);
132180
+ const segments = work.split("/").filter((s) => s.length > 0);
132181
+ if (segments.length <= MAX_CWD_SEGMENTS) return work;
132182
+ return `…/${segments.slice(-3).join("/")}`;
132183
+ }
132184
+ function formatTokenCount(n) {
132185
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
132186
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
132187
+ return String(n);
132188
+ }
132189
+ function safeUsage(usage) {
132190
+ return safeUsageRatio(usage);
132191
+ }
132192
+ function formatContextStatus(usage, tokens, maxTokens) {
132193
+ const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`;
132194
+ if (maxTokens && maxTokens > 0 && tokens !== void 0) return `上下文:${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
132195
+ return `上下文:${pct}`;
132196
+ }
132197
+ const BRAND_COLORS = [
132198
+ "#72A4E9",
132199
+ "#A78BFA",
132200
+ "#34D399"
132201
+ ];
132202
+ const GRADIENT_CYCLE_MS = 4e3;
132203
+ const SPINNER_FRAMES = [
132204
+ "●",
132205
+ "◉",
132206
+ "◎",
132207
+ "◌",
132208
+ "○",
132209
+ "◌",
132210
+ "◎",
132211
+ "◉"
132212
+ ];
132213
+ const SPINNER_TICK_MS = 120;
132214
+ function hexToRgb(hex) {
132215
+ const v = parseInt(hex.slice(1), 16);
132216
+ return [
132217
+ v >> 16 & 255,
132218
+ v >> 8 & 255,
132219
+ v & 255
132220
+ ];
132221
+ }
132222
+ function lerpGradient(t) {
132223
+ const count = BRAND_COLORS.length;
132224
+ const segment = Math.min(t * count, count - 1);
132225
+ const idx = Math.floor(segment);
132226
+ const localT = segment - idx;
132227
+ const nextIdx = (idx + 1) % count;
132228
+ const [r0, g0, b0] = hexToRgb(BRAND_COLORS[idx]);
132229
+ const [r1, g1, b1] = hexToRgb(BRAND_COLORS[nextIdx]);
132230
+ const r = Math.round(r0 + (r1 - r0) * localT);
132231
+ const g = Math.round(g0 + (g1 - g0) * localT);
132232
+ const b = Math.round(b0 + (b1 - b0) * localT);
132233
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
132234
+ }
132235
+ function buildStatusLine(streamingPhase, livePaneMode, streamingStartTime) {
132236
+ if (streamingPhase === "idle" && livePaneMode !== "tool") return "○ 空闲";
132237
+ let label;
132238
+ if (livePaneMode === "tool") label = "执行中";
132239
+ else if (streamingPhase === "waiting") label = "等待响应";
132240
+ else if (streamingPhase === "thinking") label = "思考中";
132241
+ else if (streamingPhase === "composing") label = "输出中";
132242
+ else label = "";
132243
+ const elapsed = Date.now() - streamingStartTime;
132244
+ const totalSeconds = Math.floor(elapsed / 1e3);
132245
+ const elapsedStr = totalSeconds < 60 ? `${totalSeconds}s` : `${Math.floor(totalSeconds / 60)}m${totalSeconds % 60}s`;
132246
+ const now = Date.now();
132247
+ const frame = SPINNER_FRAMES[Math.floor(now / SPINNER_TICK_MS) % SPINNER_FRAMES.length];
132248
+ const gradientColor = lerpGradient(now % GRADIENT_CYCLE_MS / GRADIENT_CYCLE_MS);
132249
+ return chalk.hex(gradientColor).bold(frame) + " " + label + " " + elapsedStr;
132250
+ }
132251
+ function formatFooterGitBadge(status, colors) {
132252
+ const base = chalk.hex(colors.status)(formatGitBadgeBase(status));
132253
+ if (status.pullRequest === null) return base;
132254
+ return `${base} ${chalk.hex(colors.primary)(formatPullRequestBadge(status.pullRequest, { linkPullRequest: true }))}`;
132255
+ }
132256
+ var FooterComponent = class {
132257
+ state;
132258
+ colors;
132259
+ onGitStatusChange;
132260
+ gitCache;
132261
+ gitCacheWorkDir;
132262
+ transientHint = null;
132263
+ /**
132264
+ * Non-terminal background-task counts split by kind so the footer can
132265
+ * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks
132266
+ * spawned via `Shell run_in_background=true`; `agentTasks` covers
132267
+ * `agent-*` BPM tasks (background subagents). Either zero hides its
132268
+ * respective badge.
132269
+ */
132270
+ backgroundBashTaskCount = 0;
132271
+ backgroundAgentCount = 0;
132272
+ constructor(state, colors, onGitStatusChange = () => {}) {
132273
+ this.state = state;
132274
+ this.colors = colors;
132275
+ this.onGitStatusChange = onGitStatusChange;
132276
+ this.gitCacheWorkDir = state.workDir;
132277
+ this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
132212
132278
  }
132213
- advanceOrHide() {
132214
- const next = this.queue.shift();
132215
- if (next === void 0) {
132216
- this.uiHooks?.hidePanel();
132217
- return;
132279
+ setState(state) {
132280
+ if (state.workDir !== this.gitCacheWorkDir) {
132281
+ this.gitCacheWorkDir = state.workDir;
132282
+ this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
132218
132283
  }
132219
- this.current = next;
132220
- this.uiHooks?.showPanel(next.payload);
132284
+ this.state = state;
132221
132285
  }
132222
- drainAutoResolved(resolvedPayload, response) {
132223
- const remaining = [];
132224
- for (const entry of this.queue) {
132225
- const auto = this.autoResolveFor(resolvedPayload, response, entry.payload);
132226
- if (auto === void 0) remaining.push(entry);
132227
- else entry.resolve(auto);
132228
- }
132229
- this.queue = remaining;
132286
+ setColors(colors) {
132287
+ this.colors = colors;
132230
132288
  }
132231
132289
  /**
132232
- * Subclasses override to short-circuit queued requests when an answer to the
132233
- * just-resolved one (e.g. an approve-for-session) implies the same answer
132234
- * for matching queued requests. Return `undefined` to leave the queued
132235
- * request waiting for its own panel turn.
132290
+ * Short-lived hint that replaces the rotating toolbar tips on line 1.
132291
+ * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C
132292
+ * again to exit" without requiring a toast/overlay subsystem.
132293
+ * Pass `null` to clear.
132236
132294
  */
132237
- autoResolveFor(_resolvedPayload, _response, _queuedPayload) {}
132238
- };
132239
- //#endregion
132240
- //#region src/tui/reverse-rpc/approval/controller.ts
132241
- var ApprovalController = class extends ReverseRpcController {
132242
- createCancelResponse(reason) {
132243
- return {
132244
- decision: "cancelled",
132245
- feedback: reason
132246
- };
132247
- }
132248
- autoResolveFor(resolvedPayload, response, queuedPayload) {
132249
- if (response.decision !== "approved") return void 0;
132250
- if (response.scope !== "session") return void 0;
132251
- if (resolvedPayload.action !== queuedPayload.action) return void 0;
132252
- return {
132253
- decision: "approved",
132254
- scope: "session"
132255
- };
132256
- }
132257
- };
132258
- //#endregion
132259
- //#region src/tui/reverse-rpc/modal-coordinator.ts
132260
- var ReverseRpcModalCoordinator = class {
132261
- hooks;
132262
- active = null;
132263
- queued = [];
132264
- constructor(hooks) {
132265
- this.hooks = hooks;
132266
- }
132267
- showApproval(payload) {
132268
- this.show({
132269
- owner: "approval",
132270
- show: () => {
132271
- this.hooks.showApprovalPanel(payload);
132272
- },
132273
- hide: () => {
132274
- this.hooks.hideApprovalPanel();
132275
- }
132276
- });
132277
- }
132278
- showQuestion(payload) {
132279
- this.show({
132280
- owner: "question",
132281
- show: () => {
132282
- this.hooks.showQuestionDialog(payload);
132283
- },
132284
- hide: () => {
132285
- this.hooks.hideQuestionDialog();
132286
- }
132287
- });
132288
- }
132289
- hide(owner) {
132290
- if (this.active?.owner === owner) {
132291
- const active = this.active;
132292
- this.active = null;
132293
- active.hide();
132294
- this.showNext();
132295
- return;
132296
- }
132297
- const queuedIndex = this.queued.findIndex((entry) => entry.owner === owner);
132298
- if (queuedIndex >= 0) this.queued.splice(queuedIndex, 1);
132295
+ setTransientHint(hint) {
132296
+ this.transientHint = hint;
132299
132297
  }
132300
- clear() {
132301
- const active = this.active;
132302
- this.active = null;
132303
- this.queued.length = 0;
132304
- active?.hide();
132298
+ /**
132299
+ * Sync both background-task badges with live counts. Each non-zero
132300
+ * count produces its own bracketed badge on line 1; zeros hide them
132301
+ * independently.
132302
+ */
132303
+ setBackgroundCounts(counts) {
132304
+ this.backgroundBashTaskCount = Math.max(0, counts.bashTasks);
132305
+ this.backgroundAgentCount = Math.max(0, counts.agentTasks);
132305
132306
  }
132306
- show(entry) {
132307
- const active = this.active;
132308
- if (active === null) {
132309
- this.active = entry;
132310
- entry.show();
132311
- return;
132312
- }
132313
- if (active.owner === entry.owner) {
132314
- this.active = entry;
132315
- entry.show();
132316
- return;
132307
+ invalidate() {}
132308
+ render(width) {
132309
+ const colors = this.colors;
132310
+ const state = this.state;
132311
+ const left = [];
132312
+ if (state.permissionMode === "auto") left.push(chalk.hex(colors.warning).bold("auto"));
132313
+ if (state.permissionMode === "yolo") left.push(chalk.hex(colors.warning).bold("YES"));
132314
+ if (state.planMode) left.push(chalk.hex(colors.primary).bold("plan"));
132315
+ if (state.wolfpackMode) left.push(chalk.hex(colors.primary).bold("wolfpack"));
132316
+ if (state.goalActive) left.push(chalk.hex(colors.primary).bold("goal"));
132317
+ const model = shortenModel(modelDisplayName(state));
132318
+ if (model) {
132319
+ const thinkingLabel = state.thinking ? " 思考中" : "";
132320
+ left.push(chalk.hex(colors.text)(`${model}${thinkingLabel}`));
132317
132321
  }
132318
- const queuedIndex = this.queued.findIndex((queued) => queued.owner === entry.owner);
132319
- if (queuedIndex >= 0) {
132320
- this.queued[queuedIndex] = entry;
132321
- return;
132322
+ if (this.backgroundBashTaskCount > 0) {
132323
+ const noun = this.backgroundBashTaskCount === 1 ? "个任务" : "个任务";
132324
+ left.push(chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)}${noun} 运行中]`));
132322
132325
  }
132323
- this.queued.push(entry);
132324
- }
132325
- showNext() {
132326
- const next = this.queued.shift();
132327
- if (next === void 0) return;
132328
- this.active = next;
132329
- next.show();
132330
- }
132331
- };
132332
- //#endregion
132333
- //#region src/tui/reverse-rpc/index.ts
132334
- function registerReverseRPCHandlers(approvalController, questionController, uiHooks) {
132335
- const modalCoordinator = new ReverseRpcModalCoordinator(uiHooks);
132336
- approvalController.setUIHooks({
132337
- showPanel: (payload) => {
132338
- modalCoordinator.showApproval(payload);
132339
- },
132340
- hidePanel: () => {
132341
- modalCoordinator.hide("approval");
132326
+ if (this.backgroundAgentCount > 0) {
132327
+ const noun = this.backgroundAgentCount === 1 ? "个代理" : "个代理";
132328
+ left.push(chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)}${noun} 运行中]`));
132342
132329
  }
132343
- });
132344
- questionController.setUIHooks({
132345
- showPanel: (payload) => {
132346
- modalCoordinator.showQuestion(payload);
132347
- },
132348
- hidePanel: () => {
132349
- modalCoordinator.hide("question");
132330
+ const cwd = shortenCwd(state.workDir);
132331
+ if (cwd) left.push(chalk.hex(colors.status)(cwd));
132332
+ const git = this.gitCache.getStatus();
132333
+ if (git !== null) left.push(formatFooterGitBadge(git, colors));
132334
+ const leftLine = left.join(" ");
132335
+ const leftWidth = visibleWidth(leftLine);
132336
+ let rightText;
132337
+ if (this.transientHint) rightText = chalk.hex(colors.warning).bold(this.transientHint);
132338
+ else {
132339
+ const statusLine = buildStatusLine(state.streamingPhase, state.livePaneMode, state.streamingStartTime);
132340
+ const ccDot = state.ccConnectActive ? chalk.hex(colors.success)("●") : chalk.hex(colors.textDim)("●");
132341
+ rightText = chalk.hex(colors.textDim)(ccDot + " " + formatContextStatus(state.contextUsage, state.contextTokens, state.maxContextTokens) + " " + statusLine);
132350
132342
  }
132351
- });
132352
- return [() => {
132353
- modalCoordinator.clear();
132354
- }];
132355
- }
132356
- //#endregion
132357
- //#region src/tui/reverse-rpc/question/controller.ts
132358
- var QuestionController = class extends ReverseRpcController {
132359
- createCancelResponse(_reason) {
132360
- return { answers: [] };
132343
+ const rightWidth = visibleWidth(rightText);
132344
+ const gap = 3;
132345
+ let line1;
132346
+ if (leftWidth + gap + rightWidth <= width) {
132347
+ const pad = width - leftWidth - rightWidth;
132348
+ line1 = leftLine + " ".repeat(pad) + rightText;
132349
+ } else if (leftWidth <= width) line1 = leftLine;
132350
+ else line1 = truncateToWidth(leftLine, width, "…");
132351
+ return [truncateToWidth(line1, width)];
132361
132352
  }
132362
132353
  };
132363
132354
  //#endregion
132364
- //#region src/tui/theme/bundle.ts
132365
- function createScreamTUIThemeBundle(theme, resolvedTheme) {
132366
- const actualTheme = resolvedTheme ?? resolveThemeSync(theme);
132367
- const colors = { ...getColorPalette(actualTheme) };
132368
- return {
132369
- resolvedTheme: actualTheme,
132370
- colors,
132371
- styles: createThemeStyles(colors),
132372
- markdownTheme: createMarkdownTheme(colors)
132373
- };
132374
- }
132375
- //#endregion
132376
- //#region src/tui/types.ts
132377
- const INITIAL_LIVE_PANE = {
132378
- mode: "idle",
132379
- pendingApproval: null,
132380
- pendingQuestion: null
132381
- };
132382
- //#endregion
132383
132355
  //#region src/tui/components/chrome/todo-panel.ts
132384
132356
  const MAX_VISIBLE = 5;
132385
132357
  /**
@@ -136147,27 +136119,6 @@ var ScreamTUI = class ScreamTUI {
136147
136119
  const footerWrap = new GutterContainer(1, 1);
136148
136120
  footerWrap.addChild(this.state.footer);
136149
136121
  this.state.ui.addChild(footerWrap);
136150
- this.installFixedBottomRegion();
136151
- }
136152
- /**
136153
- * Pin the editor + footer to the terminal bottom. The pi-tui patch adds a
136154
- * `fixedBottomLineCount` property: the last N rendered lines stay pinned
136155
- * while the transcript above scrolls independently.
136156
- *
136157
- * We override `doRender` to measure the editor + footer height each frame
136158
- * (they change with multi-line input or terminal resize) and set the count
136159
- * before the real render runs.
136160
- */
136161
- installFixedBottomRegion() {
136162
- const ui = this.state.ui;
136163
- const originalDoRender = ui.doRender.bind(ui);
136164
- const editorContainer = this.state.editorContainer;
136165
- const footerWrap = ui.children.at(-1);
136166
- ui.doRender = () => {
136167
- const w = ui.terminal?.columns ?? 0;
136168
- if (w > 0) ui.fixedBottomLineCount = editorContainer.render(w).length + (footerWrap ? footerWrap.render(w).length : 0);
136169
- originalDoRender();
136170
- };
136171
136122
  }
136172
136123
  handlePlanToggle(next) {
136173
136124
  handlePlanCommand(this, next ? "on" : "off");