scream-code 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.mjs CHANGED
@@ -118880,6 +118880,7 @@ function escapeTomlBasicString(value) {
118880
118880
  }
118881
118881
  //#endregion
118882
118882
  //#region src/tui/constant/rendering.ts
118883
+ const MAX_SHELL_OUTPUT_BYTES = 128 * 1024;
118883
118884
  const BRAILLE_SPINNER_FRAMES = [
118884
118885
  "⠋",
118885
118886
  "⠙",
@@ -121157,13 +121158,19 @@ var UsagePanelComponent = class {
121157
121158
  lines;
121158
121159
  borderHex;
121159
121160
  title;
121161
+ cachedWidth;
121162
+ cachedLines;
121160
121163
  constructor(lines, borderHex, title = " 用量 ") {
121161
121164
  this.lines = lines;
121162
121165
  this.borderHex = borderHex;
121163
121166
  this.title = title;
121164
121167
  }
121165
- invalidate() {}
121168
+ invalidate() {
121169
+ this.cachedWidth = void 0;
121170
+ this.cachedLines = void 0;
121171
+ }
121166
121172
  render(width) {
121173
+ if (this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
121167
121174
  const paint = (s) => chalk.hex(this.borderHex)(s);
121168
121175
  const indent = " ".repeat(LEFT_MARGIN$1);
121169
121176
  const availableInterior = Math.max(MIN_INTERIOR_WIDTH, width - LEFT_MARGIN$1 - 2 - 2 * SIDE_PADDING$1);
@@ -121180,6 +121187,8 @@ var UsagePanelComponent = class {
121180
121187
  out.push(indent + paint("│") + " " + clipped + " ".repeat(pad) + " " + paint("│"));
121181
121188
  }
121182
121189
  out.push(bottom);
121190
+ this.cachedWidth = width;
121191
+ this.cachedLines = out;
121183
121192
  return out;
121184
121193
  }
121185
121194
  };
@@ -122161,16 +122170,29 @@ function buildEmptyGoalLines(colors) {
122161
122170
  var GoalStatusMessageComponent = class {
122162
122171
  goal;
122163
122172
  colors;
122173
+ panel;
122174
+ cachedWidth;
122175
+ cachedLines;
122164
122176
  constructor(goal, colors) {
122165
122177
  this.goal = goal;
122166
122178
  this.colors = colors;
122179
+ if (goal === null) this.panel = new UsagePanelComponent(buildEmptyGoalLines(this.colors), this.colors.success, " Scream Goal ");
122180
+ else {
122181
+ const title = ` Scream Goal · ${statusLabel(goal.status)} `;
122182
+ this.panel = new UsagePanelComponent(buildGoalReportLines(goal, this.colors), this.colors.success, title);
122183
+ }
122184
+ }
122185
+ invalidate() {
122186
+ this.cachedWidth = void 0;
122187
+ this.cachedLines = void 0;
122188
+ this.panel.invalidate();
122167
122189
  }
122168
- invalidate() {}
122169
122190
  render(width) {
122170
- const goal = this.goal;
122171
- if (goal === null) return ["", ...new UsagePanelComponent(buildEmptyGoalLines(this.colors), this.colors.success, " Scream Goal ").render(width)];
122172
- const title = ` Scream Goal · ${statusLabel(goal.status)} `;
122173
- return ["", ...new UsagePanelComponent(buildGoalReportLines(goal, this.colors), this.colors.success, title).render(width)];
122191
+ if (this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
122192
+ const lines = ["", ...this.panel.render(width)];
122193
+ this.cachedWidth = width;
122194
+ this.cachedLines = lines;
122195
+ return lines;
122174
122196
  }
122175
122197
  };
122176
122198
  //#endregion
@@ -122350,703 +122372,117 @@ function dismissGoalPanel(host) {
122350
122372
  }
122351
122373
  }
122352
122374
  //#endregion
122353
- //#region src/utils/git/git-status.ts
122375
+ //#region src/tui/components/chrome/welcome.ts
122376
+ const HUE_STOPS = 24;
122377
+ const SUB_STEPS = 5;
122378
+ const BREATHE_STEPS = HUE_STOPS * SUB_STEPS;
122379
+ const BREATHE_INTERVAL_MS = 40;
122380
+ const LOGO_FRAMES = [
122381
+ ["██▄▄▄██", "▐█▄▀▄█▌"],
122382
+ ["██▄▄▄██", "▐▄▄▀▄▄▌"],
122383
+ ["██▄▄▄██", "▐▄▀▄▄▄▌"],
122384
+ ["██▄▄▄██", "▐▄▄▄▀▄▌"],
122385
+ ["██▄▄▄██", "▐█▄▀▄█▌"]
122386
+ ];
122387
+ function hexToRgb$1(hex) {
122388
+ return [
122389
+ parseInt(hex.slice(1, 3), 16),
122390
+ parseInt(hex.slice(3, 5), 16),
122391
+ parseInt(hex.slice(5, 7), 16)
122392
+ ];
122393
+ }
122394
+ function rgbToHsl(r, g, b) {
122395
+ const rf = r / 255, gf = g / 255, bf = b / 255;
122396
+ const max = Math.max(rf, gf, bf), min = Math.min(rf, gf, bf);
122397
+ const l = (max + min) / 2;
122398
+ if (max === min) return [
122399
+ 0,
122400
+ 0,
122401
+ l * 100
122402
+ ];
122403
+ const d = max - min;
122404
+ const s = l > .5 ? d / (2 - max - min) : d / (max + min);
122405
+ let h = 0;
122406
+ if (max === rf) h = ((gf - bf) / d + (gf < bf ? 6 : 0)) / 6;
122407
+ else if (max === gf) h = ((bf - rf) / d + 2) / 6;
122408
+ else h = ((rf - gf) / d + 4) / 6;
122409
+ return [
122410
+ h * 360,
122411
+ s * 100,
122412
+ l * 100
122413
+ ];
122414
+ }
122415
+ function hslToRgb(h, s, l) {
122416
+ const hf = (h % 360 + 360) % 360 / 360;
122417
+ const sf = s / 100, lf = l / 100;
122418
+ if (sf === 0) {
122419
+ const v = Math.round(lf * 255);
122420
+ return [
122421
+ v,
122422
+ v,
122423
+ v
122424
+ ];
122425
+ }
122426
+ const q = lf < .5 ? lf * (1 + sf) : lf + sf - lf * sf;
122427
+ const p = 2 * lf - q;
122428
+ const hue = (t) => {
122429
+ if (t < 0) t += 1;
122430
+ if (t > 1) t -= 1;
122431
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
122432
+ if (t < 1 / 2) return q;
122433
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
122434
+ return p;
122435
+ };
122436
+ return [
122437
+ Math.round(hue(hf + 1 / 3) * 255),
122438
+ Math.round(hue(hf) * 255),
122439
+ Math.round(hue(hf - 1 / 3) * 255)
122440
+ ];
122441
+ }
122442
+ function rgbToHex(r, g, b) {
122443
+ const c = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
122444
+ return `#${c(r)}${c(g)}${c(b)}`;
122445
+ }
122354
122446
  /**
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.
122447
+ * Build a full-hue-wheel palette anchored at the primary-green hue.
122448
+ * At frame 0 (and BREATHE_STEPS) the colour is pure primary; in between
122449
+ * it sweeps through all 24 hue stops with smooth sub-step interpolation.
122361
122450
  */
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
- });
122451
+ function buildBreathingPalette(primaryHex, hueStops, subSteps) {
122452
+ const [r, g, b] = hexToRgb$1(primaryHex);
122453
+ const [baseHue] = rgbToHsl(r, g, b);
122454
+ const steps = hueStops * subSteps;
122455
+ const palette = [];
122456
+ for (let i = 0; i < steps; i++) {
122457
+ const [rr, gg, bb] = hslToRgb((baseHue + i / steps * 360) % 360, 90, 70);
122458
+ palette.push(rgbToHex(rr, gg, bb));
122436
122459
  }
122460
+ return palette;
122437
122461
  }
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;
122462
+ var WelcomeComponent = class {
122463
+ state;
122464
+ colors;
122465
+ ui;
122466
+ breatheFrame = 0;
122467
+ breatheTimer = null;
122468
+ breathePalette;
122469
+ borderTitle = null;
122470
+ constructor(state, colors, ui) {
122471
+ this.state = state;
122472
+ this.colors = colors;
122473
+ this.ui = ui;
122474
+ this.breathePalette = buildBreathingPalette(colors.primary, HUE_STOPS, SUB_STEPS);
122475
+ this.startBreathing();
122452
122476
  }
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
- //#region src/tui/components/chrome/welcome.ts
122947
- const HUE_STOPS = 24;
122948
- const SUB_STEPS = 5;
122949
- const BREATHE_STEPS = HUE_STOPS * SUB_STEPS;
122950
- const BREATHE_INTERVAL_MS = 40;
122951
- function hexToRgb(hex) {
122952
- return [
122953
- parseInt(hex.slice(1, 3), 16),
122954
- parseInt(hex.slice(3, 5), 16),
122955
- parseInt(hex.slice(5, 7), 16)
122956
- ];
122957
- }
122958
- function rgbToHsl(r, g, b) {
122959
- const rf = r / 255, gf = g / 255, bf = b / 255;
122960
- const max = Math.max(rf, gf, bf), min = Math.min(rf, gf, bf);
122961
- const l = (max + min) / 2;
122962
- if (max === min) return [
122963
- 0,
122964
- 0,
122965
- l * 100
122966
- ];
122967
- const d = max - min;
122968
- const s = l > .5 ? d / (2 - max - min) : d / (max + min);
122969
- let h = 0;
122970
- if (max === rf) h = ((gf - bf) / d + (gf < bf ? 6 : 0)) / 6;
122971
- else if (max === gf) h = ((bf - rf) / d + 2) / 6;
122972
- else h = ((rf - gf) / d + 4) / 6;
122973
- return [
122974
- h * 360,
122975
- s * 100,
122976
- l * 100
122977
- ];
122978
- }
122979
- function hslToRgb(h, s, l) {
122980
- const hf = (h % 360 + 360) % 360 / 360;
122981
- const sf = s / 100, lf = l / 100;
122982
- if (sf === 0) {
122983
- const v = Math.round(lf * 255);
122984
- return [
122985
- v,
122986
- v,
122987
- v
122988
- ];
122989
- }
122990
- const q = lf < .5 ? lf * (1 + sf) : lf + sf - lf * sf;
122991
- const p = 2 * lf - q;
122992
- const hue = (t) => {
122993
- if (t < 0) t += 1;
122994
- if (t > 1) t -= 1;
122995
- if (t < 1 / 6) return p + (q - p) * 6 * t;
122996
- if (t < 1 / 2) return q;
122997
- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
122998
- return p;
122999
- };
123000
- return [
123001
- Math.round(hue(hf + 1 / 3) * 255),
123002
- Math.round(hue(hf) * 255),
123003
- Math.round(hue(hf - 1 / 3) * 255)
123004
- ];
123005
- }
123006
- function rgbToHex(r, g, b) {
123007
- const c = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
123008
- return `#${c(r)}${c(g)}${c(b)}`;
123009
- }
123010
- /**
123011
- * Build a full-hue-wheel palette anchored at the primary-green hue.
123012
- * At frame 0 (and BREATHE_STEPS) the colour is pure primary; in between
123013
- * it sweeps through all 24 hue stops with smooth sub-step interpolation.
123014
- */
123015
- function buildBreathingPalette(primaryHex, hueStops, subSteps) {
123016
- const [r, g, b] = hexToRgb(primaryHex);
123017
- const [baseHue, sat, lit] = rgbToHsl(r, g, b);
123018
- const steps = hueStops * subSteps;
123019
- const palette = [];
123020
- for (let i = 0; i < steps; i++) {
123021
- const [rr, gg, bb] = hslToRgb((baseHue + i / steps * 360) % 360, sat, lit);
123022
- palette.push(rgbToHex(rr, gg, bb));
123023
- }
123024
- return palette;
123025
- }
123026
- var WelcomeComponent = class {
123027
- state;
123028
- colors;
123029
- ui;
123030
- breatheFrame = 0;
123031
- breatheTimer = null;
123032
- breathePalette;
123033
- borderTitle = null;
123034
- constructor(state, colors, ui) {
123035
- this.state = state;
123036
- this.colors = colors;
123037
- this.ui = ui;
123038
- this.breathePalette = buildBreathingPalette(colors.primary, HUE_STOPS, SUB_STEPS);
123039
- this.startBreathing();
123040
- }
123041
- stopBreathing() {
123042
- if (this.breatheTimer !== null) {
123043
- clearInterval(this.breatheTimer);
123044
- this.breatheTimer = null;
123045
- }
123046
- if (this.breatheFrame !== 0) {
123047
- this.breatheFrame = 0;
123048
- this.ui.requestRender();
123049
- }
122477
+ stopBreathing() {
122478
+ if (this.breatheTimer !== null) {
122479
+ clearInterval(this.breatheTimer);
122480
+ this.breatheTimer = null;
122481
+ }
122482
+ if (this.breatheFrame !== 0) {
122483
+ this.breatheFrame = 0;
122484
+ this.ui.requestRender();
122485
+ }
123050
122486
  }
123051
122487
  startBreathing() {
123052
122488
  this.breatheTimer = setInterval(() => {
@@ -123058,53 +122494,52 @@ var WelcomeComponent = class {
123058
122494
  render(width) {
123059
122495
  const breatheColor = this.breathePalette[this.breatheFrame] ?? this.colors.primary;
123060
122496
  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
122497
  const dim = chalk.hex(this.colors.textDim);
123071
122498
  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];
122499
+ const innerWidth = Math.max(10, width - 4);
122500
+ const isLoggedOut = !this.state.model;
122501
+ const frame = LOGO_FRAMES[this.breatheTimer !== null ? Math.floor(this.breatheFrame / 24) % LOGO_FRAMES.length : 0];
122502
+ const logo = [logoColor(frame[0]), logoColor(frame[1])];
123074
122503
  const activeModel = this.state.availableModels[this.state.model];
123075
122504
  const modelValue = isLoggedOut ? chalk.hex(this.colors.warning)("未设置,运行 /config") : activeModel?.displayName ?? activeModel?.model ?? this.state.model;
123076
122505
  let versionValue;
123077
122506
  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
122507
  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);
122508
+ const hintText = isLoggedOut ? "运行 /config 开始配置" : "发送 / 进入快捷菜单,/exit 保存并退出";
123087
122509
  const contentLines = [
123088
- ...headerLines,
122510
+ ...logo,
123089
122511
  "",
123090
- ...infoLines,
122512
+ labelStyle("版本:") + " " + versionValue,
122513
+ labelStyle("模型:") + " " + modelValue,
122514
+ labelStyle("目录:") + " " + this.state.workDir,
123091
122515
  "",
123092
- tipLine
122516
+ dim(hintText)
123093
122517
  ];
123094
- const borderTitle = this.borderTitle;
122518
+ const borderTitle = this.borderTitle ?? "";
122519
+ const contentWidth = width - 2;
122520
+ let topBorder;
122521
+ if (borderTitle) {
122522
+ const centerPos = Math.floor(contentWidth / 2);
122523
+ const titleText = `─ ${borderTitle} ─`;
122524
+ const titleStart = centerPos - Math.floor(visibleWidth(titleText) / 2);
122525
+ const leftDash = Math.max(0, titleStart);
122526
+ const rightDash = Math.max(0, contentWidth - leftDash - visibleWidth(titleText));
122527
+ topBorder = logoColor("╭" + "─".repeat(leftDash) + titleText + "─".repeat(rightDash) + "╮");
122528
+ } else topBorder = logoColor("╭" + "─".repeat(contentWidth) + "╮");
123095
122529
  const lines = [
123096
122530
  "",
123097
- borderTitle ? primary("╭─ " + borderTitle + " " + "─".repeat(Math.max(0, width - 5 - visibleWidth(borderTitle))) + "╮") : primary("╭" + "─".repeat(width - 2) + "╮"),
123098
- primary("│") + " ".repeat(width - 2) + primary("│")
122531
+ topBorder,
122532
+ logoColor("│") + " ".repeat(width - 2) + logoColor("│")
123099
122533
  ];
123100
122534
  for (const content of contentLines) {
123101
122535
  const truncated = truncateToWidth(content, innerWidth, "…");
123102
122536
  const vis = visibleWidth(truncated);
123103
- const rightPad = Math.max(0, innerWidth - vis);
123104
- lines.push(primary("│") + pad + truncated + " ".repeat(rightPad) + primary("│"));
122537
+ const centerPad = Math.floor((width - 1 - vis) / 2);
122538
+ const rightPad = width - 2 - vis - centerPad;
122539
+ lines.push(logoColor("│") + " ".repeat(centerPad) + truncated + " ".repeat(rightPad) + logoColor("│"));
123105
122540
  }
123106
- lines.push(primary("│") + " ".repeat(width - 2) + primary("│"));
123107
- lines.push(primary("╰" + "─".repeat(width - 2) + "╯"));
122541
+ lines.push(logoColor("│") + " ".repeat(width - 2) + logoColor("│"));
122542
+ lines.push(logoColor("╰" + "─".repeat(width - 2) + "╯"));
123108
122543
  lines.push("");
123109
122544
  return lines;
123110
122545
  }
@@ -123293,6 +122728,9 @@ var AssistantMessageComponent = class {
123293
122728
  bulletColor;
123294
122729
  lastText = "";
123295
122730
  showBullet;
122731
+ cachedWidth;
122732
+ cachedLines;
122733
+ markdownChild;
123296
122734
  constructor(markdownTheme, colors, showBullet = true) {
123297
122735
  this.markdownTheme = markdownTheme;
123298
122736
  this.bulletColor = colors.roleAssistant;
@@ -123300,19 +122738,40 @@ var AssistantMessageComponent = class {
123300
122738
  this.contentContainer = new Container();
123301
122739
  }
123302
122740
  setShowBullet(show) {
122741
+ if (this.showBullet === show) return;
123303
122742
  this.showBullet = show;
122743
+ this.cachedWidth = void 0;
122744
+ this.cachedLines = void 0;
123304
122745
  }
123305
122746
  updateContent(text) {
123306
- const displayText = text;
123307
- if (displayText === this.lastText) return;
123308
- this.lastText = displayText;
122747
+ const trimmedText = text.trim();
122748
+ const previousTrimmed = this.lastText.trim();
122749
+ if (trimmedText === previousTrimmed) {
122750
+ this.lastText = text;
122751
+ return;
122752
+ }
122753
+ this.lastText = text;
122754
+ this.cachedWidth = void 0;
122755
+ this.cachedLines = void 0;
122756
+ const markdownChild = this.markdownChild;
122757
+ if (markdownChild !== void 0 && trimmedText.startsWith(previousTrimmed) && trimmedText.length > previousTrimmed.length) {
122758
+ markdownChild.setText(trimmedText);
122759
+ return;
122760
+ }
123309
122761
  this.contentContainer.clear();
123310
- if (displayText.trim().length > 0) this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, this.markdownTheme));
122762
+ this.markdownChild = void 0;
122763
+ if (trimmedText.length > 0) {
122764
+ this.markdownChild = new Markdown(trimmedText, 0, 0, this.markdownTheme);
122765
+ this.contentContainer.addChild(this.markdownChild);
122766
+ }
123311
122767
  }
123312
122768
  invalidate() {
122769
+ this.cachedWidth = void 0;
122770
+ this.cachedLines = void 0;
123313
122771
  this.contentContainer.invalidate?.();
123314
122772
  }
123315
122773
  render(width) {
122774
+ if (this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
123316
122775
  if (this.lastText.trim().length === 0) return [];
123317
122776
  const prefix = this.showBullet ? STATUS_BULLET : " ";
123318
122777
  const contentWidth = Math.max(1, width - visibleWidth(prefix));
@@ -123322,6 +122781,8 @@ var AssistantMessageComponent = class {
123322
122781
  const p = i === 0 && this.showBullet ? chalk.hex(this.bulletColor)(STATUS_BULLET) : " ";
123323
122782
  lines.push(p + contentLines[i]);
123324
122783
  }
122784
+ this.cachedWidth = width;
122785
+ this.cachedLines = lines;
123325
122786
  return lines;
123326
122787
  }
123327
122788
  };
@@ -123330,17 +122791,29 @@ var AssistantMessageComponent = class {
123330
122791
  var BackgroundAgentStatusComponent = class {
123331
122792
  data;
123332
122793
  colors;
122794
+ bullet;
122795
+ textComponent;
122796
+ cachedWidth;
122797
+ cachedLines;
123333
122798
  constructor(data, colors) {
123334
122799
  this.data = data;
123335
122800
  this.colors = colors;
122801
+ const tone = data.phase === "started" ? colors.primary : data.phase === "completed" ? colors.success : colors.error;
122802
+ this.bullet = data.phase === "failed" ? chalk.hex(tone)(FAILURE_MARK) : chalk.hex(tone)(STATUS_BULLET);
122803
+ const text = chalk.hex(tone)(data.headline) + (data.detail !== void 0 && data.detail.length > 0 ? chalk.hex(colors.textDim)(` (${data.detail})`) : "");
122804
+ this.textComponent = new Text(text, 0, 0);
122805
+ }
122806
+ invalidate() {
122807
+ this.cachedWidth = void 0;
122808
+ this.cachedLines = void 0;
123336
122809
  }
123337
- invalidate() {}
123338
122810
  render(width) {
123339
- const tone = this.data.phase === "started" ? this.colors.primary : this.data.phase === "completed" ? this.colors.success : this.colors.error;
123340
- const bullet = this.data.phase === "failed" ? chalk.hex(tone)(FAILURE_MARK) : chalk.hex(tone)(STATUS_BULLET);
123341
- const textComponent = new Text(chalk.hex(tone)(this.data.headline) + (this.data.detail !== void 0 && this.data.detail.length > 0 ? chalk.hex(this.colors.textDim)(` (${this.data.detail})`) : ""), 0, 0);
122811
+ if (this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
123342
122812
  const contentWidth = Math.max(1, width - 2);
123343
- return ["", ...textComponent.render(contentWidth).map((line, index) => (index === 0 ? bullet : " ") + line)];
122813
+ const lines = ["", ...this.textComponent.render(contentWidth).map((line, index) => (index === 0 ? this.bullet : " ") + line)];
122814
+ this.cachedWidth = width;
122815
+ this.cachedLines = lines;
122816
+ return lines;
123344
122817
  }
123345
122818
  };
123346
122819
  //#endregion
@@ -123507,6 +122980,8 @@ var ThinkingComponent = class {
123507
122980
  spinnerFrame = 0;
123508
122981
  spinnerInterval;
123509
122982
  textComponent;
122983
+ cachedWidth;
122984
+ cachedLines;
123510
122985
  constructor(text, colors, showMarker = true, mode = "finalized", ui) {
123511
122986
  this.text = text;
123512
122987
  this.color = colors.roleThinking;
@@ -123516,17 +122991,25 @@ var ThinkingComponent = class {
123516
122991
  this.textComponent = new Text(this.styled(text), 0, 0);
123517
122992
  if (mode === "live") this.startSpinner();
123518
122993
  }
123519
- invalidate() {}
122994
+ invalidate() {
122995
+ this.cachedWidth = void 0;
122996
+ this.cachedLines = void 0;
122997
+ }
123520
122998
  setText(text) {
123521
122999
  if (this.text === text) return;
123522
123000
  this.text = text;
123001
+ this.cachedWidth = void 0;
123002
+ this.cachedLines = void 0;
123523
123003
  this.textComponent.setText(this.styled(text));
123524
123004
  }
123525
123005
  styled(text) {
123526
123006
  return chalk.hex(this.color).italic(text);
123527
123007
  }
123528
123008
  finalize() {
123009
+ if (this.mode === "finalized") return;
123529
123010
  this.mode = "finalized";
123011
+ this.cachedWidth = void 0;
123012
+ this.cachedLines = void 0;
123530
123013
  this.stopSpinner();
123531
123014
  }
123532
123015
  dispose() {
@@ -123535,8 +123018,11 @@ var ThinkingComponent = class {
123535
123018
  setExpanded(expanded) {
123536
123019
  if (this.expanded === expanded) return;
123537
123020
  this.expanded = expanded;
123021
+ this.cachedWidth = void 0;
123022
+ this.cachedLines = void 0;
123538
123023
  }
123539
123024
  render(width) {
123025
+ if (this.mode === "finalized" && this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
123540
123026
  const contentWidth = Math.max(1, width - 2);
123541
123027
  const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : [""];
123542
123028
  if (this.mode === "live") {
@@ -123552,10 +123038,16 @@ var ThinkingComponent = class {
123552
123038
  const p = i === 0 && this.showMarker ? chalk.hex(this.color)(STATUS_BULLET) : " ";
123553
123039
  rendered.push(p + contentLines[i]);
123554
123040
  }
123555
- if (this.expanded || contentLines.length <= 2) return rendered;
123041
+ if (this.expanded || contentLines.length <= 2) {
123042
+ this.cachedWidth = width;
123043
+ this.cachedLines = rendered;
123044
+ return rendered;
123045
+ }
123556
123046
  const truncated = rendered.slice(0, 3);
123557
123047
  const remaining = contentLines.length - 2;
123558
123048
  truncated.push(" " + chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`));
123049
+ this.cachedWidth = width;
123050
+ this.cachedLines = truncated;
123559
123051
  return truncated;
123560
123052
  }
123561
123053
  startSpinner() {
@@ -123944,6 +123436,26 @@ function trimTrailingEmptyLines(lines) {
123944
123436
  return lines.slice(0, end);
123945
123437
  }
123946
123438
  /**
123439
+ * Returns the tail of `text` whose UTF-8 byte length is at most `maxBytes`.
123440
+ * Iterates by Unicode code points so multi-byte characters and surrogate
123441
+ * pairs are never split.
123442
+ */
123443
+ function truncateTailBytes(text, maxBytes) {
123444
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
123445
+ const chars = Array.from(text);
123446
+ let start = chars.length;
123447
+ let bytes = 0;
123448
+ for (let i = chars.length - 1; i >= 0; i--) {
123449
+ const char = chars[i];
123450
+ if (char === void 0) continue;
123451
+ const charBytes = Buffer.byteLength(char, "utf8");
123452
+ if (bytes + charBytes > maxBytes) break;
123453
+ bytes += charBytes;
123454
+ start = i;
123455
+ }
123456
+ return chars.slice(start).join("");
123457
+ }
123458
+ /**
123947
123459
  * Component that renders tool output with wrap-aware line truncation.
123948
123460
  * Uses pi-tui's Text component to compute actual visual wrapped lines,
123949
123461
  * then caps at PREVIEW_LINES. This handles long single-line output (e.g.
@@ -123960,7 +123472,8 @@ var TruncatedOutputComponent = class {
123960
123472
  this.hintFormatter = options.hintFormatter;
123961
123473
  const tint = options.isError ? chalk.hex(options.colors.error) : chalk.dim;
123962
123474
  const cleaned = trimTrailingEmptyLines(output.split("\n")).join("\n");
123963
- this.textComponent = new Text(tint(cleaned), 2, 0);
123475
+ const truncated = options.maxBytes === void 0 ? cleaned : truncateTailBytes(cleaned, options.maxBytes);
123476
+ this.textComponent = new Text(tint(truncated), 2, 0);
123964
123477
  }
123965
123478
  invalidate() {
123966
123479
  this.textComponent.invalidate();
@@ -124006,6 +123519,7 @@ var ShellExecutionComponent = class extends Container {
124006
123519
  isError: result.is_error ?? false,
124007
123520
  colors,
124008
123521
  maxLines: previewLines,
123522
+ maxBytes: MAX_SHELL_OUTPUT_BYTES,
124009
123523
  hintFormatter: (remaining) => `...(还有 ${String(remaining)} 行,按 ctrl+o 展开)`
124010
123524
  }));
124011
123525
  }
@@ -124600,6 +124114,7 @@ var ToolCallComponent = class ToolCallComponent extends Container {
124600
124114
  subagentError;
124601
124115
  streamingProgressTimer;
124602
124116
  subagentElapsedTimer;
124117
+ disposed = false;
124603
124118
  subagentStartedAtMs;
124604
124119
  subagentEndedAtMs;
124605
124120
  progressLines = [];
@@ -124679,6 +124194,8 @@ var ToolCallComponent = class ToolCallComponent extends Container {
124679
124194
  this.ui?.requestRender();
124680
124195
  }
124681
124196
  dispose() {
124197
+ if (this.disposed) return;
124198
+ this.disposed = true;
124682
124199
  this.stopStreamingProgressTimer();
124683
124200
  this.stopSubagentElapsedTimer();
124684
124201
  }
@@ -124834,6 +124351,10 @@ var ToolCallComponent = class ToolCallComponent extends Container {
124834
124351
  }
124835
124352
  if (this.ui === void 0 || this.streamingProgressTimer !== void 0) return;
124836
124353
  this.streamingProgressTimer = setInterval(() => {
124354
+ if (this.disposed) {
124355
+ this.stopStreamingProgressTimer();
124356
+ return;
124357
+ }
124837
124358
  if (!this.isStreamingEditPreview()) {
124838
124359
  this.stopStreamingProgressTimer();
124839
124360
  return;
@@ -124855,6 +124376,10 @@ var ToolCallComponent = class ToolCallComponent extends Container {
124855
124376
  }
124856
124377
  if (this.ui === void 0 || this.subagentElapsedTimer !== void 0) return;
124857
124378
  this.subagentElapsedTimer = setInterval(() => {
124379
+ if (this.disposed) {
124380
+ this.stopSubagentElapsedTimer();
124381
+ return;
124382
+ }
124858
124383
  const latestPhase = this.getDerivedSubagentPhase();
124859
124384
  if (latestPhase !== "spawning" && latestPhase !== "running") {
124860
124385
  this.stopSubagentElapsedTimer();
@@ -125544,6 +125069,8 @@ var UserMessageComponent = class {
125544
125069
  textComponent;
125545
125070
  spacerComponent;
125546
125071
  imageThumbnails;
125072
+ cachedWidth;
125073
+ cachedLines;
125547
125074
  constructor(text, colors, images) {
125548
125075
  this.color = colors.roleUser;
125549
125076
  this.textComponent = new Text(chalk.hex(colors.roleUser).bold(text), 0, 0);
@@ -125551,10 +125078,13 @@ var UserMessageComponent = class {
125551
125078
  this.imageThumbnails = images?.map((img) => new ImageThumbnail(img, colors)) ?? [];
125552
125079
  }
125553
125080
  invalidate() {
125081
+ this.cachedWidth = void 0;
125082
+ this.cachedLines = void 0;
125554
125083
  this.textComponent.invalidate();
125555
125084
  for (const img of this.imageThumbnails) img.invalidate?.();
125556
125085
  }
125557
125086
  render(width) {
125087
+ if (this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
125558
125088
  const border = chalk.hex(this.color).bold(USER_MESSAGE_BULLET);
125559
125089
  const borderWidth = visibleWidth(border);
125560
125090
  const contentWidth = Math.max(1, width - borderWidth);
@@ -125569,6 +125099,8 @@ var UserMessageComponent = class {
125569
125099
  const imageLines = thumbnail.render(contentWidth);
125570
125100
  for (const line of imageLines) lines.push(" ".repeat(borderWidth) + line);
125571
125101
  }
125102
+ this.cachedWidth = width;
125103
+ this.cachedLines = lines;
125572
125104
  return lines;
125573
125105
  }
125574
125106
  };
@@ -127152,7 +126684,7 @@ async function confirmUninstall(host, label) {
127152
126684
  * restores the editor. The question and answer are never recorded in the
127153
126685
  * main conversation history.
127154
126686
  */
127155
- const SPINNER_FRAMES = [
126687
+ const SPINNER_FRAMES$1 = [
127156
126688
  "⠋",
127157
126689
  "⠙",
127158
126690
  "⠹",
@@ -127212,7 +126744,7 @@ var BtwOverlayComponent = class extends Container {
127212
126744
  startSpinner() {
127213
126745
  this.spinnerFrame = 0;
127214
126746
  this.spinnerInterval = setInterval(() => {
127215
- this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
126747
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES$1.length;
127216
126748
  this.requestRender();
127217
126749
  }, 80);
127218
126750
  }
@@ -127232,7 +126764,7 @@ var BtwOverlayComponent = class extends Container {
127232
126764
  lines.push(chalk.hex(c.primary)("/btw") + chalk.hex(c.textMuted)(" — ") + chalk.hex(c.text)(truncated));
127233
126765
  lines.push("");
127234
126766
  if (this.status === "loading") {
127235
- const spinner = SPINNER_FRAMES[this.spinnerFrame];
126767
+ const spinner = SPINNER_FRAMES$1[this.spinnerFrame];
127236
126768
  lines.push(chalk.hex(c.textMuted)(`${spinner} Answering…`));
127237
126769
  } else if (this.status === "done" && this.markdown !== void 0) {
127238
126770
  const contentWidth = Math.max(20, width - 2);
@@ -130768,6 +130300,7 @@ var StreamingUIController = class {
130768
130300
  const tc = this._pendingToolComponents.get(toolCallId);
130769
130301
  if (tc) {
130770
130302
  tc.setResult(result);
130303
+ tc.dispose();
130771
130304
  this._pendingToolComponents.delete(toolCallId);
130772
130305
  state.ui.requestRender();
130773
130306
  return;
@@ -131267,1119 +130800,1687 @@ var TasksBrowserApp = class extends Container {
131267
130800
  }
131268
130801
  this.invalidate();
131269
130802
  }
131270
- syncSelectionFromProps() {
131271
- if (this.sortedVisible.length === 0) {
131272
- this.selectedIndex = 0;
131273
- this.listScroll = 0;
131274
- return;
131275
- }
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
- }
130803
+ syncSelectionFromProps() {
130804
+ if (this.sortedVisible.length === 0) {
130805
+ this.selectedIndex = 0;
130806
+ this.listScroll = 0;
130807
+ return;
130808
+ }
130809
+ if (this.props.selectedTaskId !== void 0) {
130810
+ const idx = this.sortedVisible.findIndex((t) => t.taskId === this.props.selectedTaskId);
130811
+ if (idx !== -1) {
130812
+ this.selectedIndex = idx;
130813
+ return;
130814
+ }
130815
+ }
130816
+ if (this.selectedIndex >= this.sortedVisible.length) this.selectedIndex = this.sortedVisible.length - 1;
130817
+ }
130818
+ clearPendingStop() {
130819
+ this.pendingStopTaskId = void 0;
130820
+ if (this.pendingStopTimer !== void 0) {
130821
+ clearTimeout(this.pendingStopTimer);
130822
+ this.pendingStopTimer = void 0;
130823
+ }
130824
+ }
130825
+ emitSelect() {
130826
+ const task = this.sortedVisible[this.selectedIndex];
130827
+ if (task) this.props.onSelect(task.taskId);
130828
+ }
130829
+ handleInput(data) {
130830
+ const k = printableChar(data);
130831
+ if (this.pendingStopTaskId !== void 0) {
130832
+ if (k === "y" || k === "Y") {
130833
+ const taskId = this.pendingStopTaskId;
130834
+ this.clearPendingStop();
130835
+ this.props.onStopConfirmed(taskId);
130836
+ this.invalidate();
130837
+ return;
130838
+ }
130839
+ this.clearPendingStop();
130840
+ this.invalidate();
130841
+ return;
130842
+ }
130843
+ if (matchesKey(data, Key.escape) || k === "q" || k === "Q") {
130844
+ this.props.onCancel();
130845
+ return;
130846
+ }
130847
+ if (matchesKey(data, Key.up) || k === "k") {
130848
+ if (this.sortedVisible.length === 0) return;
130849
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
130850
+ this.emitSelect();
130851
+ this.invalidate();
130852
+ return;
130853
+ }
130854
+ if (matchesKey(data, Key.down) || k === "j") {
130855
+ if (this.sortedVisible.length === 0) return;
130856
+ this.selectedIndex = Math.min(this.sortedVisible.length - 1, this.selectedIndex + 1);
130857
+ this.emitSelect();
130858
+ this.invalidate();
130859
+ return;
130860
+ }
130861
+ if (matchesKey(data, Key.tab) || k === " ") {
130862
+ this.props.onToggleFilter();
130863
+ return;
130864
+ }
130865
+ if (k === "r" || k === "R") {
130866
+ this.props.onRefresh();
130867
+ return;
130868
+ }
130869
+ if (k === "s" || k === "S") {
130870
+ const task = this.sortedVisible[this.selectedIndex];
130871
+ if (task === void 0) return;
130872
+ if (isTerminal(task.status)) {
130873
+ this.props.onStopIgnored?.(task.taskId, "terminal");
130874
+ return;
130875
+ }
130876
+ this.pendingStopTaskId = task.taskId;
130877
+ this.pendingStopTimer = setTimeout(() => {
130878
+ this.clearPendingStop();
130879
+ this.invalidate();
130880
+ }, STOP_CONFIRM_TIMEOUT_MS);
130881
+ this.invalidate();
130882
+ return;
130883
+ }
130884
+ if (k === "o" || k === "O" || matchesKey(data, Key.enter)) {
130885
+ const task = this.sortedVisible[this.selectedIndex];
130886
+ if (task) this.props.onOpenOutput(task.taskId);
130887
+ return;
130888
+ }
130889
+ }
130890
+ /**
130891
+ * Render the entire screen as `terminal.rows` lines of `width` cols.
130892
+ * Layout: header(1) + body(rows-2) + footer(1).
130893
+ */
130894
+ render(width) {
130895
+ const rows = Math.max(1, this.terminal.rows);
130896
+ if (width < MIN_WIDTH || rows < MIN_HEIGHT) return this.renderTooSmall(width, rows);
130897
+ const header = this.renderHeader(width);
130898
+ const footer = this.renderFooter(width);
130899
+ const bodyHeight = rows - 2;
130900
+ const listWidth = Math.max(LIST_COL_MIN, Math.min(LIST_COL_MAX, Math.floor(width * LIST_COL_RATIO)));
130901
+ const rightWidth = width - listWidth;
130902
+ const listFrame = this.renderListFrame(listWidth, bodyHeight);
130903
+ const rightFrames = this.renderRightStack(rightWidth, bodyHeight);
130904
+ const lines = [header];
130905
+ for (let i = 0; i < bodyHeight; i++) lines.push((listFrame[i] ?? " ".repeat(listWidth)) + (rightFrames[i] ?? " ".repeat(rightWidth)));
130906
+ lines.push(footer);
130907
+ return lines;
130908
+ }
130909
+ renderHeader(width) {
130910
+ const colors = this.props.colors;
130911
+ const title = chalk.hex(colors.primary).bold(" TASK BROWSER ");
130912
+ const filterText = chalk.hex(colors.textMuted)(` filter=${this.props.filter === "all" ? "ALL" : "ACTIVE"} `);
130913
+ const counts = countByStatus(this.props.tasks);
130914
+ const countSegments = [];
130915
+ if (counts.running > 0) countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} 运行中 `));
130916
+ if (counts.awaiting > 0) countSegments.push(chalk.hex(colors.warning)(` ${String(counts.awaiting)} 等待中 `));
130917
+ if (counts.completed > 0) countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} 已完成 `));
130918
+ if (counts.terminalFailed > 0) countSegments.push(chalk.hex(colors.error)(` ${String(counts.terminalFailed)} 已中断 `));
130919
+ const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} 总计 `);
130920
+ return fitExactly$1(title + filterText + countSegments.join("") + totals, width);
130921
+ }
130922
+ renderFooter(width) {
130923
+ const colors = this.props.colors;
130924
+ const key = (text) => chalk.hex(colors.primary).bold(text);
130925
+ const dim = (text) => chalk.hex(colors.textMuted)(text);
130926
+ if (this.pendingStopTaskId !== void 0) {
130927
+ const warn = (text) => chalk.hex(colors.warning).bold(text);
130928
+ return fitExactly$1(` ${warn("停止")} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ${key("Y")} ${dim("确认")} ${key("N")} ${dim("取消")} `, width);
130929
+ }
130930
+ const left = [
130931
+ ` ${key("↑↓")} ${dim("选择")}`,
130932
+ `${key("Enter/O")} ${dim("输出")}`,
130933
+ `${key("S")} ${dim("停止")}`,
130934
+ `${key("R")} ${dim("刷新")}`,
130935
+ `${key("Tab")} ${dim("筛选")}`,
130936
+ `${key("Q/Esc")} ${dim("退出")} `
130937
+ ].join(" ");
130938
+ const flash = this.props.flashMessage;
130939
+ if (flash !== void 0 && flash.length > 0) {
130940
+ const flashStyled = chalk.hex(colors.warning)(` ${flash} `);
130941
+ const total = visibleWidth(left) + visibleWidth(flashStyled);
130942
+ if (total <= width) return left + " ".repeat(width - total) + flashStyled;
130943
+ }
130944
+ return fitExactly$1(left, width);
130945
+ }
130946
+ /**
130947
+ * Render a framed box: `┌─ Title ─┐` top, `│ <content> │` sides, `└─┘`
130948
+ * bottom. Result is exactly `width × height` cells. `content` is a
130949
+ * pre-rendered array of inner-width-sized lines; extra rows are padded.
130950
+ */
130951
+ renderFrame(title, content, width, height) {
130952
+ if (height < 2 || width < 4) {
130953
+ const out = [];
130954
+ for (let i = 0; i < height; i++) out.push(" ".repeat(width));
130955
+ return out;
130956
+ }
130957
+ const stroke = this.props.colors.primary;
130958
+ const innerWidth = width - 2;
130959
+ const innerHeight = height - 2;
130960
+ const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title);
130961
+ const titleWidth = visibleWidth(titleStyled);
130962
+ const titleSegmentWidth = visibleWidth(`─ ${titleStyled} `);
130963
+ const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth);
130964
+ const topMid = titleWidth > 0 && titleSegmentWidth <= innerWidth ? chalk.hex(stroke)("─ ") + titleStyled + " " + chalk.hex(stroke)("─".repeat(remainingDashes)) : chalk.hex(stroke)("─".repeat(innerWidth));
130965
+ const top = chalk.hex(stroke)("┌") + topMid + chalk.hex(stroke)("┐");
130966
+ const bottom = chalk.hex(stroke)("└" + "─".repeat(innerWidth) + "┘");
130967
+ const lines = [top];
130968
+ for (let i = 0; i < innerHeight; i++) {
130969
+ const inner = content[i] ?? "";
130970
+ lines.push(chalk.hex(stroke)("│") + fitExactly$1(inner, innerWidth) + chalk.hex(stroke)("│"));
130971
+ }
130972
+ lines.push(bottom);
130973
+ return lines;
130974
+ }
130975
+ renderListFrame(width, height) {
130976
+ const title = `Tasks [${this.props.filter}]`;
130977
+ const innerHeight = Math.max(0, height - 2);
130978
+ if (this.sortedVisible.length === 0) {
130979
+ const empty = this.props.filter === "active" ? "无活跃任务。Tab = 显示全部。" : "本会话无后台任务。";
130980
+ const lines = [chalk.hex(this.props.colors.textMuted)(empty)];
130981
+ while (lines.length < innerHeight) lines.push("");
130982
+ return this.renderFrame(title, lines, width, height);
130983
+ }
130984
+ this.adjustScroll(innerHeight);
130985
+ const start = this.listScroll;
130986
+ const window = this.sortedVisible.slice(start, start + innerHeight);
130987
+ const innerWidth = width - 2;
130988
+ const lines = [];
130989
+ for (const [vi, task] of window.entries()) {
130990
+ const index = start + vi;
130991
+ lines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth));
130992
+ }
130993
+ while (lines.length < innerHeight) lines.push("");
130994
+ return this.renderFrame(title, lines, width, height);
130995
+ }
130996
+ renderListRow(task, selected, innerWidth) {
130997
+ const colors = this.props.colors;
130998
+ const pointer = selected ? "> " : " ";
130999
+ const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer);
131000
+ const idColor = selected ? colors.primary : task.taskId.startsWith("agent-") ? colors.success : colors.accent;
131001
+ const idText = selected ? chalk.hex(idColor).bold(task.taskId) : chalk.hex(idColor)(task.taskId);
131002
+ const idPad = " ".repeat(Math.max(0, 17 - task.taskId.length));
131003
+ const status = STATUS_LABEL[task.status];
131004
+ const prefix = `${pointerStyled}${idText}${idPad} ${chalk.hex(statusColor(colors, task.status))(status)}`;
131005
+ const prefixWidth = visibleWidth(prefix);
131006
+ const descBudget = Math.max(0, innerWidth - prefixWidth - 1);
131007
+ if (descBudget < 4) return fitExactly$1(prefix, innerWidth);
131008
+ const desc = truncateToWidth(singleLine$2(task.description) || singleLine$2(task.command) || "(no description)", descBudget, ELLIPSIS$3);
131009
+ return fitExactly$1(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth);
131010
+ }
131011
+ adjustScroll(visibleRows) {
131012
+ if (visibleRows <= 0) {
131013
+ this.listScroll = 0;
131014
+ return;
131015
+ }
131016
+ if (this.selectedIndex < this.listScroll) this.listScroll = this.selectedIndex;
131017
+ else if (this.selectedIndex >= this.listScroll + visibleRows) this.listScroll = this.selectedIndex - visibleRows + 1;
131018
+ const maxScroll = Math.max(0, this.sortedVisible.length - visibleRows);
131019
+ if (this.listScroll < 0) this.listScroll = 0;
131020
+ if (this.listScroll > maxScroll) this.listScroll = maxScroll;
131021
+ }
131022
+ renderRightStack(width, height) {
131023
+ const detailHeight = Math.max(8, Math.min(Math.floor(height * .4), height - 5));
131024
+ const previewHeight = height - detailHeight;
131025
+ return [...this.renderDetailFrame(width, detailHeight), ...this.renderPreviewFrame(width, previewHeight)];
131026
+ }
131027
+ renderDetailFrame(width, height) {
131028
+ const colors = this.props.colors;
131029
+ const innerHeight = Math.max(0, height - 2);
131030
+ const task = this.sortedVisible[this.selectedIndex];
131031
+ if (task === void 0) {
131032
+ const lines = [chalk.hex(colors.textMuted)("从列表中选择一个任务。")];
131033
+ while (lines.length < innerHeight) lines.push("");
131034
+ return this.renderFrame("详情", lines, width, height);
131035
+ }
131036
+ const label = (text) => chalk.hex(colors.textMuted)(text.padEnd(14));
131037
+ const value = (text) => chalk.hex(colors.text)(text);
131038
+ const lines = [
131039
+ `${label("任务 ID:")}${value(task.taskId)}`,
131040
+ `${label("状态:")}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`,
131041
+ `${label("描述:")}${value(singleLine$2(task.description) || "—")}`
131042
+ ];
131043
+ if (task.command && task.command !== task.description) lines.push(`${label("命令:")}${value(singleLine$2(task.command))}`);
131044
+ const timing = task.status === "running" || task.status === "awaiting_approval" ? `运行中 ${formatRelativeTime$2(task.startedAt)}` : task.endedAt !== null && task.endedAt !== void 0 ? `已完成 ${formatRelativeTime$2(task.endedAt)}` : "";
131045
+ if (timing.length > 0) lines.push(`${label("时间:")}${chalk.hex(colors.textMuted)(timing)}`);
131046
+ if (task.pid > 0) lines.push(`${label("进程 ID:")}${chalk.hex(colors.textMuted)(String(task.pid))}`);
131047
+ if (task.exitCode !== null && task.exitCode !== void 0) lines.push(`${label("退出码:")}${chalk.hex(colors.textMuted)(String(task.exitCode))}`);
131048
+ if (task.stopReason !== void 0 && task.stopReason.length > 0) lines.push(`${label("停止原因:")}${chalk.hex(colors.textMuted)(task.stopReason)}`);
131049
+ if (task.timedOut === true) lines.push(`${label("已超时:")}${chalk.hex(colors.warning)("是")}`);
131050
+ if (task.approvalReason !== void 0 && task.approvalReason.length > 0) lines.push(`${label("等待中:")}${chalk.hex(colors.warning)(singleLine$2(task.approvalReason))}`);
131051
+ while (lines.length < innerHeight) lines.push("");
131052
+ return this.renderFrame("详情", lines, width, height);
131053
+ }
131054
+ renderPreviewFrame(width, height) {
131055
+ const colors = this.props.colors;
131056
+ const innerHeight = Math.max(0, height - 2);
131057
+ if (this.sortedVisible[this.selectedIndex] === void 0) {
131058
+ const lines = [chalk.hex(colors.textMuted)("No task selected.")];
131059
+ while (lines.length < innerHeight) lines.push("");
131060
+ return this.renderFrame("Preview Output", lines, width, height);
131282
131061
  }
131283
- if (this.selectedIndex >= this.sortedVisible.length) this.selectedIndex = this.sortedVisible.length - 1;
131062
+ let body;
131063
+ if (this.props.tailLoading) body = "[loading…]";
131064
+ else if (this.props.tailOutput === void 0 || this.props.tailOutput.length === 0) body = "[no output captured]";
131065
+ else body = this.props.tailOutput;
131066
+ const styled = body.split("\n").slice(-innerHeight).map((line) => chalk.hex(colors.textDim)(line));
131067
+ while (styled.length < innerHeight) styled.push("");
131068
+ return this.renderFrame("Preview Output", styled, width, height);
131284
131069
  }
131285
- clearPendingStop() {
131286
- this.pendingStopTaskId = void 0;
131287
- if (this.pendingStopTimer !== void 0) {
131288
- clearTimeout(this.pendingStopTimer);
131289
- this.pendingStopTimer = void 0;
131290
- }
131070
+ renderTooSmall(width, rows) {
131071
+ const lines = [];
131072
+ const msg = chalk.hex(this.props.colors.error)(`Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`);
131073
+ lines.push(fitExactly$1(msg, width));
131074
+ for (let i = 1; i < rows; i++) lines.push(" ".repeat(width));
131075
+ return lines;
131291
131076
  }
131292
- emitSelect() {
131293
- const task = this.sortedVisible[this.selectedIndex];
131294
- if (task) this.props.onSelect(task.taskId);
131077
+ };
131078
+ //#endregion
131079
+ //#region src/tui/controllers/tasks-browser.ts
131080
+ var TasksBrowserController = class {
131081
+ host;
131082
+ constructor(host) {
131083
+ this.host = host;
131295
131084
  }
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;
131305
- }
131306
- this.clearPendingStop();
131307
- this.invalidate();
131085
+ async show() {
131086
+ const { state } = this.host;
131087
+ if (state.tasksBrowser !== void 0) return;
131088
+ const session = this.host.session;
131089
+ if (session === void 0) {
131090
+ this.host.showError("没有活动会话。");
131308
131091
  return;
131309
131092
  }
131310
- if (matchesKey(data, Key.escape) || k === "q" || k === "Q") {
131311
- this.props.onCancel();
131093
+ let tasks = [];
131094
+ try {
131095
+ tasks = await session.listBackgroundTasks({ activeOnly: false });
131096
+ } catch (error) {
131097
+ this.host.showError(`加载任务失败: ${error instanceof Error ? error.message : String(error)}`);
131312
131098
  return;
131313
131099
  }
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();
131100
+ if (state.tasksBrowser !== void 0) return;
131101
+ const filter = "all";
131102
+ const selectedTaskId = this.pickInitialSelection(tasks, filter);
131103
+ const component = new TasksBrowserApp({
131104
+ tasks,
131105
+ filter,
131106
+ selectedTaskId,
131107
+ tailOutput: void 0,
131108
+ tailLoading: false,
131109
+ flashMessage: void 0,
131110
+ colors: state.theme.colors,
131111
+ ...this.buildCallbacks()
131112
+ }, state.terminal);
131113
+ const savedChildren = [...state.ui.children];
131114
+ state.ui.clear();
131115
+ state.ui.addChild(component);
131116
+ state.ui.setFocus(component);
131117
+ state.ui.requestRender(true);
131118
+ const pollTimer = setInterval(() => {
131119
+ this.refresh({ silent: true });
131120
+ }, 1e3);
131121
+ this.host.setTasksBrowser({
131122
+ component,
131123
+ savedChildren,
131124
+ filter,
131125
+ selectedTaskId,
131126
+ tailOutput: void 0,
131127
+ tailLoading: false,
131128
+ tailRequestId: 0,
131129
+ flashMessage: void 0,
131130
+ flashTimer: void 0,
131131
+ pollTimer,
131132
+ viewer: void 0
131133
+ });
131134
+ if (selectedTaskId !== void 0) this.loadTail(selectedTaskId);
131135
+ }
131136
+ close() {
131137
+ const { state } = this.host;
131138
+ const browser = state.tasksBrowser;
131139
+ if (browser === void 0) return;
131140
+ if (browser.viewer !== void 0) this.closeOutputViewer();
131141
+ if (browser.pollTimer !== void 0) clearInterval(browser.pollTimer);
131142
+ if (browser.flashTimer !== void 0) clearTimeout(browser.flashTimer);
131143
+ state.ui.clear();
131144
+ for (const child of browser.savedChildren) state.ui.addChild(child);
131145
+ this.host.setTasksBrowser(void 0);
131146
+ state.ui.setFocus(state.editor);
131147
+ state.ui.requestRender(true);
131148
+ }
131149
+ repaint() {
131150
+ if (this.host.state.tasksBrowser === void 0) return;
131151
+ const tasks = [...this.host.backgroundTasks.values()];
131152
+ this.pushProps(tasks);
131153
+ }
131154
+ async refreshOutputViewer(opts = {}) {
131155
+ const { state } = this.host;
131156
+ const browser = state.tasksBrowser;
131157
+ const viewer = browser?.viewer;
131158
+ if (browser === void 0 || viewer === void 0) return;
131159
+ const session = this.host.session;
131160
+ if (session === void 0) return;
131161
+ const myRefreshId = ++viewer.refreshId;
131162
+ let output;
131163
+ try {
131164
+ output = await session.getBackgroundTaskOutput(viewer.taskId);
131165
+ } catch (error) {
131166
+ if (!opts.silent) {
131167
+ const message = error instanceof Error ? error.message : String(error);
131168
+ this.flash(`输出刷新失败: ${message}`);
131169
+ }
131319
131170
  return;
131320
131171
  }
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();
131172
+ const current = state.tasksBrowser?.viewer;
131173
+ if (current === void 0 || current !== viewer || current.refreshId !== myRefreshId) return;
131174
+ if (output === viewer.output) return;
131175
+ viewer.output = output;
131176
+ const info = this.host.backgroundTasks.get(viewer.taskId);
131177
+ viewer.component.setProps({
131178
+ taskId: viewer.taskId,
131179
+ info,
131180
+ output,
131181
+ colors: state.theme.colors,
131182
+ onClose: () => {
131183
+ this.closeOutputViewer();
131184
+ }
131185
+ });
131186
+ state.ui.requestRender();
131187
+ }
131188
+ pickInitialSelection(tasks, filter) {
131189
+ const candidates = filter === "all" ? tasks : tasks.filter((t) => t.status !== "completed" && t.status !== "failed" && t.status !== "killed" && t.status !== "lost");
131190
+ if (candidates.length === 0) return void 0;
131191
+ return candidates.find((t) => t.status === "running" || t.status === "awaiting_approval")?.taskId ?? candidates[0].taskId;
131192
+ }
131193
+ async refresh(opts = {}) {
131194
+ const { state } = this.host;
131195
+ const browser = state.tasksBrowser;
131196
+ if (browser === void 0) return;
131197
+ const session = this.host.session;
131198
+ if (session === void 0) return;
131199
+ let tasks;
131200
+ try {
131201
+ tasks = await session.listBackgroundTasks({ activeOnly: false });
131202
+ } catch (error) {
131203
+ if (!opts.silent) this.flash(`刷新失败: ${error instanceof Error ? error.message : String(error)}`);
131326
131204
  return;
131327
131205
  }
131328
- if (matchesKey(data, Key.tab) || k === " ") {
131329
- this.props.onToggleFilter();
131206
+ if (state.tasksBrowser !== browser) return;
131207
+ this.pushProps(tasks);
131208
+ }
131209
+ pushProps(tasks) {
131210
+ const browser = this.host.state.tasksBrowser;
131211
+ if (browser === void 0) return;
131212
+ browser.component.setProps({
131213
+ tasks,
131214
+ filter: browser.filter,
131215
+ selectedTaskId: browser.selectedTaskId,
131216
+ tailOutput: browser.tailOutput,
131217
+ tailLoading: browser.tailLoading,
131218
+ flashMessage: browser.flashMessage,
131219
+ colors: this.host.state.theme.colors,
131220
+ ...this.buildCallbacks()
131221
+ });
131222
+ this.host.state.ui.requestRender();
131223
+ }
131224
+ buildCallbacks() {
131225
+ return {
131226
+ onSelect: (taskId) => {
131227
+ this.handleSelect(taskId);
131228
+ },
131229
+ onToggleFilter: () => {
131230
+ this.handleToggleFilter();
131231
+ },
131232
+ onRefresh: () => {
131233
+ this.handleRefresh();
131234
+ },
131235
+ onCancel: () => {
131236
+ this.close();
131237
+ },
131238
+ onStopConfirmed: (taskId) => {
131239
+ this.handleStop(taskId);
131240
+ },
131241
+ onOpenOutput: (taskId) => {
131242
+ this.handleOpenOutput(taskId);
131243
+ },
131244
+ onStopIgnored: (taskId, reason) => {
131245
+ if (reason === "terminal") this.flash(`${taskId} 已是终止状态 — 无需停止。`);
131246
+ }
131247
+ };
131248
+ }
131249
+ handleSelect(taskId) {
131250
+ const browser = this.host.state.tasksBrowser;
131251
+ if (browser === void 0) return;
131252
+ if (browser.selectedTaskId === taskId) return;
131253
+ browser.selectedTaskId = taskId;
131254
+ browser.tailOutput = void 0;
131255
+ browser.tailLoading = true;
131256
+ this.repaint();
131257
+ this.loadTail(taskId);
131258
+ }
131259
+ handleToggleFilter() {
131260
+ const browser = this.host.state.tasksBrowser;
131261
+ if (browser === void 0) return;
131262
+ browser.filter = browser.filter === "all" ? "active" : "all";
131263
+ this.repaint();
131264
+ }
131265
+ handleRefresh() {
131266
+ this.flash("正在刷新…", 600);
131267
+ this.refresh();
131268
+ }
131269
+ async handleStop(taskId) {
131270
+ if (this.host.state.tasksBrowser === void 0) return;
131271
+ const session = this.host.session;
131272
+ if (session === void 0) {
131273
+ this.flash("没有活动会话。");
131330
131274
  return;
131331
131275
  }
131332
- if (k === "r" || k === "R") {
131333
- this.props.onRefresh();
131334
- return;
131276
+ this.flash(`正在停止 ${taskId}…`, 1500);
131277
+ try {
131278
+ await session.stopBackgroundTask(taskId, { reason: "用户发起停止" });
131279
+ await this.refresh({ silent: true });
131280
+ } catch (error) {
131281
+ const message = error instanceof Error ? error.message : String(error);
131282
+ this.flash(`停止失败: ${message}`);
131335
131283
  }
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;
131342
- }
131343
- this.pendingStopTaskId = task.taskId;
131344
- this.pendingStopTimer = setTimeout(() => {
131345
- this.clearPendingStop();
131346
- this.invalidate();
131347
- }, STOP_CONFIRM_TIMEOUT_MS);
131348
- this.invalidate();
131284
+ }
131285
+ async handleOpenOutput(taskId) {
131286
+ const { state } = this.host;
131287
+ const browser = state.tasksBrowser;
131288
+ if (browser === void 0) return;
131289
+ if (browser.viewer !== void 0) return;
131290
+ const session = this.host.session;
131291
+ if (session === void 0) {
131292
+ this.flash("没有活动会话。");
131349
131293
  return;
131350
131294
  }
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);
131295
+ let output;
131296
+ try {
131297
+ output = await session.getBackgroundTaskOutput(taskId);
131298
+ } catch (error) {
131299
+ const message = error instanceof Error ? error.message : String(error);
131300
+ this.flash(`无法打开输出: ${message}`);
131354
131301
  return;
131355
131302
  }
131303
+ const current = state.tasksBrowser;
131304
+ if (current === void 0 || current !== browser) return;
131305
+ const viewer = new TaskOutputViewer({
131306
+ taskId,
131307
+ info: this.host.backgroundTasks.get(taskId),
131308
+ output,
131309
+ colors: state.theme.colors,
131310
+ onClose: () => {
131311
+ this.closeOutputViewer();
131312
+ }
131313
+ }, state.terminal);
131314
+ const savedBrowserChildren = [...state.ui.children];
131315
+ state.ui.clear();
131316
+ state.ui.addChild(viewer);
131317
+ state.ui.setFocus(viewer);
131318
+ state.ui.requestRender(true);
131319
+ const pollTimer = setInterval(() => {
131320
+ this.refreshOutputViewer({ silent: true });
131321
+ }, 1e3);
131322
+ browser.viewer = {
131323
+ component: viewer,
131324
+ savedChildren: savedBrowserChildren,
131325
+ taskId,
131326
+ output,
131327
+ refreshId: 0,
131328
+ pollTimer
131329
+ };
131356
131330
  }
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;
131331
+ loadTail(taskId) {
131332
+ const { state } = this.host;
131333
+ const browser = state.tasksBrowser;
131334
+ if (browser === void 0) return;
131335
+ const session = this.host.session;
131336
+ if (session === void 0) {
131337
+ browser.tailLoading = false;
131338
+ this.repaint();
131339
+ return;
131340
+ }
131341
+ const requestId = ++browser.tailRequestId;
131342
+ session.getBackgroundTaskOutput(taskId, { tail: 4e3 }).then((output) => {
131343
+ const current = state.tasksBrowser;
131344
+ if (current === void 0) return;
131345
+ if (current !== browser || current.tailRequestId !== requestId) return;
131346
+ if (current.selectedTaskId !== taskId) return;
131347
+ current.tailOutput = output;
131348
+ current.tailLoading = false;
131349
+ this.repaint();
131350
+ }).catch(() => {
131351
+ const current = state.tasksBrowser;
131352
+ if (current === void 0) return;
131353
+ if (current !== browser || current.tailRequestId !== requestId) return;
131354
+ if (current.selectedTaskId !== taskId) return;
131355
+ current.tailOutput = "";
131356
+ current.tailLoading = false;
131357
+ this.repaint();
131358
+ });
131375
131359
  }
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);
131360
+ flash(message, durationMs = 2500) {
131361
+ const browser = this.host.state.tasksBrowser;
131362
+ if (browser === void 0) return;
131363
+ if (browser.flashTimer !== void 0) clearTimeout(browser.flashTimer);
131364
+ browser.flashMessage = message;
131365
+ browser.flashTimer = setTimeout(() => {
131366
+ const current = this.host.state.tasksBrowser;
131367
+ if (current !== browser) return;
131368
+ current.flashMessage = void 0;
131369
+ current.flashTimer = void 0;
131370
+ this.repaint();
131371
+ }, durationMs);
131372
+ this.repaint();
131388
131373
  }
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);
131374
+ closeOutputViewer() {
131375
+ const browser = this.host.state.tasksBrowser;
131376
+ if (browser === void 0 || browser.viewer === void 0) return;
131377
+ const viewer = browser.viewer;
131378
+ clearInterval(viewer.pollTimer);
131379
+ browser.viewer = void 0;
131380
+ this.host.state.ui.clear();
131381
+ for (const child of viewer.savedChildren) this.host.state.ui.addChild(child);
131382
+ this.host.state.ui.setFocus(browser.component);
131383
+ this.host.state.ui.requestRender(true);
131412
131384
  }
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;
131385
+ };
131386
+ //#endregion
131387
+ //#region src/tui/components/editor/file-mention-provider.ts
131388
+ /**
131389
+ * `@file` autocomplete provider for the input box.
131390
+ *
131391
+ * pi-tui's `CombinedAutocompleteProvider` handles the mechanical parts
131392
+ * (extract `@…` prefix, insert completion with the right quoting). This
131393
+ * wrapper adds scream-specific ranking + filtering so the default "empty
131394
+ * `@`" list surfaces files the user actually wants, not alphabetical
131395
+ * noise from `.agents/skills/*` et al.
131396
+ *
131397
+ * Sort order empty query:
131398
+ * 1. recently edited (from `git log --name-only`)
131399
+ * 2. recent fs mtime
131400
+ * 3. basename alphabetical
131401
+ * (first 15, not 50 pi-tui's menu height is ~6-10 lines anyway)
131402
+ *
131403
+ * Sort order non-empty query (strict to fuzzy):
131404
+ * cat 0: basename starts-with query
131405
+ * cat 1: basename contains query
131406
+ * cat 2: fuzzyMatch succeeds on full path
131407
+ * tie-break within each cat: recency rank mtime basename length
131408
+ * (first 50)
131409
+ *
131410
+ * Filter — dot directories are hidden by default. User can opt in by starting the query
131411
+ * with `.` (e.g. `@.github/`), since those paths rarely need
131412
+ * completion.
131413
+ *
131414
+ * When `fd` is available the inner pi-tui provider owns the `@` branch
131415
+ * verbatim — its fd invocation respects `.gitignore` and is strictly
131416
+ * better than anything we can cheaply reproduce in TS. We only kick in
131417
+ * when `fd` is missing AND we're in a git repo.
131418
+ */
131419
+ const MAX_SUGGESTIONS_WHEN_QUERY = 50;
131420
+ const MAX_SUGGESTIONS_WHEN_EMPTY = 15;
131421
+ const PATH_DELIMITERS = new Set([
131422
+ " ",
131423
+ " ",
131424
+ "\"",
131425
+ "'",
131426
+ "="
131427
+ ]);
131428
+ var FileMentionProvider = class {
131429
+ fdPath;
131430
+ gitCache;
131431
+ inner;
131432
+ slashCommandItems;
131433
+ constructor(slashCommands, workDir, fdPath, gitCache) {
131434
+ this.fdPath = fdPath;
131435
+ this.gitCache = gitCache;
131436
+ this.slashCommandItems = slashCommands.map((cmd) => {
131437
+ const ac = cmd;
131438
+ if (ac.label !== void 0 && ac.label.length > 0) return {
131439
+ value: ac.value ?? ac.name ?? "",
131440
+ label: ac.label
131441
+ };
131442
+ const name = ac.value ?? ac.name ?? "";
131443
+ const desc = ac.description ?? "";
131444
+ return {
131445
+ value: name,
131446
+ label: `/${name}${desc ? ` — ${desc}` : ""}`
131447
+ };
131448
+ });
131449
+ this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath);
131441
131450
  }
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);
131451
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
131452
+ const textBeforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
131453
+ const atPrefix = extractAtPrefix(textBeforeCursor);
131454
+ if (!options.force && atPrefix === null && textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
131455
+ const query = textBeforeCursor.slice(1);
131456
+ const filtered = fuzzyFilter(this.slashCommandItems, query, (item) => item.value).slice(0, MAX_SUGGESTIONS_WHEN_QUERY);
131457
+ if (filtered.length === 0) return null;
131458
+ return {
131459
+ items: filtered,
131460
+ prefix: textBeforeCursor
131461
+ };
131450
131462
  }
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));
131463
+ if (atPrefix === null) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131464
+ if (this.fdPath !== null) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131465
+ const snapshot = this.gitCache.getSnapshot();
131466
+ if (snapshot === null || snapshot.files.length === 0) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131467
+ const query = atPrefix.slice(1);
131468
+ const candidates = query.startsWith(".") ? snapshot.files : snapshot.files.filter((p) => !containsDotSegment(p));
131469
+ const items = query.length === 0 ? rankForEmptyQuery(candidates, snapshot) : rankForQuery(candidates, query, snapshot);
131470
+ if (items.length === 0) return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
131471
+ return {
131472
+ items,
131473
+ prefix: atPrefix
131474
+ };
131475
+ }
131476
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
131477
+ if (prefix.startsWith("/")) {
131478
+ const line = lines[cursorLine] ?? "";
131479
+ const before = line.slice(0, cursorCol - prefix.length);
131480
+ const after = line.slice(cursorCol);
131481
+ const replacement = `/${item.value} `;
131482
+ const newLine = `${before}${replacement}${after}`;
131483
+ return {
131484
+ lines: [
131485
+ ...lines.slice(0, cursorLine),
131486
+ newLine,
131487
+ ...lines.slice(cursorLine + 1)
131488
+ ],
131489
+ cursorLine,
131490
+ cursorCol: before.length + replacement.length
131491
+ };
131459
131492
  }
131460
- while (lines.length < innerHeight) lines.push("");
131461
- return this.renderFrame(title, lines, width, height);
131493
+ return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
131462
131494
  }
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);
131495
+ };
131496
+ /**
131497
+ * Return the `@…` token ending at the cursor, or `null` if we're not in
131498
+ * an `@` mention. Mirrors pi-tui's `extractAtPrefix` — the token
131499
+ * boundary is the last PATH_DELIMITER before the cursor, and the token
131500
+ * must start with `@`.
131501
+ */
131502
+ function extractAtPrefix(text) {
131503
+ let tokenStart = 0;
131504
+ for (let i = text.length - 1; i >= 0; i -= 1) if (PATH_DELIMITERS.has(text[i] ?? "")) {
131505
+ tokenStart = i + 1;
131506
+ break;
131477
131507
  }
131478
- adjustScroll(visibleRows) {
131479
- if (visibleRows <= 0) {
131480
- this.listScroll = 0;
131481
- return;
131508
+ if (text[tokenStart] !== "@") return null;
131509
+ return text.slice(tokenStart);
131510
+ }
131511
+ /** True when any path segment starts with a dot (e.g. `.github/x.yml`). */
131512
+ function containsDotSegment(path) {
131513
+ for (const segment of path.split("/")) if (segment.startsWith(".")) return true;
131514
+ return false;
131515
+ }
131516
+ /**
131517
+ * Empty-query ranking: stratified by signal strength.
131518
+ *
131519
+ * Layer 1: files touched in the last RECENT_COMMIT_DEPTH commits,
131520
+ * ordered by how recently. Strongest signal — if the user
131521
+ * just worked on it, they probably want to mention it.
131522
+ * Layer 2: files with the newest fs mtime (covers uncommitted edits
131523
+ * and files edited but not yet added to git).
131524
+ * Layer 3: everything else, alphabetical by basename so
131525
+ * README/package.json-style top-level files bubble up
131526
+ * relative to deeply-nested alphabetical paths.
131527
+ *
131528
+ * Cap at MAX_SUGGESTIONS_WHEN_EMPTY. Layers fill in order; dedup by
131529
+ * path so a recently-edited file isn't also listed in layer 2.
131530
+ */
131531
+ function rankForEmptyQuery(files, snapshot) {
131532
+ const picked = /* @__PURE__ */ new Set();
131533
+ const result = [];
131534
+ const cap = MAX_SUGGESTIONS_WHEN_EMPTY;
131535
+ const inFiles = new Set(files);
131536
+ const byRecency = [...snapshot.recencyOrder.entries()].filter(([path]) => inFiles.has(path)).toSorted((a, b) => a[1] - b[1]);
131537
+ for (const [path] of byRecency) {
131538
+ if (result.length >= cap) break;
131539
+ if (picked.has(path)) continue;
131540
+ picked.add(path);
131541
+ result.push(path);
131542
+ }
131543
+ if (result.length < cap) {
131544
+ 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));
131545
+ for (const path of byMtime) {
131546
+ if (result.length >= cap) break;
131547
+ picked.add(path);
131548
+ result.push(path);
131482
131549
  }
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;
131488
131550
  }
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)];
131551
+ if (result.length < cap) {
131552
+ const rest = files.filter((p) => !picked.has(p)).toSorted((a, b) => basename(a).localeCompare(basename(b)) || a.localeCompare(b));
131553
+ for (const path of rest) {
131554
+ if (result.length >= cap) break;
131555
+ result.push(path);
131556
+ }
131493
131557
  }
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);
131558
+ return result.map(toItem);
131559
+ }
131560
+ /**
131561
+ * Non-empty-query ranking: three strictness tiers, with recency /
131562
+ * mtime as tie-breakers inside each tier so "the readme you just
131563
+ * edited" beats "a readme deep in a vendor dir".
131564
+ */
131565
+ function rankForQuery(files, query, snapshot) {
131566
+ const lowerQuery = query.toLowerCase();
131567
+ const scored = [];
131568
+ for (const path of files) {
131569
+ const base = basename(path).toLowerCase();
131570
+ if (base.startsWith(lowerQuery)) {
131571
+ scored.push({
131572
+ path,
131573
+ cat: 0,
131574
+ fuzzyScore: 0
131575
+ });
131576
+ continue;
131502
131577
  }
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);
131578
+ if (base.includes(lowerQuery)) {
131579
+ scored.push({
131580
+ path,
131581
+ cat: 1,
131582
+ fuzzyScore: 0
131583
+ });
131584
+ continue;
131585
+ }
131586
+ const fuzzy = fuzzyMatch(query, path);
131587
+ if (fuzzy.matches) scored.push({
131588
+ path,
131589
+ cat: 2,
131590
+ fuzzyScore: fuzzy.score
131591
+ });
131592
+ }
131593
+ if (scored.length === 0) return fuzzyFilter([...files], query, (p) => p).slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map(toItem);
131594
+ scored.sort((a, b) => {
131595
+ if (a.cat !== b.cat) return a.cat - b.cat;
131596
+ if (a.cat === 2 && a.fuzzyScore !== b.fuzzyScore) return a.fuzzyScore - b.fuzzyScore;
131597
+ const ra = snapshot.recencyOrder.get(a.path);
131598
+ const rb = snapshot.recencyOrder.get(b.path);
131599
+ if (ra !== void 0 && rb !== void 0 && ra !== rb) return ra - rb;
131600
+ if (ra !== void 0 && rb === void 0) return -1;
131601
+ if (ra === void 0 && rb !== void 0) return 1;
131602
+ const ma = snapshot.mtimeByPath.get(a.path) ?? 0;
131603
+ const mb = snapshot.mtimeByPath.get(b.path) ?? 0;
131604
+ if (ma !== mb) return mb - ma;
131605
+ const baseLenDiff = basename(a.path).length - basename(b.path).length;
131606
+ if (baseLenDiff !== 0) return baseLenDiff;
131607
+ return a.path.localeCompare(b.path);
131608
+ });
131609
+ return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path));
131610
+ }
131611
+ function toItem(path) {
131612
+ return {
131613
+ value: `@${path}`,
131614
+ label: basename(path),
131615
+ description: path
131616
+ };
131617
+ }
131618
+ //#endregion
131619
+ //#region src/tui/components/messages/cron-message.ts
131620
+ var CronMessageComponent = class {
131621
+ colors;
131622
+ spacer = new Spacer(1);
131623
+ title;
131624
+ detail;
131625
+ titleColor;
131626
+ promptText;
131627
+ bullet;
131628
+ bulletWidth;
131629
+ cachedWidth;
131630
+ cachedLines;
131631
+ constructor(prompt, data, colors) {
131632
+ this.colors = colors;
131633
+ const missed = data.missedCount !== void 0;
131634
+ this.title = missed ? "错过的定时提醒" : "定时提醒触发";
131635
+ this.detail = cronDetail(data);
131636
+ this.titleColor = data.stale === true || missed ? colors.warning : colors.accent;
131637
+ this.promptText = new Text(chalk.hex(colors.text)(prompt), 0, 0);
131638
+ this.bullet = chalk.hex(this.titleColor).bold(STATUS_BULLET);
131639
+ this.bulletWidth = visibleWidth(this.bullet);
131520
131640
  }
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);
131528
- }
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);
131641
+ invalidate() {
131642
+ this.cachedWidth = void 0;
131643
+ this.cachedLines = void 0;
131644
+ this.promptText.invalidate();
131536
131645
  }
131537
- renderTooSmall(width, rows) {
131646
+ render(width) {
131647
+ if (this.cachedLines !== void 0 && this.cachedWidth === width) return this.cachedLines;
131648
+ const contentWidth = Math.max(1, width - this.bulletWidth);
131538
131649
  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));
131650
+ for (const line of this.spacer.render(width)) lines.push(line);
131651
+ const title = chalk.hex(this.titleColor).bold(this.title);
131652
+ lines.push(`${this.bullet}${title}`);
131653
+ if (this.detail !== void 0) lines.push(`${" ".repeat(this.bulletWidth)}${chalk.hex(this.colors.textDim)(this.detail)}`);
131654
+ const promptLines = this.promptText.render(contentWidth);
131655
+ for (const line of promptLines) lines.push(`${" ".repeat(this.bulletWidth)}${line}`);
131656
+ this.cachedWidth = width;
131657
+ this.cachedLines = lines;
131542
131658
  return lines;
131543
131659
  }
131544
131660
  };
131661
+ function cronDetail(data) {
131662
+ const parts = [];
131663
+ if (data.cron !== void 0 && data.cron.length > 0) parts.push(data.cron);
131664
+ if (data.jobId !== void 0 && data.jobId.length > 0) parts.push(`job ${data.jobId}`);
131665
+ if (data.recurring === false) parts.push("一次性");
131666
+ if (data.coalescedCount !== void 0 && data.coalescedCount > 1) parts.push(`${String(data.coalescedCount)} 次合并触发`);
131667
+ if (data.missedCount !== void 0) parts.push(`${String(data.missedCount)} 次错过`);
131668
+ if (data.stale === true) parts.push("最终投递");
131669
+ return parts.length > 0 ? parts.join(" | ") : void 0;
131670
+ }
131545
131671
  //#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("没有活动会话。");
131672
+ //#region src/tui/components/panes/activity-pane.ts
131673
+ var ActivityPaneComponent = class extends Container {
131674
+ constructor(options) {
131675
+ super();
131676
+ if (options.mode === "waiting" || options.mode === "tool") {
131677
+ if (options.spinner !== void 0) {
131678
+ this.addChild(new Spacer(1));
131679
+ this.addChild(options.spinner);
131680
+ }
131681
+ if (options.pulseWave !== void 0) {
131682
+ this.addChild(new Spacer(1));
131683
+ this.addChild(options.pulseWave);
131684
+ }
131558
131685
  return;
131559
131686
  }
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;
131687
+ if (options.mode === "composing" && options.spinner !== void 0) {
131688
+ this.addChild(new Spacer(1));
131689
+ this.addChild(options.spinner);
131690
+ if (options.pulseWave !== void 0) {
131691
+ this.addChild(new Spacer(1));
131692
+ this.addChild(options.pulseWave);
131693
+ }
131566
131694
  }
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
131695
  }
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);
131696
+ };
131697
+ //#endregion
131698
+ //#region src/tui/components/panes/queue-pane.ts
131699
+ var QueuePaneComponent = class extends Container {
131700
+ constructor(options) {
131701
+ super();
131702
+ const accent = chalk.hex(options.colors.accent);
131703
+ const dim = chalk.hex(options.colors.textDim);
131704
+ for (const item of options.messages) this.addChild(new Text(accent(` ❯ ${item.text}`), 0, 0));
131705
+ if (options.messages.length > 0) {
131706
+ 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";
131707
+ this.addChild(new Text(dim(hint), 0, 0));
131708
+ }
131615
131709
  }
131616
- repaint() {
131617
- if (this.host.state.tasksBrowser === void 0) return;
131618
- const tasks = [...this.host.backgroundTasks.values()];
131619
- this.pushProps(tasks);
131710
+ };
131711
+ //#endregion
131712
+ //#region src/tui/reverse-rpc/base-controller.ts
131713
+ var ReverseRpcController = class {
131714
+ uiHooks = null;
131715
+ current = null;
131716
+ queue = [];
131717
+ setUIHooks(hooks) {
131718
+ this.uiHooks = hooks;
131620
131719
  }
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
- }
131720
+ /**
131721
+ * Called when a reverse RPC request arrives from core. The returned promise
131722
+ * resolves after the user responds or `cancelAll` forces cancellation.
131723
+ */
131724
+ show(payload) {
131725
+ return new Promise((resolve) => {
131726
+ const entry = {
131727
+ payload,
131728
+ resolve
131729
+ };
131730
+ if (this.current === null) {
131731
+ this.current = entry;
131732
+ this.uiHooks?.showPanel(payload);
131733
+ } else this.queue.push(entry);
131652
131734
  });
131653
- state.ui.requestRender();
131654
131735
  }
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;
131736
+ /** Called by the UI after the user makes a panel choice. */
131737
+ respond(data) {
131738
+ const pending = this.current;
131739
+ this.current = null;
131740
+ pending?.resolve(data);
131741
+ if (pending !== null) this.drainAutoResolved(pending.payload, data);
131742
+ this.advanceOrHide();
131659
131743
  }
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)}`);
131744
+ /** Cancels all pending requests during shutdown or session switches. */
131745
+ cancelAll(reason) {
131746
+ const all = [...this.current === null ? [] : [this.current], ...this.queue];
131747
+ this.current = null;
131748
+ this.queue = [];
131749
+ this.uiHooks?.hidePanel();
131750
+ for (const entry of all) entry.resolve(this.createCancelResponse(reason));
131751
+ }
131752
+ hasPending() {
131753
+ return this.current !== null || this.queue.length > 0;
131754
+ }
131755
+ advanceOrHide() {
131756
+ const next = this.queue.shift();
131757
+ if (next === void 0) {
131758
+ this.uiHooks?.hidePanel();
131671
131759
  return;
131672
131760
  }
131673
- if (state.tasksBrowser !== browser) return;
131674
- this.pushProps(tasks);
131761
+ this.current = next;
131762
+ this.uiHooks?.showPanel(next.payload);
131675
131763
  }
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();
131764
+ drainAutoResolved(resolvedPayload, response) {
131765
+ const remaining = [];
131766
+ for (const entry of this.queue) {
131767
+ const auto = this.autoResolveFor(resolvedPayload, response, entry.payload);
131768
+ if (auto === void 0) remaining.push(entry);
131769
+ else entry.resolve(auto);
131770
+ }
131771
+ this.queue = remaining;
131690
131772
  }
131691
- buildCallbacks() {
131773
+ /**
131774
+ * Subclasses override to short-circuit queued requests when an answer to the
131775
+ * just-resolved one (e.g. an approve-for-session) implies the same answer
131776
+ * for matching queued requests. Return `undefined` to leave the queued
131777
+ * request waiting for its own panel turn.
131778
+ */
131779
+ autoResolveFor(_resolvedPayload, _response, _queuedPayload) {}
131780
+ };
131781
+ //#endregion
131782
+ //#region src/tui/reverse-rpc/approval/controller.ts
131783
+ var ApprovalController = class extends ReverseRpcController {
131784
+ createCancelResponse(reason) {
131692
131785
  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
- }
131786
+ decision: "cancelled",
131787
+ feedback: reason
131714
131788
  };
131715
131789
  }
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);
131790
+ autoResolveFor(resolvedPayload, response, queuedPayload) {
131791
+ if (response.decision !== "approved") return void 0;
131792
+ if (response.scope !== "session") return void 0;
131793
+ if (resolvedPayload.action !== queuedPayload.action) return void 0;
131794
+ return {
131795
+ decision: "approved",
131796
+ scope: "session"
131797
+ };
131725
131798
  }
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();
131799
+ };
131800
+ //#endregion
131801
+ //#region src/tui/reverse-rpc/modal-coordinator.ts
131802
+ var ReverseRpcModalCoordinator = class {
131803
+ hooks;
131804
+ active = null;
131805
+ queued = [];
131806
+ constructor(hooks) {
131807
+ this.hooks = hooks;
131731
131808
  }
131732
- handleRefresh() {
131733
- this.flash("正在刷新…", 600);
131734
- this.refresh();
131809
+ showApproval(payload) {
131810
+ this.show({
131811
+ owner: "approval",
131812
+ show: () => {
131813
+ this.hooks.showApprovalPanel(payload);
131814
+ },
131815
+ hide: () => {
131816
+ this.hooks.hideApprovalPanel();
131817
+ }
131818
+ });
131735
131819
  }
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("没有活动会话。");
131820
+ showQuestion(payload) {
131821
+ this.show({
131822
+ owner: "question",
131823
+ show: () => {
131824
+ this.hooks.showQuestionDialog(payload);
131825
+ },
131826
+ hide: () => {
131827
+ this.hooks.hideQuestionDialog();
131828
+ }
131829
+ });
131830
+ }
131831
+ hide(owner) {
131832
+ if (this.active?.owner === owner) {
131833
+ const active = this.active;
131834
+ this.active = null;
131835
+ active.hide();
131836
+ this.showNext();
131741
131837
  return;
131742
131838
  }
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
- }
131839
+ const queuedIndex = this.queued.findIndex((entry) => entry.owner === owner);
131840
+ if (queuedIndex >= 0) this.queued.splice(queuedIndex, 1);
131751
131841
  }
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("没有活动会话。");
131842
+ clear() {
131843
+ const active = this.active;
131844
+ this.active = null;
131845
+ this.queued.length = 0;
131846
+ active?.hide();
131847
+ }
131848
+ show(entry) {
131849
+ const active = this.active;
131850
+ if (active === null) {
131851
+ this.active = entry;
131852
+ entry.show();
131760
131853
  return;
131761
131854
  }
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}`);
131855
+ if (active.owner === entry.owner) {
131856
+ this.active = entry;
131857
+ entry.show();
131768
131858
  return;
131769
131859
  }
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();
131860
+ const queuedIndex = this.queued.findIndex((queued) => queued.owner === entry.owner);
131861
+ if (queuedIndex >= 0) {
131862
+ this.queued[queuedIndex] = entry;
131806
131863
  return;
131807
131864
  }
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
- });
131865
+ this.queued.push(entry);
131826
131866
  }
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();
131867
+ showNext() {
131868
+ const next = this.queued.shift();
131869
+ if (next === void 0) return;
131870
+ this.active = next;
131871
+ next.show();
131840
131872
  }
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);
131873
+ };
131874
+ //#endregion
131875
+ //#region src/tui/reverse-rpc/index.ts
131876
+ function registerReverseRPCHandlers(approvalController, questionController, uiHooks) {
131877
+ const modalCoordinator = new ReverseRpcModalCoordinator(uiHooks);
131878
+ approvalController.setUIHooks({
131879
+ showPanel: (payload) => {
131880
+ modalCoordinator.showApproval(payload);
131881
+ },
131882
+ hidePanel: () => {
131883
+ modalCoordinator.hide("approval");
131884
+ }
131885
+ });
131886
+ questionController.setUIHooks({
131887
+ showPanel: (payload) => {
131888
+ modalCoordinator.showQuestion(payload);
131889
+ },
131890
+ hidePanel: () => {
131891
+ modalCoordinator.hide("question");
131892
+ }
131893
+ });
131894
+ return [() => {
131895
+ modalCoordinator.clear();
131896
+ }];
131897
+ }
131898
+ //#endregion
131899
+ //#region src/tui/reverse-rpc/question/controller.ts
131900
+ var QuestionController = class extends ReverseRpcController {
131901
+ createCancelResponse(_reason) {
131902
+ return { answers: [] };
131851
131903
  }
131852
131904
  };
131853
131905
  //#endregion
131854
- //#region src/tui/components/editor/file-mention-provider.ts
131906
+ //#region src/tui/theme/bundle.ts
131907
+ function createScreamTUIThemeBundle(theme, resolvedTheme) {
131908
+ const actualTheme = resolvedTheme ?? resolveThemeSync(theme);
131909
+ const colors = { ...getColorPalette(actualTheme) };
131910
+ return {
131911
+ resolvedTheme: actualTheme,
131912
+ colors,
131913
+ styles: createThemeStyles(colors),
131914
+ markdownTheme: createMarkdownTheme(colors)
131915
+ };
131916
+ }
131917
+ //#endregion
131918
+ //#region src/tui/types.ts
131919
+ const INITIAL_LIVE_PANE = {
131920
+ mode: "idle",
131921
+ pendingApproval: null,
131922
+ pendingQuestion: null
131923
+ };
131924
+ //#endregion
131925
+ //#region src/utils/git/git-status.ts
131855
131926
  /**
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.
131927
+ * Cached git branch + working-tree status for the footer/statusline.
131880
131928
  *
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.
131929
+ * Branch name refreshes every 5s, porcelain status every 15s. Branch
131930
+ * and status reads stay synchronous with short timeouts. Pull request
131931
+ * lookup uses an async cache so a slow `gh pr view` never blocks
131932
+ * footer rendering.
131885
131933
  */
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}` : ""}`
131934
+ const BRANCH_TTL_MS = 5e3;
131935
+ const STATUS_TTL_MS = 15e3;
131936
+ const PULL_REQUEST_TTL_MS = 6e4;
131937
+ const SPAWN_TIMEOUT_MS = 500;
131938
+ const PR_SPAWN_TIMEOUT_MS = 5e3;
131939
+ const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/;
131940
+ function createGitStatusCache(workDir, options = {}) {
131941
+ const isRepo = detectGitRepo(workDir);
131942
+ let branch = {
131943
+ value: null,
131944
+ fetchedAt: 0
131945
+ };
131946
+ let status = {
131947
+ dirty: false,
131948
+ ahead: 0,
131949
+ behind: 0,
131950
+ diffAdded: 0,
131951
+ diffDeleted: 0,
131952
+ fetchedAt: 0
131953
+ };
131954
+ let pullRequest = {
131955
+ value: null,
131956
+ branch: null,
131957
+ fetchedAt: 0,
131958
+ pendingBranch: null,
131959
+ requestId: 0
131960
+ };
131961
+ return { getStatus: () => {
131962
+ if (!isRepo) return null;
131963
+ const now = Date.now();
131964
+ if (now - branch.fetchedAt >= BRANCH_TTL_MS) branch = {
131965
+ value: readBranch(workDir),
131966
+ fetchedAt: now
131967
+ };
131968
+ if (branch.value === null) return null;
131969
+ if (now - status.fetchedAt >= STATUS_TTL_MS) status = {
131970
+ ...readStatus(workDir),
131971
+ fetchedAt: now
131972
+ };
131973
+ refreshPullRequestIfNeeded(branch.value, now);
131974
+ return {
131975
+ branch: branch.value,
131976
+ dirty: status.dirty,
131977
+ ahead: status.ahead,
131978
+ behind: status.behind,
131979
+ diffAdded: status.diffAdded,
131980
+ diffDeleted: status.diffDeleted,
131981
+ pullRequest: pullRequest.branch === branch.value ? pullRequest.value : null
131982
+ };
131983
+ } };
131984
+ function refreshPullRequestIfNeeded(branchName, now) {
131985
+ if (pullRequest.pendingBranch === branchName) return;
131986
+ const fetchedAt = pullRequest.branch === branchName ? pullRequest.fetchedAt : 0;
131987
+ if (now - fetchedAt < PULL_REQUEST_TTL_MS) return;
131988
+ const requestId = pullRequest.requestId + 1;
131989
+ pullRequest = {
131990
+ value: pullRequest.branch === branchName ? pullRequest.value : null,
131991
+ branch: branchName,
131992
+ fetchedAt,
131993
+ pendingBranch: branchName,
131994
+ requestId
131995
+ };
131996
+ readPullRequest(workDir).then((value) => {
131997
+ if (pullRequest.requestId !== requestId) return;
131998
+ const changed = !samePullRequest(pullRequest.branch === branchName ? pullRequest.value : null, value);
131999
+ pullRequest = {
132000
+ value,
132001
+ branch: branchName,
132002
+ fetchedAt: Date.now(),
132003
+ pendingBranch: null,
132004
+ requestId
131914
132005
  };
132006
+ if (changed) options.onChange?.();
131915
132007
  });
131916
- this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath);
131917
132008
  }
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);
132009
+ }
132010
+ function detectGitRepo(workDir) {
132011
+ try {
132012
+ const result = spawnSync("git", [
132013
+ "-C",
132014
+ workDir,
132015
+ "rev-parse",
132016
+ "--is-inside-work-tree"
132017
+ ], {
132018
+ encoding: "utf8",
132019
+ timeout: SPAWN_TIMEOUT_MS
132020
+ });
132021
+ return result.status === 0 && result.stdout.trim() === "true";
132022
+ } catch {
132023
+ return false;
132024
+ }
132025
+ }
132026
+ function readBranch(workDir) {
132027
+ try {
132028
+ const result = spawnSync("git", [
132029
+ "-C",
132030
+ workDir,
132031
+ "branch",
132032
+ "--show-current"
132033
+ ], {
132034
+ encoding: "utf8",
132035
+ timeout: SPAWN_TIMEOUT_MS
132036
+ });
132037
+ if (result.status !== 0) return null;
132038
+ const name = result.stdout.trim();
132039
+ return name.length > 0 ? name : null;
132040
+ } catch {
132041
+ return null;
132042
+ }
132043
+ }
132044
+ function readStatus(workDir) {
132045
+ try {
132046
+ const result = spawnSync("git", [
132047
+ "-C",
132048
+ workDir,
132049
+ "status",
132050
+ "--porcelain",
132051
+ "-b"
132052
+ ], {
132053
+ encoding: "utf8",
132054
+ timeout: SPAWN_TIMEOUT_MS,
132055
+ maxBuffer: 4 * 1024 * 1024
132056
+ });
132057
+ if (result.status !== 0) return {
132058
+ dirty: false,
132059
+ ahead: 0,
132060
+ behind: 0,
132061
+ diffAdded: 0,
132062
+ diffDeleted: 0
132063
+ };
132064
+ let dirty = false;
132065
+ let ahead = 0;
132066
+ let behind = 0;
132067
+ for (const line of result.stdout.split("\n")) if (line.startsWith("## ")) {
132068
+ const m = AHEAD_BEHIND_RE.exec(line);
132069
+ if (m) {
132070
+ ahead = Number.parseInt(m[1] ?? "0", 10) || 0;
132071
+ behind = Number.parseInt(m[2] ?? "0", 10) || 0;
132072
+ }
132073
+ } else if (line.trim().length > 0) dirty = true;
132074
+ const diff = dirty ? readDiffStats(workDir) : {
132075
+ added: 0,
132076
+ deleted: 0
132077
+ };
132078
+ return {
132079
+ dirty,
132080
+ ahead,
132081
+ behind,
132082
+ diffAdded: diff.added,
132083
+ diffDeleted: diff.deleted
132084
+ };
132085
+ } catch {
131938
132086
  return {
131939
- items,
131940
- prefix: atPrefix
132087
+ dirty: false,
132088
+ ahead: 0,
132089
+ behind: 0,
132090
+ diffAdded: 0,
132091
+ diffDeleted: 0
131941
132092
  };
131942
132093
  }
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
132094
  }
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);
132095
+ function readDiffStats(workDir) {
132096
+ try {
132097
+ const result = spawnSync("git", [
132098
+ "-C",
132099
+ workDir,
132100
+ "diff",
132101
+ "--numstat",
132102
+ "HEAD",
132103
+ "--"
132104
+ ], {
132105
+ encoding: "utf8",
132106
+ timeout: SPAWN_TIMEOUT_MS,
132107
+ maxBuffer: 4 * 1024 * 1024
132108
+ });
132109
+ if (result.status !== 0) return {
132110
+ added: 0,
132111
+ deleted: 0
132112
+ };
132113
+ let added = 0;
132114
+ let deleted = 0;
132115
+ for (const line of result.stdout.split("\n")) {
132116
+ if (!line) continue;
132117
+ const [addedText, deletedText] = line.split(" ");
132118
+ added += parseDiffNumstatCount(addedText);
132119
+ deleted += parseDiffNumstatCount(deletedText);
132023
132120
  }
132121
+ return {
132122
+ added,
132123
+ deleted
132124
+ };
132125
+ } catch {
132126
+ return {
132127
+ added: 0,
132128
+ deleted: 0
132129
+ };
132024
132130
  }
132025
- return result.map(toItem);
132026
132131
  }
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
132132
+ function parseDiffNumstatCount(value) {
132133
+ if (value === void 0 || value === "-") return 0;
132134
+ const n = Number.parseInt(value, 10);
132135
+ return Number.isFinite(n) && n > 0 ? n : 0;
132136
+ }
132137
+ function readPullRequest(workDir) {
132138
+ return new Promise((resolve) => {
132139
+ try {
132140
+ execFile("gh", [
132141
+ "pr",
132142
+ "view",
132143
+ "--json",
132144
+ "number,url"
132145
+ ], {
132146
+ cwd: workDir,
132147
+ encoding: "utf8",
132148
+ env: {
132149
+ ...process.env,
132150
+ GH_NO_UPDATE_NOTIFIER: "1",
132151
+ GH_PROMPT_DISABLED: "1"
132152
+ },
132153
+ timeout: PR_SPAWN_TIMEOUT_MS,
132154
+ maxBuffer: 256 * 1024
132155
+ }, (error, stdout) => {
132156
+ if (error !== null) {
132157
+ resolve(null);
132158
+ return;
132159
+ }
132160
+ resolve(parsePullRequest(stdout));
132050
132161
  });
132051
- continue;
132162
+ } catch {
132163
+ resolve(null);
132052
132164
  }
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
132165
  });
132076
- return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path));
132077
132166
  }
132078
- function toItem(path) {
132079
- return {
132080
- value: `@${path}`,
132081
- label: basename(path),
132082
- description: path
132083
- };
132167
+ function samePullRequest(a, b) {
132168
+ if (a === null || b === null) return a === b;
132169
+ return a.number === b.number && a.url === b.url;
132084
132170
  }
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);
132171
+ function parsePullRequest(stdout) {
132172
+ try {
132173
+ const raw = JSON.parse(stdout);
132174
+ if (typeof raw !== "object" || raw === null) return null;
132175
+ const record = raw;
132176
+ const number = record["number"];
132177
+ const url = record["url"];
132178
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) return null;
132179
+ if (typeof url !== "string" || !isSafeHttpUrl(url)) return null;
132180
+ return {
132181
+ number,
132182
+ url
132183
+ };
132184
+ } catch {
132185
+ return null;
132101
132186
  }
132102
- invalidate() {
132103
- this.promptText.invalidate();
132187
+ }
132188
+ function isSafeHttpUrl(value) {
132189
+ if (hasControlChars(value)) return false;
132190
+ try {
132191
+ const url = new URL(value);
132192
+ return url.protocol === "https:" || url.protocol === "http:";
132193
+ } catch {
132194
+ return false;
132104
132195
  }
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;
132196
+ }
132197
+ function hasControlChars(value) {
132198
+ for (const char of value) {
132199
+ const code = char.codePointAt(0) ?? 0;
132200
+ if (code <= 31 || code === 127) return true;
132117
132201
  }
132118
- };
132119
- function cronDetail(data) {
132202
+ return false;
132203
+ }
132204
+ function formatGitBadgeBase(status) {
132120
132205
  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;
132206
+ const diff = formatDiffStats(status);
132207
+ if (diff) parts.push(diff);
132208
+ let sync = "";
132209
+ if (status.ahead > 0) sync += `↑${status.ahead}`;
132210
+ if (status.behind > 0) sync += `↓${status.behind}`;
132211
+ if (sync) parts.push(sync);
132212
+ return parts.length === 0 ? status.branch : `${status.branch} [${parts.join(" ")}]`;
132213
+ }
132214
+ function formatPullRequestBadge(pullRequest, options = {}) {
132215
+ const prText = `[PR#${String(pullRequest.number)}]`;
132216
+ return options.linkPullRequest ? toTerminalHyperlink(prText, pullRequest.url) : prText;
132217
+ }
132218
+ function formatDiffStats(status) {
132219
+ const parts = [];
132220
+ if (status.diffAdded > 0) parts.push(`+${String(status.diffAdded)}`);
132221
+ if (status.diffDeleted > 0) parts.push(`-${String(status.diffDeleted)}`);
132222
+ if (parts.length > 0) return parts.join(" ");
132223
+ return status.dirty ? "±" : null;
132224
+ }
132225
+ function toTerminalHyperlink(text, url) {
132226
+ if (!isSafeHttpUrl(url)) return text;
132227
+ return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
132128
132228
  }
132129
132229
  //#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
- }
132230
+ //#region src/tui/components/chrome/footer.ts
132231
+ const MAX_CWD_SEGMENTS = 3;
132232
+ const TOOLBAR_TIPS = [
132233
+ { text: "shift+tab: 计划模式" },
132234
+ { text: "/model: 切换模型" },
132235
+ {
132236
+ text: "ctrl+s: 中途干预",
132237
+ priority: 2
132238
+ },
132239
+ {
132240
+ text: "/compact: 压缩上下文",
132241
+ priority: 2
132242
+ },
132243
+ { text: "ctrl+o: 展开工具输出" },
132244
+ { text: "/tasks: 后台任务" },
132245
+ { text: "shift+enter: 换行" },
132246
+ {
132247
+ text: "/init: 生成 AGENTS.md",
132248
+ priority: 2
132249
+ },
132250
+ { text: "@: 提及文件" },
132251
+ { text: "ctrl+c: 取消" },
132252
+ { text: "/theme: 切换主题" },
132253
+ { text: "/auto: 自动权限模式" },
132254
+ { text: "/yes: 自动批准" },
132255
+ { text: "/help: 显示命令" },
132256
+ {
132257
+ text: "/config: 选择并配置你常用的模型商",
132258
+ solo: true,
132259
+ priority: 3
132260
+ },
132261
+ {
132262
+ text: "让 Scream 安排任务,例如 \"2个小时后提醒我去拿快递\"",
132263
+ solo: true,
132264
+ priority: 3
132153
132265
  }
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));
132266
+ ];
132267
+ /**
132268
+ * Expand tips into a rotation sequence using smooth weighted round-robin
132269
+ * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while
132270
+ * staying evenly spread, so a tip generally does not land next to its own
132271
+ * duplicate. Deterministic and computed once at module load. Exported for
132272
+ * unit testing.
132273
+ */
132274
+ function buildWeightedTips(tips) {
132275
+ const items = tips.map((t) => ({
132276
+ tip: t,
132277
+ weight: Math.max(1, Math.trunc(t.priority ?? 1)),
132278
+ current: 0
132279
+ }));
132280
+ const total = items.reduce((sum, it) => sum + it.weight, 0);
132281
+ const seq = [];
132282
+ for (let n = 0; n < total; n++) {
132283
+ let best = items[0];
132284
+ for (const it of items) {
132285
+ it.current += it.weight;
132286
+ if (it.current > best.current) best = it;
132166
132287
  }
132288
+ best.current -= total;
132289
+ seq.push(best.tip);
132167
132290
  }
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;
132291
+ return seq;
132292
+ }
132293
+ buildWeightedTips(TOOLBAR_TIPS);
132294
+ function shortenModel(model) {
132295
+ if (!model) return model;
132296
+ const slash = model.lastIndexOf("/");
132297
+ return slash >= 0 ? model.slice(slash + 1) : model;
132298
+ }
132299
+ function modelDisplayName(state) {
132300
+ const model = state.availableModels[state.model];
132301
+ return model?.displayName ?? model?.model ?? state.model;
132302
+ }
132303
+ function shortenCwd(path) {
132304
+ if (!path) return path;
132305
+ const home = process.env["HOME"] ?? "";
132306
+ let work = path;
132307
+ if (home && path === home) return "~";
132308
+ if (home && path.startsWith(home + "/")) work = "~" + path.slice(home.length);
132309
+ const segments = work.split("/").filter((s) => s.length > 0);
132310
+ if (segments.length <= MAX_CWD_SEGMENTS) return work;
132311
+ return `…/${segments.slice(-3).join("/")}`;
132312
+ }
132313
+ function formatTokenCount(n) {
132314
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
132315
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
132316
+ return String(n);
132317
+ }
132318
+ function safeUsage(usage) {
132319
+ return safeUsageRatio(usage);
132320
+ }
132321
+ function formatContextStatus(usage, tokens, maxTokens) {
132322
+ const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`;
132323
+ if (maxTokens && maxTokens > 0 && tokens !== void 0) return `上下文:${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
132324
+ return `上下文:${pct}`;
132325
+ }
132326
+ const BRAND_COLORS = [
132327
+ "#72A4E9",
132328
+ "#A78BFA",
132329
+ "#34D399"
132330
+ ];
132331
+ const GRADIENT_CYCLE_MS = 4e3;
132332
+ const SPINNER_FRAMES = [
132333
+ "●",
132334
+ "◉",
132335
+ "◎",
132336
+ "◌",
132337
+ "○",
132338
+ "◌",
132339
+ "◎",
132340
+ "◉"
132341
+ ];
132342
+ const SPINNER_TICK_MS = 120;
132343
+ function hexToRgb(hex) {
132344
+ const v = parseInt(hex.slice(1), 16);
132345
+ return [
132346
+ v >> 16 & 255,
132347
+ v >> 8 & 255,
132348
+ v & 255
132349
+ ];
132350
+ }
132351
+ function lerpGradient(t) {
132352
+ const count = BRAND_COLORS.length;
132353
+ const segment = Math.min(t * count, count - 1);
132354
+ const idx = Math.floor(segment);
132355
+ const localT = segment - idx;
132356
+ const nextIdx = (idx + 1) % count;
132357
+ const [r0, g0, b0] = hexToRgb(BRAND_COLORS[idx]);
132358
+ const [r1, g1, b1] = hexToRgb(BRAND_COLORS[nextIdx]);
132359
+ const r = Math.round(r0 + (r1 - r0) * localT);
132360
+ const g = Math.round(g0 + (g1 - g0) * localT);
132361
+ const b = Math.round(b0 + (b1 - b0) * localT);
132362
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
132363
+ }
132364
+ function buildStatusLine(streamingPhase, livePaneMode, streamingStartTime) {
132365
+ if (streamingPhase === "idle" && livePaneMode !== "tool") return "○ 空闲";
132366
+ let label;
132367
+ if (livePaneMode === "tool") label = "执行中";
132368
+ else if (streamingPhase === "waiting") label = "等待响应";
132369
+ else if (streamingPhase === "thinking") label = "思考中";
132370
+ else if (streamingPhase === "composing") label = "输出中";
132371
+ else label = "";
132372
+ const elapsed = Date.now() - streamingStartTime;
132373
+ const totalSeconds = Math.floor(elapsed / 1e3);
132374
+ const elapsedStr = totalSeconds < 60 ? `${totalSeconds}s` : `${Math.floor(totalSeconds / 60)}m${totalSeconds % 60}s`;
132375
+ const now = Date.now();
132376
+ const frame = SPINNER_FRAMES[Math.floor(now / SPINNER_TICK_MS) % SPINNER_FRAMES.length];
132377
+ const gradientColor = lerpGradient(now % GRADIENT_CYCLE_MS / GRADIENT_CYCLE_MS);
132378
+ return chalk.hex(gradientColor).bold(frame) + " " + label + " " + elapsedStr;
132379
+ }
132380
+ function formatFooterGitBadge(status, colors) {
132381
+ const base = chalk.hex(colors.status)(formatGitBadgeBase(status));
132382
+ if (status.pullRequest === null) return base;
132383
+ return `${base} ${chalk.hex(colors.primary)(formatPullRequestBadge(status.pullRequest, { linkPullRequest: true }))}`;
132384
+ }
132385
+ var FooterComponent = class {
132386
+ state;
132387
+ colors;
132388
+ onGitStatusChange;
132389
+ gitCache;
132390
+ gitCacheWorkDir;
132391
+ transientHint = null;
132392
+ /**
132393
+ * Non-terminal background-task counts split by kind so the footer can
132394
+ * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks
132395
+ * spawned via `Shell run_in_background=true`; `agentTasks` covers
132396
+ * `agent-*` BPM tasks (background subagents). Either zero hides its
132397
+ * respective badge.
132398
+ */
132399
+ backgroundBashTaskCount = 0;
132400
+ backgroundAgentCount = 0;
132401
+ constructor(state, colors, onGitStatusChange = () => {}) {
132402
+ this.state = state;
132403
+ this.colors = colors;
132404
+ this.onGitStatusChange = onGitStatusChange;
132405
+ this.gitCacheWorkDir = state.workDir;
132406
+ this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
132212
132407
  }
132213
- advanceOrHide() {
132214
- const next = this.queue.shift();
132215
- if (next === void 0) {
132216
- this.uiHooks?.hidePanel();
132217
- return;
132408
+ setState(state) {
132409
+ if (state.workDir !== this.gitCacheWorkDir) {
132410
+ this.gitCacheWorkDir = state.workDir;
132411
+ this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
132218
132412
  }
132219
- this.current = next;
132220
- this.uiHooks?.showPanel(next.payload);
132413
+ this.state = state;
132221
132414
  }
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;
132415
+ setColors(colors) {
132416
+ this.colors = colors;
132230
132417
  }
132231
132418
  /**
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.
132419
+ * Short-lived hint that replaces the rotating toolbar tips on line 1.
132420
+ * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C
132421
+ * again to exit" without requiring a toast/overlay subsystem.
132422
+ * Pass `null` to clear.
132236
132423
  */
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);
132424
+ setTransientHint(hint) {
132425
+ this.transientHint = hint;
132299
132426
  }
132300
- clear() {
132301
- const active = this.active;
132302
- this.active = null;
132303
- this.queued.length = 0;
132304
- active?.hide();
132427
+ /**
132428
+ * Sync both background-task badges with live counts. Each non-zero
132429
+ * count produces its own bracketed badge on line 1; zeros hide them
132430
+ * independently.
132431
+ */
132432
+ setBackgroundCounts(counts) {
132433
+ this.backgroundBashTaskCount = Math.max(0, counts.bashTasks);
132434
+ this.backgroundAgentCount = Math.max(0, counts.agentTasks);
132305
132435
  }
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;
132436
+ invalidate() {}
132437
+ render(width) {
132438
+ const colors = this.colors;
132439
+ const state = this.state;
132440
+ const left = [];
132441
+ if (state.permissionMode === "auto") left.push(chalk.hex(colors.warning).bold("auto"));
132442
+ if (state.permissionMode === "yolo") left.push(chalk.hex(colors.warning).bold("YES"));
132443
+ if (state.planMode) left.push(chalk.hex(colors.primary).bold("plan"));
132444
+ if (state.wolfpackMode) left.push(chalk.hex(colors.primary).bold("wolfpack"));
132445
+ if (state.goalActive) left.push(chalk.hex(colors.primary).bold("goal"));
132446
+ const model = shortenModel(modelDisplayName(state));
132447
+ if (model) {
132448
+ const thinkingLabel = state.thinking ? " 思考中" : "";
132449
+ left.push(chalk.hex(colors.text)(`${model}${thinkingLabel}`));
132317
132450
  }
132318
- const queuedIndex = this.queued.findIndex((queued) => queued.owner === entry.owner);
132319
- if (queuedIndex >= 0) {
132320
- this.queued[queuedIndex] = entry;
132321
- return;
132451
+ if (this.backgroundBashTaskCount > 0) {
132452
+ const noun = this.backgroundBashTaskCount === 1 ? "个任务" : "个任务";
132453
+ left.push(chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)}${noun} 运行中]`));
132322
132454
  }
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");
132455
+ if (this.backgroundAgentCount > 0) {
132456
+ const noun = this.backgroundAgentCount === 1 ? "个代理" : "个代理";
132457
+ left.push(chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)}${noun} 运行中]`));
132342
132458
  }
132343
- });
132344
- questionController.setUIHooks({
132345
- showPanel: (payload) => {
132346
- modalCoordinator.showQuestion(payload);
132347
- },
132348
- hidePanel: () => {
132349
- modalCoordinator.hide("question");
132459
+ const cwd = shortenCwd(state.workDir);
132460
+ if (cwd) left.push(chalk.hex(colors.status)(cwd));
132461
+ const git = this.gitCache.getStatus();
132462
+ if (git !== null) left.push(formatFooterGitBadge(git, colors));
132463
+ const leftLine = left.join(" ");
132464
+ const leftWidth = visibleWidth(leftLine);
132465
+ let rightText;
132466
+ if (this.transientHint) rightText = chalk.hex(colors.warning).bold(this.transientHint);
132467
+ else {
132468
+ const statusLine = buildStatusLine(state.streamingPhase, state.livePaneMode, state.streamingStartTime);
132469
+ const ccDot = state.ccConnectActive ? chalk.hex(colors.success)("●") : chalk.hex(colors.textDim)("●");
132470
+ rightText = chalk.hex(colors.textDim)(ccDot + " " + formatContextStatus(state.contextUsage, state.contextTokens, state.maxContextTokens) + " " + statusLine);
132350
132471
  }
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: [] };
132472
+ const rightWidth = visibleWidth(rightText);
132473
+ const gap = 3;
132474
+ let line1;
132475
+ if (leftWidth + gap + rightWidth <= width) {
132476
+ const pad = width - leftWidth - rightWidth;
132477
+ line1 = leftLine + " ".repeat(pad) + rightText;
132478
+ } else if (leftWidth <= width) line1 = leftLine;
132479
+ else line1 = truncateToWidth(leftLine, width, "…");
132480
+ return [truncateToWidth(line1, width)];
132361
132481
  }
132362
132482
  };
132363
132483
  //#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
132484
  //#region src/tui/components/chrome/todo-panel.ts
132384
132485
  const MAX_VISIBLE = 5;
132385
132486
  /**
@@ -136147,27 +136248,6 @@ var ScreamTUI = class ScreamTUI {
136147
136248
  const footerWrap = new GutterContainer(1, 1);
136148
136249
  footerWrap.addChild(this.state.footer);
136149
136250
  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
136251
  }
136172
136252
  handlePlanToggle(next) {
136173
136253
  handlePlanCommand(this, next ? "on" : "off");
@@ -136598,7 +136678,7 @@ var ScreamTUI = class ScreamTUI {
136598
136678
  updateActivityPane() {
136599
136679
  const effectiveMode = this.resolveActivityPaneMode();
136600
136680
  this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode));
136601
- if (effectiveMode === this.lastActivityMode && (effectiveMode === "waiting" || effectiveMode === "thinking" || effectiveMode === "tool")) return;
136681
+ if (effectiveMode === this.lastActivityMode) return;
136602
136682
  this.lastActivityMode = effectiveMode;
136603
136683
  this.state.activityContainer.clear();
136604
136684
  switch (effectiveMode) {
@@ -136702,6 +136782,7 @@ var ScreamTUI = class ScreamTUI {
136702
136782
  this.state.theme.markdownTheme = nextTheme.markdownTheme;
136703
136783
  this.setAppState({ theme });
136704
136784
  this.updateEditorBorderHighlight();
136785
+ for (const child of this.state.transcriptContainer.children) child.invalidate?.();
136705
136786
  this.state.ui.requestRender(true);
136706
136787
  }
136707
136788
  refreshTerminalThemeTracking() {