dsh-code 1.0.6 → 1.0.7

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/lib/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { n as __require, r as __toESM, t as __commonJSMin } from "./rolldown-run
2
2
  import { a as inkColor, c as chalk, i as getTheme, n as dim, o as parseThemeName, r as getPalette, s as setTheme } from "./theme-DCT8Y2xf.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import * as fs from "node:fs";
5
- import { readFileSync, realpathSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
6
6
  import os, { homedir } from "node:os";
7
7
  import { appendFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
8
8
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
@@ -24732,6 +24732,225 @@ function ThemePanel({ current, select, close }) {
24732
24732
  wrap: "truncate-end"
24733
24733
  }, truncateColumns(`↑↓ choose · enter apply · esc/q close${hiddenThemes > 0 ? ` · +${hiddenThemes} more` : ""}`, viewport.contentColumns)));
24734
24734
  }
24735
+ /** Keep the newest UPDATE_OUTPUT_CAP lines of streamed update output. */
24736
+ function clipUpdateLines(lines) {
24737
+ return lines.length <= 800 ? lines : lines.slice(lines.length - 800);
24738
+ }
24739
+ /** Rendered facts of one probe: current/target versions, actions, refusals. */
24740
+ function updatePlanView(status) {
24741
+ const rows = [];
24742
+ const latest = status.code.latest ?? "unknown";
24743
+ rows.push(status.code.latest !== null && status.code.latest === status.code.running ? {
24744
+ key: "code",
24745
+ text: `dsh-code ${status.code.running} (latest)`
24746
+ } : {
24747
+ key: "code",
24748
+ text: `dsh-code ${status.code.running} → ${latest}`
24749
+ });
24750
+ if (status.host.targetLine === null) rows.push({
24751
+ key: "host",
24752
+ text: "harness pinned line unreadable — update would install @deepseek-ai/dsh@latest",
24753
+ tone: "warn"
24754
+ });
24755
+ else if (status.host.installed === null) rows.push({
24756
+ key: "host",
24757
+ text: `harness not installed → ${status.host.targetLine}`
24758
+ });
24759
+ else if (status.host.installed === status.host.targetLine) rows.push({
24760
+ key: "host",
24761
+ text: `harness ${status.host.installed} (on pinned line)`
24762
+ });
24763
+ else rows.push({
24764
+ key: "host",
24765
+ text: `harness ${status.host.installed} → ${status.host.targetLine}`
24766
+ });
24767
+ const mounted = status.profile.mounted === null ? status.profile.spec ?? "not mounted" : `dsh-code ${status.profile.mounted}`;
24768
+ rows.push({
24769
+ key: "profile",
24770
+ text: `profile ${mounted}${status.profile.localCheckout ? " · local checkout" : ""}`,
24771
+ tone: status.profile.localCheckout ? "dim" : void 0
24772
+ });
24773
+ for (const plugin of status.plan.pluginSpecs) rows.push({
24774
+ key: `plugin:${plugin}`,
24775
+ text: `plugin ${plugin}`,
24776
+ tone: "dim"
24777
+ });
24778
+ const registryBlocker = status.blockers.registry ?? null;
24779
+ if (registryBlocker !== null) rows.push({
24780
+ key: "blocker:registry",
24781
+ text: registryBlocker,
24782
+ tone: "error"
24783
+ });
24784
+ if (status.blockers.downgrade) rows.push({
24785
+ key: "blocker:downgrade",
24786
+ text: `refusing to downgrade the host: dsh-code@${latest} needs ${status.host.targetLine ?? "the pinned line"}, but ${status.host.installed ?? "the installed host"} is newer — wait for the next dsh-code release`,
24787
+ tone: "error"
24788
+ });
24789
+ const checkoutBlocker = status.blockers.localCheckout ?? null;
24790
+ if (checkoutBlocker !== null) for (const [index, line] of checkoutBlocker.entries()) rows.push({
24791
+ key: `blocker:checkout:${index}`,
24792
+ text: line,
24793
+ tone: "error"
24794
+ });
24795
+ if (status.upToDate) rows.push({
24796
+ key: "uptodate",
24797
+ text: "everything is already on the pinned line — nothing to update",
24798
+ tone: "ok"
24799
+ });
24800
+ return {
24801
+ rows,
24802
+ runnable: status.upToDate !== true && registryBlocker === null && status.blockers.downgrade !== true && checkoutBlocker === null
24803
+ };
24804
+ }
24805
+ /** Footer hint line per phase; the plan phase names the confirm key only when runnable. */
24806
+ function updateFooter(phase, runnable, upToDate) {
24807
+ if (phase === "probe") return "checking… · esc close";
24808
+ if (phase === "error") return "r recheck · esc close";
24809
+ if (phase === "apply") return "updating… · ↑↓ scroll · esc waits";
24810
+ if (phase === "done") return "r recheck · esc close";
24811
+ if (upToDate) return "up to date · r recheck · esc close";
24812
+ return runnable ? "enter update · r recheck · esc close" : "blocked · r recheck · esc close";
24813
+ }
24814
+ /**
24815
+ * The /update surface: probe on open (and on r), confirm with enter/y,
24816
+ * stream the aligned apply, and land on a bounded result view. Escape is
24817
+ * locked while the apply child runs — killing npm mid-install is exactly
24818
+ * the half-updated state this command exists to prevent.
24819
+ */
24820
+ function UpdatePanel({ probe, apply, close, notify }) {
24821
+ const [phase, setPhase] = (0, import_react.useState)("probe");
24822
+ const [status, setStatus] = (0, import_react.useState)();
24823
+ const [probeError, setProbeError] = (0, import_react.useState)();
24824
+ const [lines, setLines] = (0, import_react.useState)([]);
24825
+ const [exit, setExit] = (0, import_react.useState)();
24826
+ const [applyError, setApplyError] = (0, import_react.useState)();
24827
+ const [anchor, setAnchor] = (0, import_react.useState)("tail");
24828
+ const [epoch, setEpoch] = (0, import_react.useState)(0);
24829
+ (0, import_react.useEffect)(() => {
24830
+ let disposed = false;
24831
+ setPhase("probe");
24832
+ setStatus(void 0);
24833
+ setProbeError(void 0);
24834
+ setAnchor("tail");
24835
+ probe().then((value) => {
24836
+ if (disposed) return;
24837
+ setStatus(value);
24838
+ setPhase("plan");
24839
+ }, (reason) => {
24840
+ if (disposed) return;
24841
+ setProbeError(reason instanceof Error ? reason.message : String(reason));
24842
+ setPhase("error");
24843
+ });
24844
+ return () => {
24845
+ disposed = true;
24846
+ };
24847
+ }, [epoch, probe]);
24848
+ const stdout = useStdout().stdout;
24849
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
24850
+ const start = () => {
24851
+ if (phase !== "plan" || status === void 0) return;
24852
+ if (!updatePlanView(status).runnable) return;
24853
+ setPhase("apply");
24854
+ setLines([]);
24855
+ setExit(void 0);
24856
+ setApplyError(void 0);
24857
+ setAnchor("tail");
24858
+ apply((line) => {
24859
+ setLines((previous) => clipUpdateLines([...previous, singleLineText(line)]));
24860
+ }).then((code) => {
24861
+ setExit(code);
24862
+ setPhase("done");
24863
+ notify(code === 0 ? "update installed — restart dsh to activate" : `update failed (exit ${code})`, code === 0 ? "info" : "error");
24864
+ }, (reason) => {
24865
+ const message = reason instanceof Error ? reason.message : String(reason);
24866
+ setApplyError(message);
24867
+ setPhase("done");
24868
+ notify(`update failed: ${message}`, "error");
24869
+ });
24870
+ };
24871
+ const planView = status === void 0 ? void 0 : updatePlanView(status);
24872
+ const rows = phase === "probe" ? [{
24873
+ key: "probe",
24874
+ text: "checking npm for the aligned update…",
24875
+ tone: "dim"
24876
+ }] : phase === "error" ? [{
24877
+ key: "error",
24878
+ text: singleLineText(probeError ?? "probe failed"),
24879
+ tone: "error"
24880
+ }] : phase === "plan" && planView !== void 0 ? planView.rows : phase === "apply" ? lines.map((line, index) => ({
24881
+ key: `out:${index}`,
24882
+ text: line,
24883
+ tone: "dim"
24884
+ })) : [
24885
+ ...exit === 0 ? [{
24886
+ key: "ok",
24887
+ text: "update installed — restart dsh to load the new version (/quit or ctrl+c)",
24888
+ tone: "ok"
24889
+ }] : [],
24890
+ ...exit !== void 0 && exit !== 0 ? [{
24891
+ key: "fail",
24892
+ text: `update failed (exit ${exit})`,
24893
+ tone: "error"
24894
+ }] : [],
24895
+ ...applyError !== void 0 ? [{
24896
+ key: "fail:start",
24897
+ text: singleLineText(applyError),
24898
+ tone: "error"
24899
+ }] : [],
24900
+ ...lines.map((line, index) => ({
24901
+ key: `out:${index}`,
24902
+ text: line,
24903
+ tone: "dim"
24904
+ }))
24905
+ ];
24906
+ const budget = Math.max(1, viewport.bodyRows);
24907
+ const tailOffset = clampScroll(Math.max(0, rows.length - budget), rows.length, budget);
24908
+ const offset = anchor === "tail" ? tailOffset : clampScroll(anchor, rows.length, budget);
24909
+ useInput((input, key) => {
24910
+ if (phase === "apply") {
24911
+ if (key.upArrow) setAnchor(offset <= 0 ? 0 : offset - 1);
24912
+ if (key.downArrow && offset >= tailOffset) setAnchor("tail");
24913
+ else if (key.downArrow) setAnchor(offset + 1);
24914
+ return;
24915
+ }
24916
+ if (key.escape || input === "q") return close();
24917
+ if (input === "r") {
24918
+ setEpoch((value) => value + 1);
24919
+ return;
24920
+ }
24921
+ if (key.return || input === "y") {
24922
+ start();
24923
+ return;
24924
+ }
24925
+ if (key.upArrow) setAnchor(offset <= 0 ? 0 : offset - 1);
24926
+ if (key.downArrow && offset >= tailOffset) setAnchor("tail");
24927
+ else if (key.downArrow) setAnchor(offset + 1);
24928
+ });
24929
+ if (viewport.maxHeight === 0 || viewport.compact) {
24930
+ const summary = phase === "probe" ? "checking…" : phase === "error" ? "probe failed" : phase === "apply" ? singleLineText(lines[lines.length - 1] ?? "updating…") : phase === "done" ? exit === 0 ? "installed — restart to activate" : "failed" : status?.upToDate === true ? "up to date" : planView?.runnable === true ? "enter updates" : "blocked";
24931
+ return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(singleLineText(`/update · ${summary}`), viewport.contentColumns));
24932
+ }
24933
+ const visible = rows.slice(offset, offset + budget);
24934
+ const toneColor = (tone) => tone === "ok" ? inkColor(getPalette().success) : tone === "error" ? inkColor(getPalette().error) : tone === "warn" ? inkColor(getPalette().warn) : tone === "dim" ? inkColor(getPalette().dim) : void 0;
24935
+ const title = status === void 0 ? "/update · aligned upgrade" : `/update · dsh-code ${status.code.running}${status.code.latest !== null && status.code.latest !== status.code.running ? ` → ${status.code.latest}` : ""}`;
24936
+ return (0, import_react.createElement)(Box, {
24937
+ width: viewport.outerColumns,
24938
+ borderStyle: "round",
24939
+ borderColor: inkColor(getPalette().dim),
24940
+ flexDirection: "column",
24941
+ paddingX: 1
24942
+ }, (0, import_react.createElement)(Text, {
24943
+ color: inkColor(getPalette().brandBright),
24944
+ wrap: "truncate-end"
24945
+ }, truncateColumns(singleLineText(title), viewport.contentColumns)), ...visible.map((row) => (0, import_react.createElement)(Text, {
24946
+ key: row.key,
24947
+ color: toneColor(row.tone),
24948
+ wrap: "truncate-end"
24949
+ }, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns))), (0, import_react.createElement)(Text, {
24950
+ dimColor: true,
24951
+ wrap: "truncate-end"
24952
+ }, truncateColumns(singleLineText(updateFooter(phase, planView?.runnable ?? false, status?.upToDate === true)), viewport.contentColumns)));
24953
+ }
24735
24954
  //#endregion
24736
24955
  //#region src/whale-glyph.ts
24737
24956
  /** Half-block whale glyph rows; render with the brand color. */
@@ -24797,6 +25016,166 @@ function dshKernelVersion() {
24797
25016
  return cachedDshKernelVersion;
24798
25017
  }
24799
25018
  //#endregion
25019
+ //#region src/terminal-title.ts
25020
+ /**
25021
+ * Terminal tab/window title management for the TUI.
25022
+ *
25023
+ * Terminals label their tab from the window title, which an application sets
25024
+ * with an OSC 0 sequence; without one the tab shows the process name ("node").
25025
+ * The title text is untrusted display content (session names arrive through
25026
+ * events and user input), so it is sanitized before emission: control
25027
+ * characters and bidi/invisible formatting codepoints are stripped, whitespace
25028
+ * runs collapse to single spaces, and the result is bounded. Clearing writes
25029
+ * an empty OSC payload and the terminal falls back to its own default; the
25030
+ * previously set title is not portable to read back and is never restored.
25031
+ *
25032
+ * @module @deepseek-ai/dsh-code/terminal-title
25033
+ */
25034
+ /** The host process title at module load, restored on unmount. On Windows
25035
+ * this reaches the tab through SetConsoleTitleW (ConPTY reflects it to VS
25036
+ * Code and friends without any user setting); on POSIX it becomes the ps
25037
+ * name. Node replaces the argv memory, so the original must be saved before
25038
+ * the first assignment. */
25039
+ const initialProcessTitle = process.title;
25040
+ /** Tab label before a session carries a name. */
25041
+ const DEFAULT_TERMINAL_TITLE = "deepseek";
25042
+ /** Control characters, DEL/C1 range, and bidi or invisible formatting
25043
+ * codepoints that could terminate the OSC sequence or visually reorder the
25044
+ * title relative to its underlying text. */
25045
+ const DISALLOWED_TITLE_CHARS = /[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/;
25046
+ /** Normalize untrusted title text into one bounded display line: disallowed
25047
+ * codepoints dropped, whitespace runs collapsed to single spaces, leading and
25048
+ * trailing whitespace removed, length bounded. */
25049
+ function sanitizeTerminalTitle(text) {
25050
+ const chars = [];
25051
+ let pendingSpace = false;
25052
+ for (const ch of text) {
25053
+ if (DISALLOWED_TITLE_CHARS.test(ch)) continue;
25054
+ if (ch.trim() === "") {
25055
+ if (chars.length > 0) pendingSpace = true;
25056
+ continue;
25057
+ }
25058
+ if (pendingSpace) {
25059
+ if (chars.length + 1 >= 240) break;
25060
+ chars.push(" ");
25061
+ pendingSpace = false;
25062
+ }
25063
+ if (chars.length >= 240) break;
25064
+ chars.push(ch);
25065
+ }
25066
+ return chars.join("");
25067
+ }
25068
+ /** Build one OSC 0 title sequence. An empty sanitized title yields an empty
25069
+ * string: emitting nothing is distinct from clearing, which is a separate
25070
+ * policy decision made by the caller. */
25071
+ function terminalTitleSequence(text) {
25072
+ const title = sanitizeTerminalTitle(text);
25073
+ return title === "" ? "" : `\x1b]0;${title}\x07`;
25074
+ }
25075
+ /** Clear the managed title with an empty OSC payload; the terminal falls back
25076
+ * to its own default label. */
25077
+ function clearTerminalTitleSequence() {
25078
+ return "\x1B]0;\x07";
25079
+ }
25080
+ /** VS Code renders an application-set tab title only when
25081
+ * "terminal.integrated.tabs.title" maps to the sequence variable; the editor
25082
+ * default shows the process name instead ("node" for a Node CLI). Inside a VS
25083
+ * Code integrated terminal, align the user settings once: if the key is
25084
+ * absent, insert it and keep a one-shot backup of the original file. A value
25085
+ * the user already set is never overwritten, an unparseable file is never
25086
+ * touched, and every failure degrades to a no-op - the OSC and process-title
25087
+ * channels keep working everywhere else. */
25088
+ function ensureVsCodeTabTitleSetting(options = {}) {
25089
+ const env = options.env ?? process.env;
25090
+ if (env["TERM_PROGRAM"] !== "vscode") return {
25091
+ wrote: false,
25092
+ reason: "not-vscode"
25093
+ };
25094
+ if ((options.isTTY ?? process.stdout.isTTY) !== true) return {
25095
+ wrote: false,
25096
+ reason: "not-vscode"
25097
+ };
25098
+ let file = options.settingsFile;
25099
+ if (file === void 0) {
25100
+ const base = process.platform === "win32" ? env["APPDATA"] : env["HOME"];
25101
+ if (base === void 0 || base === "") return {
25102
+ wrote: false,
25103
+ reason: "error"
25104
+ };
25105
+ file = process.platform === "win32" ? join(base, "Code", "User", "settings.json") : join(base, ".config", "Code", "User", "settings.json");
25106
+ }
25107
+ try {
25108
+ if (!existsSync(file)) {
25109
+ mkdirSync(dirname(file), { recursive: true });
25110
+ writeFileSync(file, "{\n \"terminal.integrated.tabs.title\": \"${sequence}\"\n}\n", "utf8");
25111
+ return { wrote: true };
25112
+ }
25113
+ const text = readFileSync(file, "utf8");
25114
+ if (text.includes("\"terminal.integrated.tabs.title\"")) return {
25115
+ wrote: false,
25116
+ reason: "key-present"
25117
+ };
25118
+ try {
25119
+ JSON.parse(text);
25120
+ } catch {
25121
+ return {
25122
+ wrote: false,
25123
+ reason: "unparseable"
25124
+ };
25125
+ }
25126
+ const close = text.lastIndexOf("}");
25127
+ if (close < 0) return {
25128
+ wrote: false,
25129
+ reason: "unparseable"
25130
+ };
25131
+ const before = text.slice(0, close);
25132
+ const trimmedBefore = before.replace(/[ \t\r\n]+$/, "");
25133
+ const tail = before.slice(trimmedBefore.length);
25134
+ const inserted = (trimmedBefore.trim() !== "" && !trimmedBefore.trimEnd().endsWith("{") ? "," : "") + "\n \"terminal.integrated.tabs.title\": \"${sequence}\"\n";
25135
+ writeFileSync(file + ".dsh-backup", text, "utf8");
25136
+ writeFileSync(file, trimmedBefore + inserted + tail + text.slice(close), "utf8");
25137
+ return { wrote: true };
25138
+ } catch {
25139
+ return {
25140
+ wrote: false,
25141
+ reason: "error"
25142
+ };
25143
+ }
25144
+ }
25145
+ /**
25146
+ * Keep the terminal tab label on `title` (sanitized; empty titles leave the
25147
+ * current label alone). Two delivery channels run in parallel: the OSC 0
25148
+ * sequence to stdout, and the host process title. Writes are deduplicated by
25149
+ * title, and on unmount the managed title is cleared and the process title
25150
+ * restored so the host shell regains its default label.
25151
+ */
25152
+ function useTerminalTitle(title, options = {}) {
25153
+ const { clearOnUnmount = true } = options;
25154
+ const { stdout } = useStdout();
25155
+ const writtenRef = (0, import_react.useRef)(void 0);
25156
+ const processTitleRef = (0, import_react.useRef)(false);
25157
+ (0, import_react.useLayoutEffect)(() => {
25158
+ if (stdout === void 0) return void 0;
25159
+ return () => {
25160
+ if (processTitleRef.current) {
25161
+ processTitleRef.current = false;
25162
+ process.title = initialProcessTitle;
25163
+ }
25164
+ if (clearOnUnmount && writtenRef.current !== void 0) stdout.write(clearTerminalTitleSequence());
25165
+ };
25166
+ }, [stdout, clearOnUnmount]);
25167
+ (0, import_react.useLayoutEffect)(() => {
25168
+ if (stdout === void 0) return;
25169
+ if (title === writtenRef.current) return;
25170
+ const sequence = terminalTitleSequence(title);
25171
+ if (sequence === "") return;
25172
+ stdout.write(sequence);
25173
+ process.title = sanitizeTerminalTitle(title);
25174
+ processTitleRef.current = true;
25175
+ writtenRef.current = title;
25176
+ }, [stdout, title]);
25177
+ }
25178
+ //#endregion
24800
25179
  //#region src/render/tool-preview.ts
24801
25180
  /**
24802
25181
  * Bounded preview line for a tool invocation's raw JSON arguments: the first
@@ -25083,6 +25462,40 @@ function appendStreamingTail(current, delta) {
25083
25462
  const next = current + delta;
25084
25463
  return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-65536);
25085
25464
  }
25465
+ /** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */
25466
+ function applyScheduleChange(rows, data) {
25467
+ if (data.operation === "create" && data.schedule !== void 0) {
25468
+ const schedule = data.schedule;
25469
+ const row = {
25470
+ id: schedule.id,
25471
+ kind: schedule.kind,
25472
+ prompt: schedule.prompt,
25473
+ targetAt: Date.parse(schedule.scheduledAt),
25474
+ ...schedule.kind === "every" ? { everySeconds: schedule.everySeconds ?? 0 } : {}
25475
+ };
25476
+ return [...rows.filter((existing) => existing.id !== schedule.id), row];
25477
+ }
25478
+ if (data.operation === "delete" && data.id !== void 0) return rows.filter((existing) => existing.id !== data.id);
25479
+ if (data.operation === "dispatch" && data.id !== void 0) {
25480
+ if (data.acceptedAt === void 0) return rows.filter((existing) => existing.id !== data.id);
25481
+ const accepted = Date.parse(data.acceptedAt);
25482
+ return rows.map((existing) => existing.id === data.id ? {
25483
+ ...existing,
25484
+ targetAt: nextEveryTarget(existing.targetAt, accepted, existing.everySeconds ?? 0)
25485
+ } : existing);
25486
+ }
25487
+ return rows;
25488
+ }
25489
+ /** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */
25490
+ function nextEveryTarget(previousTarget, acceptedAt, everySeconds) {
25491
+ const interval = Math.max(1, everySeconds) * 1e3;
25492
+ if (acceptedAt <= previousTarget) return previousTarget + interval;
25493
+ return previousTarget + Math.ceil((acceptedAt - previousTarget + 1) / interval) * interval;
25494
+ }
25495
+ /** Plugin snapshot sources folded into token stats but never rendered as rows. */
25496
+ const HIDDEN_SNAPSHOT_PLUGINS = /* @__PURE__ */ new Set(["time-context", "tmux-context"]);
25497
+ /** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
25498
+ const REMINDER_PLUGINS = /* @__PURE__ */ new Set(["schedule"]);
25086
25499
  /** Assemble the effective system prompt from surface nodes: head text plus every later non-empty node. */
25087
25500
  function assembleSystemPrompt(nodes) {
25088
25501
  if (nodes.size === 0) return "";
@@ -25202,6 +25615,7 @@ function createReplayAccumulator() {
25202
25615
  systemPrompt: "",
25203
25616
  sandbox: "",
25204
25617
  goal: void 0,
25618
+ schedules: [],
25205
25619
  stats: {
25206
25620
  turns: 0,
25207
25621
  steps: 0,
@@ -25348,7 +25762,7 @@ function replayProjectEvent(acc, event) {
25348
25762
  const text = textOf(message.content);
25349
25763
  const images = imagesOf(message.content);
25350
25764
  const files = filesOf(message.content);
25351
- if (message.source.kind === "user") {
25765
+ if (message.source.kind === "user" || message.source.kind === "plugin" && REMINDER_PLUGINS.has(message.source.plugin)) {
25352
25766
  appendReplayEntry(acc, {
25353
25767
  kind: "user",
25354
25768
  text,
@@ -25365,7 +25779,17 @@ function replayProjectEvent(acc, event) {
25365
25779
  };
25366
25780
  return true;
25367
25781
  }
25368
- const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
25782
+ if (message.source.kind === "plugin" && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
25783
+ acc.stats = {
25784
+ ...acc.stats,
25785
+ contextSegments: {
25786
+ ...acc.stats.contextSegments,
25787
+ system: acc.stats.contextSegments.system + estimateTokens(text)
25788
+ }
25789
+ };
25790
+ return true;
25791
+ }
25792
+ const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind === "plugin" ? message.source.plugin : message.source.kind;
25369
25793
  const summary = boundContextSummary(notice);
25370
25794
  appendReplayEntry(acc, {
25371
25795
  kind: "user",
@@ -25683,6 +26107,10 @@ function replayProjectEvent(acc, event) {
25683
26107
  });
25684
26108
  return true;
25685
26109
  }
26110
+ case "schedule/change":
26111
+ acc.schedules = applyScheduleChange(acc.schedules, event.data);
26112
+ acc.ops += 1;
26113
+ return true;
25686
26114
  case "session/title":
25687
26115
  acc.title = event.data.title;
25688
26116
  return true;
@@ -25787,6 +26215,7 @@ function materializeReplayView(acc, copy) {
25787
26215
  systemPrompt: acc.systemPrompt,
25788
26216
  sandbox: acc.sandbox,
25789
26217
  goal: acc.goal,
26218
+ schedules: acc.schedules,
25790
26219
  pending: {
25791
26220
  "next-turn": [...acc.pendingTurn],
25792
26221
  "next-step": [...acc.pendingStep]
@@ -25879,6 +26308,132 @@ function settledEntryCount(entries) {
25879
26308
  return entries.length;
25880
26309
  }
25881
26310
  //#endregion
26311
+ //#region src/render/ime-cursor.ts
26312
+ /**
26313
+ * IME cursor anchoring for the composer.
26314
+ *
26315
+ * Ink keeps the real terminal cursor hidden and parks it just below the
26316
+ * dynamic tree (after the status row). IME composition text and candidate
26317
+ * windows anchor to that real cursor cell - VS Code's integrated terminal
26318
+ * positions its hidden IME textarea there, and Windows consoles behave the
26319
+ * same - so CJK input appeared at the bottom of the screen instead of at the
26320
+ * caret. The anchor moves the real cursor onto the caret cell without
26321
+ * touching Ink's relative erase ledger:
26322
+ *
26323
+ * - the displacement is owned: before any foreign write or re-anchor, the
26324
+ * wrapper cancels it (cursor down, column 1), so every writer - Ink's
26325
+ * log-update rewrites, the resize replay, protocol pushes - keeps seeing
26326
+ * the cursor exactly where it was left;
26327
+ * - log-update frame chunks (which start with the erase-line sequence) get
26328
+ * the anchor re-appended inside the same write, so a repaint can never
26329
+ * leave the cursor behind - in particular the caret blink keeps the anchor
26330
+ * stable while an IME composition is open, because the terminal parses the
26331
+ * rewrite and the re-anchor as one atomic update.
26332
+ *
26333
+ * @module @deepseek-ai/dsh-code/render/ime-cursor
26334
+ */
26335
+ /** Cancel `rows` of owned upward displacement and return to column 1. */
26336
+ function imeCursorRestore(rows) {
26337
+ return rows > 0 ? `\x1b[${rows}B\r` : "";
26338
+ }
26339
+ /** Move onto the caret cell: `rows` up from Ink's parked row, 0-based column. */
26340
+ function imeCursorMove(rows, column) {
26341
+ return rows > 0 ? `\x1b[${rows}A\x1b[${column + 1}G` : "";
26342
+ }
26343
+ /**
26344
+ * Rows between the caret cell and Ink's parked cursor row: the band's bottom
26345
+ * blank row, the editor window rows below the caret, the status footer, and
26346
+ * Ink's own below-frame row. Rows above the composer (gutter, live content)
26347
+ * never enter this distance.
26348
+ */
26349
+ function imeCursorRowsUp(input) {
26350
+ return Math.max(1, input.editorWindowRows - input.caretRowInWindow + input.rowsBelowComposer);
26351
+ }
26352
+ /** log-update chunks start with the erase-line sequence; a chunk without it
26353
+ * leaves no frame behind, so the next commit re-applies the anchor. */
26354
+ const FRAME_CHUNK_MARKER = "\x1B[2K";
26355
+ const ANCHOR_STATE = Symbol.for("dsh-code.ime-cursor-anchor");
26356
+ /**
26357
+ * Take over `stream.write` so the anchor displacement stays invisible to every
26358
+ * other writer. Idempotent per stream: a second install returns the live
26359
+ * handle. Returns `undefined` on non-TTY streams where anchoring is meaningless.
26360
+ */
26361
+ function installImeCursorAnchor(stream) {
26362
+ if (stream?.isTTY !== true) return void 0;
26363
+ const target = stream;
26364
+ const installed = target[ANCHOR_STATE];
26365
+ if (installed !== void 0) return installed;
26366
+ const originalWrite = target.write.bind(target);
26367
+ let rows = 0;
26368
+ let column = -1;
26369
+ let detached = false;
26370
+ const anchor = {
26371
+ anchor(nextRows, nextColumn) {
26372
+ if (detached) return;
26373
+ if (nextRows <= 0) {
26374
+ if (rows !== 0) originalWrite(imeCursorRestore(rows));
26375
+ rows = 0;
26376
+ column = -1;
26377
+ return;
26378
+ }
26379
+ if (rows === nextRows && column === nextColumn) return;
26380
+ originalWrite(imeCursorRestore(rows) + imeCursorMove(nextRows, nextColumn));
26381
+ rows = nextRows;
26382
+ column = nextColumn;
26383
+ },
26384
+ release() {
26385
+ if (detached) return;
26386
+ detached = true;
26387
+ if (rows !== 0) originalWrite(imeCursorRestore(rows));
26388
+ rows = 0;
26389
+ column = -1;
26390
+ target.write = originalWrite;
26391
+ delete target[ANCHOR_STATE];
26392
+ }
26393
+ };
26394
+ target.write = ((chunk, ...rest) => {
26395
+ const ownedRows = rows;
26396
+ const ownedColumn = column;
26397
+ if (ownedRows === 0 || typeof chunk !== "string") {
26398
+ if (ownedRows !== 0) {
26399
+ originalWrite(imeCursorRestore(ownedRows));
26400
+ rows = 0;
26401
+ column = -1;
26402
+ }
26403
+ return originalWrite(chunk, ...rest);
26404
+ }
26405
+ const reanchor = chunk.startsWith(FRAME_CHUNK_MARKER);
26406
+ rows = reanchor ? ownedRows : 0;
26407
+ column = reanchor ? ownedColumn : -1;
26408
+ return originalWrite(imeCursorRestore(ownedRows) + chunk + (reanchor ? imeCursorMove(ownedRows, ownedColumn) : ""), ...rest);
26409
+ });
26410
+ target[ANCHOR_STATE] = anchor;
26411
+ return anchor;
26412
+ }
26413
+ /**
26414
+ * Keep the real terminal cursor on the composer's caret cell while `active`
26415
+ * (the editable composer). The second effect runs after every commit without
26416
+ * a dep list: frame rewrites restore the anchor themselves, but any other
26417
+ * write (protocol push, resize replay) leaves the cursor at Ink's parked
26418
+ * position, and the next commit re-anchors it.
26419
+ */
26420
+ function useImeCursorAnchor(active, rows, column) {
26421
+ const { stdout } = useStdout();
26422
+ const anchorRef = (0, import_react.useRef)(void 0);
26423
+ (0, import_react.useEffect)(() => {
26424
+ if (stdout === void 0) return void 0;
26425
+ const installed = installImeCursorAnchor(stdout);
26426
+ anchorRef.current = installed;
26427
+ return () => {
26428
+ installed?.release();
26429
+ if (anchorRef.current === installed) anchorRef.current = void 0;
26430
+ };
26431
+ }, [stdout]);
26432
+ (0, import_react.useEffect)(() => {
26433
+ anchorRef.current?.anchor(active && rows > 0 ? rows : 0, column);
26434
+ });
26435
+ }
26436
+ //#endregion
25882
26437
  //#region src/render/markdown.ts
25883
26438
  /**
25884
26439
  * Terminal markdown renderer for assistant replies: a pure GFM-subset
@@ -28384,14 +28939,6 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
28384
28939
  }];
28385
28940
  const right = [];
28386
28941
  const row2 = [];
28387
- if (facts.plan && enabled.has("plan")) row2.push({
28388
- group: { spans: [{
28389
- text: "⧉ plan",
28390
- tone: "accent"
28391
- }] },
28392
- rank: RANK2_PLAN,
28393
- id: "plan"
28394
- });
28395
28942
  if (stats.turns > 0 || stats.steps > 0) {
28396
28943
  if (enabled.has("turns")) {
28397
28944
  const counts = [];
@@ -28510,9 +29057,13 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
28510
29057
  });
28511
29058
  const permission = safe(facts.permission);
28512
29059
  let badge = -1;
29060
+ const planStation = facts.plan && permissionTone(permission) === "success";
28513
29061
  if (permission !== "" && enabled.has("permission")) {
28514
29062
  right.push({
28515
- span: {
29063
+ span: planStation ? {
29064
+ text: "plan on",
29065
+ tone: "plan"
29066
+ } : {
28516
29067
  text: permission,
28517
29068
  tone: permissionTone(permission)
28518
29069
  },
@@ -28521,6 +29072,14 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
28521
29072
  });
28522
29073
  badge = right.length - 1;
28523
29074
  }
29075
+ if (facts.plan && enabled.has("plan")) row2.push({
29076
+ group: { spans: [{
29077
+ text: "⧉ plan",
29078
+ tone: "accent"
29079
+ }] },
29080
+ rank: RANK2_PLAN,
29081
+ id: "plan"
29082
+ });
28524
29083
  return {
28525
29084
  left,
28526
29085
  right,
@@ -29362,14 +29921,15 @@ function composerMaxRows(terminalRows) {
29362
29921
  return Math.max(1, Math.min(6, Math.floor((Math.max(1, terminalRows) - 10) / 3)));
29363
29922
  }
29364
29923
  /**
29365
- * History navigation starts with Up on an empty draft, or after visual
29366
- * movement has reached the directional text edge of an unchanged recalled
29367
- * entry. Every other position remains under textarea movement.
29924
+ * History navigation starts with Up on an empty draft, or continues from an
29925
+ * unchanged recalled entry whenever the caret sits on either text edge
29926
+ * (start or end) - moving the caret into the interior returns the keys to
29927
+ * ordinary editing until an edge is reached again.
29368
29928
  */
29369
29929
  function shouldRecallNavigate(value, cursor, lastRecalled, direction) {
29370
29930
  if (value === "") return direction < 0;
29371
29931
  if (lastRecalled !== value) return false;
29372
- return direction < 0 ? cursor === 0 : cursor === value.length;
29932
+ return cursor === 0 || cursor === value.length;
29373
29933
  }
29374
29934
  //#endregion
29375
29935
  //#region src/keyboard.ts
@@ -29710,7 +30270,8 @@ function ModePanel({ current, load, select, close }) {
29710
30270
  const visible = (0, import_react.useMemo)(() => rows.filter((row) => `${row.id} ${row.name ?? ""} ${row.description ?? ""}`.toLowerCase().includes(query.toLowerCase())), [rows, query]);
29711
30271
  (0, import_react.useEffect)(() => setCursor((value) => Math.min(value, Math.max(0, visible.length - 1))), [visible.length]);
29712
30272
  useInput((input, key) => {
29713
- if (key.escape || input === "q") return close();
30273
+ if (key.escape) return close();
30274
+ if (input === "q" && query === "") return close();
29714
30275
  if (input === "r" && query === "") return refresh();
29715
30276
  if (key.upArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length);
29716
30277
  if (key.downArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + 1) % visible.length);
@@ -29756,7 +30317,8 @@ function PermissionPanel({ current, load, select, close }) {
29756
30317
  const visible = (0, import_react.useMemo)(() => rows.filter((row) => `${row.id} ${row.description ?? ""}`.toLowerCase().includes(query.toLowerCase())), [rows, query]);
29757
30318
  (0, import_react.useEffect)(() => setCursor((value) => Math.min(value, Math.max(0, visible.length - 1))), [visible.length]);
29758
30319
  useInput((input, key) => {
29759
- if (key.escape || input === "q") return close();
30320
+ if (key.escape) return close();
30321
+ if (input === "q" && query === "") return close();
29760
30322
  if (input === "r" && query === "") return refresh();
29761
30323
  if (key.upArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length);
29762
30324
  if (key.downArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + 1) % visible.length);
@@ -29788,7 +30350,8 @@ function PluginPanel({ load, close, initialQuery = "" }) {
29788
30350
  const rows = (0, import_react.useMemo)(() => load().filter((row) => `${row.entryId} ${row.moduleName} ${row.phase ?? ""}`.toLowerCase().includes(query.toLowerCase())), [epoch, query]);
29789
30351
  (0, import_react.useEffect)(() => setCursor((value) => Math.min(value, Math.max(0, rows.length - 1))), [rows.length]);
29790
30352
  useInput((input, key) => {
29791
- if (key.escape || input === "q") return close();
30353
+ if (key.escape) return close();
30354
+ if (input === "q" && query === "") return close();
29792
30355
  if (input === "r" && query === "") return setEpoch((value) => value + 1);
29793
30356
  if (key.upArrow) return setCursor((value) => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length);
29794
30357
  if (key.downArrow) return setCursor((value) => rows.length === 0 ? 0 : (value + 1) % rows.length);
@@ -30469,6 +31032,69 @@ function SubagentPanel({ current, load, pick, inherit, close }) {
30469
31032
  footer: "↑↓ choose · enter apply · r refresh · esc close"
30470
31033
  });
30471
31034
  }
31035
+ /** Human frequency label: one-shot kinds read as Once, every rows carry the interval. */
31036
+ function scheduleFrequency(row) {
31037
+ if (row.kind !== "every") return "Once";
31038
+ const seconds = row.everySeconds ?? 0;
31039
+ if (seconds >= 3600 && seconds % 3600 === 0) return `Every ${seconds / 3600}h`;
31040
+ if (seconds >= 60 && seconds % 60 === 0) return `Every ${seconds / 60}m`;
31041
+ return `Every ${seconds}s`;
31042
+ }
31043
+ /** Relative label for the next target: in N unit, or N unit overdue. */
31044
+ function scheduleRelative(targetAt, now) {
31045
+ const delta = Math.max(0, Math.abs(targetAt - now));
31046
+ const minutes = Math.floor(delta / 6e4);
31047
+ const unit = minutes === 0 ? "<1m" : minutes >= 60 ? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}` : `${minutes}m`;
31048
+ return targetAt <= now ? `${unit} overdue` : `in ${unit}`;
31049
+ }
31050
+ /** Ordered display rows: overdue first (error tone), then ascending target. */
31051
+ function scheduleDisplayRows(rows, now) {
31052
+ return [...rows].sort((left, right) => Number(left.targetAt > now) - Number(right.targetAt > now) || left.targetAt - right.targetAt).map((row) => ({
31053
+ key: row.id,
31054
+ text: `${row.prompt} · ${scheduleFrequency(row)} · ${new Date(row.targetAt).toLocaleString()} (${scheduleRelative(row.targetAt, now)})`,
31055
+ tone: row.targetAt <= now ? "error" : void 0
31056
+ }));
31057
+ }
31058
+ function SchedulePanel({ rows, close }) {
31059
+ const [, setTick] = (0, import_react.useState)(0);
31060
+ (0, import_react.useEffect)(() => {
31061
+ const id = setInterval(() => setTick((value) => value + 1), 1e3);
31062
+ return () => clearInterval(id);
31063
+ }, []);
31064
+ const display = scheduleDisplayRows(rows(), Date.now());
31065
+ const stdout = useStdout().stdout;
31066
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
31067
+ useInput((input, key) => {
31068
+ if (key.escape || input === "q") return close();
31069
+ });
31070
+ if (viewport.maxHeight === 0 || viewport.compact) {
31071
+ const summary = display.length === 0 ? "no active reminders" : singleLineText(display[0].text);
31072
+ return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns));
31073
+ }
31074
+ const budget = Math.max(1, viewport.bodyRows);
31075
+ const visible = display.slice(0, budget);
31076
+ const hidden = display.length - visible.length;
31077
+ return (0, import_react.createElement)(Box, {
31078
+ width: viewport.outerColumns,
31079
+ borderStyle: "round",
31080
+ borderColor: inkColor(getPalette().dim),
31081
+ flexDirection: "column",
31082
+ paddingX: 1
31083
+ }, (0, import_react.createElement)(Text, {
31084
+ color: inkColor(getPalette().brandBright),
31085
+ wrap: "truncate-end"
31086
+ }, truncateColumns(`/schedule · ${display.length} active reminder${display.length === 1 ? "" : "s"}`, viewport.contentColumns)), ...display.length === 0 ? [(0, import_react.createElement)(Text, {
31087
+ dimColor: true,
31088
+ wrap: "truncate-end"
31089
+ }, truncateColumns(" no active reminders — the model creates them with schedule_create", viewport.contentColumns))] : visible.map((row) => (0, import_react.createElement)(Text, {
31090
+ key: row.key,
31091
+ color: row.tone === "error" ? inkColor(getPalette().error) : void 0,
31092
+ wrap: "truncate-end"
31093
+ }, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns))), (0, import_react.createElement)(Text, {
31094
+ dimColor: true,
31095
+ wrap: "truncate-end"
31096
+ }, truncateColumns(`esc/q close${hidden > 0 ? ` · +${hidden} more` : ""} · the model schedules via schedule_create`, viewport.contentColumns)));
31097
+ }
30472
31098
  /** Encode one entry for the history file (JSON keeps multi-line drafts intact). */
30473
31099
  function serializeHistoryEntry(text) {
30474
31100
  return JSON.stringify(text);
@@ -31304,10 +31930,18 @@ const LOCAL_COMMANDS = [
31304
31930
  label: "/plugin",
31305
31931
  description: "inspect the live plugin composition"
31306
31932
  },
31933
+ {
31934
+ label: "/update",
31935
+ description: "update dsh-code, the harness host, and profile plugins in one aligned step"
31936
+ },
31307
31937
  {
31308
31938
  label: "/jobs",
31309
31939
  description: "inspect background jobs"
31310
31940
  },
31941
+ {
31942
+ label: "/schedule",
31943
+ description: "inspect active reminders (created through schedule tools)"
31944
+ },
31311
31945
  {
31312
31946
  label: "/statusline",
31313
31947
  description: "customize the status line items"
@@ -31882,6 +32516,11 @@ function statusToneProps(tone) {
31882
32516
  bold: true,
31883
32517
  dimColor: void 0
31884
32518
  };
32519
+ case "plan": return {
32520
+ color: inkColor(getPalette().success),
32521
+ bold: true,
32522
+ dimColor: void 0
32523
+ };
31885
32524
  case "warn": return {
31886
32525
  color: inkColor(getPalette().warn),
31887
32526
  bold: true,
@@ -31935,7 +32574,7 @@ function deepseekWaveHues(tier) {
31935
32574
  palette.brandMid
31936
32575
  ];
31937
32576
  }
31938
- function StatusLine({ facts, stats, busy, columns, items }) {
32577
+ function StatusLine({ facts, stats, busy, columns, items, onRows }) {
31939
32578
  const layout = (0, import_react.useMemo)(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
31940
32579
  busy,
31941
32580
  items,
@@ -31958,6 +32597,10 @@ function StatusLine({ facts, stats, busy, columns, items }) {
31958
32597
  columns,
31959
32598
  items
31960
32599
  ]);
32600
+ const statusRowCount = layout.row2.left.length > 0 ? 2 : 1;
32601
+ (0, import_react.useEffect)(() => {
32602
+ onRows?.(statusRowCount);
32603
+ }, [onRows, statusRowCount]);
31961
32604
  const renderRow = (row, key, indent = 0) => {
31962
32605
  const leftParts = [];
31963
32606
  row.left.forEach((group, groupIndex) => {
@@ -32498,36 +33141,44 @@ function QuestionBar({ store, snapshot, locked }) {
32498
33141
  }
32499
33142
  /** The /model panel: a scrolling list over the advisory model directory. */
32500
33143
  function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry, onClose }) {
33144
+ const [query, setQuery] = (0, import_react.useState)("");
32501
33145
  const [cursor, setCursor] = (0, import_react.useState)(0);
32502
33146
  const stdout = useStdout().stdout;
32503
33147
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
32504
33148
  const rows = directory?.rows ?? [];
33149
+ const filtered = (0, import_react.useMemo)(() => {
33150
+ if (query === "") return rows;
33151
+ const needle = query.toLowerCase();
33152
+ return rows.filter((row) => `${row.provider} ${row.providerName ?? ""} ${row.model} ${row.modelName}`.toLowerCase().includes(needle));
33153
+ }, [rows, query]);
32505
33154
  const positioned = (0, import_react.useRef)(false);
32506
33155
  (0, import_react.useEffect)(() => {
32507
33156
  if (positioned.current || rows.length === 0 || current === void 0) {
32508
- if (rows.length === 0) {
33157
+ if (filtered.length === 0) {
32509
33158
  if (cursor !== 0) setCursor(0);
32510
33159
  return;
32511
33160
  }
32512
- if (cursor >= rows.length) setCursor(rows.length - 1);
33161
+ if (cursor >= filtered.length) setCursor(filtered.length - 1);
32513
33162
  return;
32514
33163
  }
32515
33164
  const index = rows.findIndex((row) => `${row.provider}/${row.model}` === current);
32516
33165
  if (index >= 0) {
32517
33166
  positioned.current = true;
32518
- setCursor(index);
32519
- } else if (cursor >= rows.length) setCursor(Math.max(0, rows.length - 1));
33167
+ const filteredIndex = filtered.indexOf(rows[index]);
33168
+ setCursor(filteredIndex >= 0 ? filteredIndex : 0);
33169
+ } else if (cursor >= filtered.length) setCursor(Math.max(0, filtered.length - 1));
32520
33170
  }, [
32521
33171
  rows,
33172
+ filtered,
32522
33173
  cursor,
32523
33174
  current
32524
33175
  ]);
32525
33176
  useInput((input, key) => {
32526
- if (key.escape || input === "q") {
33177
+ if (key.escape || input === "q" && query === "") {
32527
33178
  onClose();
32528
33179
  return;
32529
33180
  }
32530
- if (input === "r") {
33181
+ if (input === "r" && query === "") {
32531
33182
  onRetry();
32532
33183
  return;
32533
33184
  }
@@ -32539,13 +33190,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
32539
33190
  onClose();
32540
33191
  return;
32541
33192
  }
32542
- if (rows.length === 0) return;
33193
+ const next = editQuery(query, input, key);
33194
+ if (next !== void 0) {
33195
+ setQuery(next);
33196
+ setCursor(0);
33197
+ return;
33198
+ }
33199
+ if (filtered.length === 0) return;
32543
33200
  if (key.upArrow) {
32544
- setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
33201
+ setCursor(cursor > 0 ? cursor - 1 : filtered.length - 1);
32545
33202
  return;
32546
33203
  }
32547
33204
  if (key.downArrow) {
32548
- setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
33205
+ setCursor(cursor < filtered.length - 1 ? cursor + 1 : 0);
32549
33206
  return;
32550
33207
  }
32551
33208
  if (key.pageUp) {
@@ -32553,23 +33210,15 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
32553
33210
  return;
32554
33211
  }
32555
33212
  if (key.pageDown) {
32556
- setCursor((current) => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)));
32557
- return;
32558
- }
32559
- if (input === "g") {
32560
- setCursor(0);
32561
- return;
32562
- }
32563
- if (input === "G") {
32564
- setCursor(rows.length - 1);
33213
+ setCursor((current) => Math.min(filtered.length - 1, current + Math.max(1, viewport.bodyRows - 1)));
32565
33214
  return;
32566
33215
  }
32567
- if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
33216
+ if (key.return && filtered[cursor] !== void 0) onSelect(filtered[cursor]);
32568
33217
  });
32569
33218
  if (viewport.maxHeight === 0 || viewport.compact) {
32570
33219
  const providers = onProviders === void 0 ? "" : " · tab providers";
32571
- const state = rows.length === 0 ? directory === void 0 && error === void 0 ? "loading…" : error !== void 0 ? "error" : "no models" : `❯ ${rows[cursor]?.modelName ?? rows[cursor]?.model ?? ""}`;
32572
- return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(`/model · ${state}${providers} · r retry · esc/q close`, viewport.contentColumns));
33220
+ const state = filtered.length === 0 ? directory === void 0 && error === void 0 ? "loading…" : error !== void 0 ? "error" : query === "" ? "no models" : `no match for '${singleLineText(query)}'` : `❯ ${filtered[cursor]?.modelName ?? filtered[cursor]?.model ?? ""}`;
33221
+ return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(`/model · ${state}${providers} · ${query === "" ? "type to filter · r retry · esc/q close" : "backspace edits · esc close"}`, viewport.contentColumns));
32573
33222
  }
32574
33223
  const visibleStateRows = (directory === void 0 && error === void 0 ? [(0, import_react.createElement)(Text, {
32575
33224
  key: "loading",
@@ -32587,10 +33236,14 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
32587
33236
  key: "empty",
32588
33237
  dimColor: true,
32589
33238
  wrap: "truncate-end"
32590
- }, " no models available")] : []]).slice(0, viewport.bodyRows);
33239
+ }, " no models available")] : filtered.length === 0 ? [(0, import_react.createElement)(Text, {
33240
+ key: "no-match",
33241
+ dimColor: true,
33242
+ wrap: "truncate-end"
33243
+ }, truncateColumns(` no models match '${singleLineText(query)}'`, viewport.contentColumns))] : []]).slice(0, viewport.bodyRows);
32591
33244
  const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length);
32592
- const first = selectionWindow(cursor, rows.length, rowBudget);
32593
- const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget);
33245
+ const first = selectionWindow(cursor, filtered.length, rowBudget);
33246
+ const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget);
32594
33247
  return (0, import_react.createElement)(Box, {
32595
33248
  flexDirection: "column",
32596
33249
  width: viewport.outerColumns,
@@ -32601,8 +33254,8 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
32601
33254
  color: inkColor(getPalette().brand),
32602
33255
  bold: true,
32603
33256
  wrap: "truncate-end"
32604
- }, truncateColumns(`/model — select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), ...visibleStateRows, ...visible.map((row) => {
32605
- const index = rows.indexOf(row);
33257
+ }, truncateColumns(query === "" ? `/model — select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}` : `/model — select model · ${filtered.length} of ${rows.length} match '${singleLineText(query)}'`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), ...visibleStateRows, ...visible.map((row) => {
33258
+ const index = filtered.indexOf(row);
32606
33259
  const capability = row.inputModalities?.includes("image") === true ? " · image" : "";
32607
33260
  const label = displayText(`${row.providerName} · ${row.modelName}${capability}`);
32608
33261
  return (0, import_react.createElement)(Text, {
@@ -32613,7 +33266,7 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
32613
33266
  }), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(Text, {
32614
33267
  dimColor: true,
32615
33268
  wrap: "truncate-end"
32616
- }, dim(truncateColumns(`↑↓ move · pgup/pgdn page · enter select${onProviders === void 0 ? "" : " · tab providers"} · r retry · esc/q close`, viewport.contentColumns))));
33269
+ }, dim(truncateColumns(query === "" ? `type to filter · ↑↓ move · pgup/pgdn page · enter select${onProviders === void 0 ? "" : " · tab providers"} · r retry · esc/q close` : `↑↓ move · pgup/pgdn page · enter select · backspace edits · esc close`, viewport.contentColumns))));
32617
33270
  }
32618
33271
  /** Compact provider-state copy; only value-free credential facts cross this boundary. */
32619
33272
  function providerStateLabel(row) {
@@ -33916,9 +34569,12 @@ function CompletionMenu({ active, mention, index, rows, error }) {
33916
34569
  * While a modal (approval / question / model panel) owns the keys, the
33917
34570
  * box passes every key through untouched.
33918
34571
  */
33919
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, onEditorRows, onMenuRows, sessionKey }) {
33920
- const columns = useStdout().stdout?.columns ?? 80;
33921
- const inputTerminalRows = useStdout().stdout?.rows ?? 30;
34572
+ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openUpdate, openSchedule, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }) {
34573
+ const { stdout: inputStdout } = useStdout();
34574
+ const columns = inputStdout?.columns ?? 80;
34575
+ const inputTerminalRows = inputStdout?.rows ?? 30;
34576
+ const tabTitleRef = (0, import_react.useRef)(tabTitle);
34577
+ tabTitleRef.current = tabTitle;
33922
34578
  const editorColumns = Math.max(1, columns - 6);
33923
34579
  const stdin = useStdin().stdin;
33924
34580
  const focusReporting = isVsCodeTerminalEnv();
@@ -34004,6 +34660,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34004
34660
  const normalized = normalizeKeyboardChunk(typeof chunk === "string" ? chunk : String(chunk));
34005
34661
  const input = focusReporting ? stripTerminalFocusEvents(normalized, (focused) => {
34006
34662
  terminalFocusedRef.current = focused;
34663
+ if (focused && inputStdout !== void 0) {
34664
+ inputStdout.write(terminalTitleSequence(tabTitleRef.current));
34665
+ process.title = sanitizeTerminalTitle(tabTitleRef.current);
34666
+ }
34007
34667
  }) : normalized;
34008
34668
  rawEditorTokens.current = tokenizeRawEditorChunk(input);
34009
34669
  return input;
@@ -34313,20 +34973,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34313
34973
  dismissNotice();
34314
34974
  notify("image submission cancelled", "warning");
34315
34975
  };
34316
- /** Move through visual rows first, then cross history at the true edge. */
34976
+ /** Cross history while an unchanged recalled draft rests its caret on
34977
+ * either text edge; between the edges (or inside ordinary drafts) the
34978
+ * arrows move through visual rows first. */
34317
34979
  const navigateVertical = (direction) => {
34318
34980
  const currentValue = valueRef.current;
34319
34981
  const currentCursor = cursorRef.current;
34320
- const model = editorModel(currentValue, editorColumns);
34321
- const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column;
34322
- const next = moveCursorVertically(model, currentCursor, preferred, direction);
34323
- if (next !== currentCursor) {
34324
- cursorRef.current = next;
34325
- setCursor(next);
34326
- resetCursorBlink();
34327
- preferredColumnRef.current = preferred;
34328
- return;
34329
- }
34330
34982
  if (recall.current.entries.length > 0 && shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
34331
34983
  const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current);
34332
34984
  recall.current = step.state;
@@ -34337,10 +34989,20 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34337
34989
  setValue(safe);
34338
34990
  setCursor(safe.length);
34339
34991
  preferredColumnRef.current = null;
34340
- setDismissedMenuValue(void 0);
34992
+ setDismissedMenuValue(safe);
34341
34993
  }
34994
+ resetCursorBlink();
34995
+ return;
34996
+ }
34997
+ const model = editorModel(currentValue, editorColumns);
34998
+ const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column;
34999
+ const next = moveCursorVertically(model, currentCursor, preferred, direction);
35000
+ if (next !== currentCursor) {
35001
+ cursorRef.current = next;
35002
+ setCursor(next);
35003
+ resetCursorBlink();
35004
+ preferredColumnRef.current = preferred;
34342
35005
  }
34343
- resetCursorBlink();
34344
35006
  };
34345
35007
  useStableInput((input, key) => {
34346
35008
  if (!active) return;
@@ -34357,8 +35019,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34357
35019
  }
34358
35020
  if (key.tab && key.shift) {
34359
35021
  try {
34360
- const next = cyclePermission();
34361
- if (next !== "") notify(`permission → ${next}`);
35022
+ const label = cycleMode();
35023
+ if (label !== "") notify(label);
34362
35024
  } catch (error) {
34363
35025
  notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, "error");
34364
35026
  }
@@ -34482,10 +35144,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34482
35144
  setDismissedMenuValue(void 0);
34483
35145
  if (trimmed === "") return;
34484
35146
  dismissNotice();
34485
- if (!text.startsWith("/")) {
34486
- recordLocal(text);
34487
- recordHistory(text);
34488
- }
35147
+ recordLocal(text);
35148
+ recordHistory(text);
34489
35149
  recall.current = beginRecall(recallSpace, "");
34490
35150
  if (text === "/quit") {
34491
35151
  quit();
@@ -34564,6 +35224,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34564
35224
  openPlugin(text.slice(7).trim());
34565
35225
  return;
34566
35226
  }
35227
+ if (text === "/update") {
35228
+ openUpdate();
35229
+ return;
35230
+ }
35231
+ if (text === "/schedule") {
35232
+ openSchedule();
35233
+ return;
35234
+ }
34567
35235
  if (text === "/jobs" || text.startsWith("/jobs ")) {
34568
35236
  openJobs();
34569
35237
  return;
@@ -34760,6 +35428,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34760
35428
  (0, import_react.useEffect)(() => {
34761
35429
  onEditorRows(editorRowCount);
34762
35430
  }, [editorRowCount, onEditorRows]);
35431
+ const caretRowInWindow = Math.max(0, Math.min(caret.row - editorWindowStart, editorWindowRows - 1));
35432
+ useImeCursorAnchor(!frozen, imeCursorRowsUp({
35433
+ editorWindowRows,
35434
+ caretRowInWindow,
35435
+ rowsBelowComposer: anchorRowsBelow
35436
+ }), 2 + caret.column);
34763
35437
  (0, import_react.useEffect)(() => {
34764
35438
  onMenuRows(menuHeightRows);
34765
35439
  return () => onMenuRows(0);
@@ -34785,7 +35459,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
34785
35459
  bold: true
34786
35460
  }, warning), bandFill(2 + visibleColumns(warning))));
34787
35461
  }
34788
- const frozenLine = value === "" ? "type a message" : verboseLine(value, Math.max(1, columns - 6));
35462
+ const frozenLine = value === "" ? frozenHint ?? "type a message" : verboseLine(value, Math.max(1, columns - 6));
34789
35463
  return band((0, import_react.createElement)(Text, {
34790
35464
  backgroundColor: bandBg,
34791
35465
  wrap: "truncate-end"
@@ -35157,6 +35831,8 @@ function App(props) {
35157
35831
  const [resumeOpen, setResumeOpen] = (0, import_react.useState)(false);
35158
35832
  const [pluginOpen, setPluginOpen] = (0, import_react.useState)(false);
35159
35833
  const [pluginQuery, setPluginQuery] = (0, import_react.useState)("");
35834
+ const [updateOpen, setUpdateOpen] = (0, import_react.useState)(false);
35835
+ const [scheduleOpen, setScheduleOpen] = (0, import_react.useState)(false);
35160
35836
  const [jobsOpen, setJobsOpen] = (0, import_react.useState)(false);
35161
35837
  const [statuslineOpen, setStatuslineOpen] = (0, import_react.useState)(false);
35162
35838
  const [statuslineItems, setStatuslineItems] = (0, import_react.useState)(() => parseStatuslineItems(props.statusline));
@@ -35229,7 +35905,7 @@ function App(props) {
35229
35905
  const agentRows = (0, import_react.useSyncExternalStore)(props.subagents.subscribe, props.subagents.getSnapshot);
35230
35906
  const approvalPending = approvalSnapshot.pending !== void 0;
35231
35907
  const questionPending = questionSnapshot.pending !== void 0;
35232
- const inputActive = deleteConfirmId !== void 0 ? !approvalPending && !questionPending : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === void 0 && !approvalPending && !questionPending;
35908
+ const inputActive = deleteConfirmId !== void 0 ? !approvalPending && !questionPending : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === void 0 && !approvalPending && !questionPending;
35233
35909
  (0, import_react.useEffect)(() => {
35234
35910
  if (!approvalPending && !questionPending) return;
35235
35911
  setModelOpen(false);
@@ -35241,6 +35917,8 @@ function App(props) {
35241
35917
  setPermissionOpen(false);
35242
35918
  setResumeOpen(false);
35243
35919
  setPluginOpen(false);
35920
+ setUpdateOpen(false);
35921
+ setScheduleOpen(false);
35244
35922
  setStatuslineOpen(false);
35245
35923
  setThemeOpen(false);
35246
35924
  setHistoryOpen(false);
@@ -35306,11 +35984,18 @@ function App(props) {
35306
35984
  const handleMenuRows = (0, import_react.useCallback)((rows) => {
35307
35985
  setMenuRows((current) => current === rows ? current : rows);
35308
35986
  }, []);
35987
+ const [statusBarRows, setStatusBarRows] = (0, import_react.useState)(1);
35988
+ const handleStatusRows = (0, import_react.useCallback)((rows) => {
35989
+ setStatusBarRows((current) => current === rows ? current : rows);
35990
+ }, []);
35991
+ const imeRowsBelowComposer = statusBarRows + 1;
35309
35992
  const composerEditorCap = composerMaxRows(terminalRows);
35310
35993
  const MENU_RESERVE_ROWS = 5;
35311
35994
  const dynamicRows = Math.max(1, terminalRows - 8 - MENU_RESERVE_ROWS - composerGutterRows - (composerRows - 1) - Math.max(0, menuRows - MENU_RESERVE_ROWS));
35312
35995
  const streamingActive = view.streaming !== "" || view.streamingReasoning !== "";
35313
35996
  const deepDivingVisible = busy && !streamingActive;
35997
+ const tabTitle = view.title === "" ? DEFAULT_TERMINAL_TITLE : view.title;
35998
+ useTerminalTitle(tabTitle);
35314
35999
  const allLiveLines = (0, import_react.useMemo)(() => view.entries.slice(settled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2), showReasoning)), [
35315
36000
  view.entries,
35316
36001
  settled,
@@ -35334,8 +36019,11 @@ function App(props) {
35334
36019
  const auditedLiveLines = liveAudit.allocation.live === visibleLiveLines.length ? visibleLiveLines : visibleLiveLines.slice(-liveAudit.allocation.live);
35335
36020
  const auditedReasoningRows = liveAudit.allocation.reasoning;
35336
36021
  const auditedAnswerRows = liveAudit.allocation.answer;
35337
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === void 0 && !approvalPending && !questionPending;
35338
- const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || verboseOpen && !approvalPending && !questionPending || diffView !== void 0 || approvalPending || questionPending;
36022
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === void 0 && !approvalPending && !questionPending;
36023
+ const inspectorVisible = verboseOpen && !approvalPending && !questionPending;
36024
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== void 0 || approvalPending || questionPending;
36025
+ const keyboardOwner = approvalPending ? "the approval prompt" : questionPending ? "the question" : diffView !== void 0 ? "the diff review" : modelOpen ? "/model" : helpOpen ? "/help" : modeOpen ? "/mode" : permissionOpen ? "/permission" : resumeOpen ? "/resume" : pluginOpen ? "/plugin" : updateOpen ? "/update" : scheduleOpen ? "/schedule" : jobsOpen ? "/jobs" : statuslineOpen ? "/statusline" : themeOpen ? "/theme" : historyOpen ? "/history" : agentsOpen ? "/agents" : subagentOpen ? "/subagent" : todosOpen ? "/todos" : inspectorVisible ? "history details" : void 0;
36026
+ const frozenHint = keyboardOwner === void 0 ? void 0 : `keys go to ${keyboardOwner} · esc ${approvalPending ? "rejects" : questionPending ? "cancels" : "closes"}`;
35339
36027
  const closeInspector = (0, import_react.useCallback)(() => {
35340
36028
  setVerboseOpen(false);
35341
36029
  }, []);
@@ -35682,6 +36370,14 @@ function App(props) {
35682
36370
  load: props.loadPlugins,
35683
36371
  initialQuery: pluginQuery,
35684
36372
  close: () => setPluginOpen(false)
36373
+ }) : void 0, updateOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(UpdatePanel, {
36374
+ probe: props.probeUpdate,
36375
+ apply: props.applyUpdate,
36376
+ notify: (text, tone) => notify(text, tone),
36377
+ close: () => setUpdateOpen(false)
36378
+ }) : void 0, scheduleOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(SchedulePanel, {
36379
+ rows: () => view.schedules,
36380
+ close: () => setScheduleOpen(false)
35685
36381
  }) : void 0, jobsOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(JobsPanel, {
35686
36382
  load: props.loadJobs,
35687
36383
  close: () => setJobsOpen(false)
@@ -35744,6 +36440,7 @@ function App(props) {
35744
36440
  }, (0, import_react.createElement)(Input, {
35745
36441
  active: inputActive,
35746
36442
  frozen: modalVisible,
36443
+ frozenHint,
35747
36444
  busy,
35748
36445
  descriptors,
35749
36446
  skills,
@@ -35801,6 +36498,8 @@ function App(props) {
35801
36498
  setPluginQuery(query);
35802
36499
  setPluginOpen(true);
35803
36500
  },
36501
+ openUpdate: () => setUpdateOpen(true),
36502
+ openSchedule: () => setScheduleOpen(true),
35804
36503
  openJobs: () => setJobsOpen(true),
35805
36504
  openStatusline: () => setStatuslineOpen(true),
35806
36505
  openTheme: () => setThemeOpen(true),
@@ -35851,7 +36550,7 @@ function App(props) {
35851
36550
  inspectFiles: props.inspectFiles,
35852
36551
  prepareFiles: props.prepareFiles,
35853
36552
  sessionKey: props.sessionKey,
35854
- cyclePermission: props.cyclePermission,
36553
+ cycleMode: props.cycleMode,
35855
36554
  exportTranscript: props.exportTranscript,
35856
36555
  renameTitle: props.renameTitle,
35857
36556
  copyLastResponse: props.copyLastResponse,
@@ -35867,6 +36566,8 @@ function App(props) {
35867
36566
  waveTier,
35868
36567
  waveStyle,
35869
36568
  maxRows: composerEditorCap,
36569
+ anchorRowsBelow: imeRowsBelowComposer,
36570
+ tabTitle,
35870
36571
  onEditorRows: handleEditorRows,
35871
36572
  onMenuRows: handleMenuRows
35872
36573
  }), (0, import_react.createElement)(StatusLine, {
@@ -35877,7 +36578,7 @@ function App(props) {
35877
36578
  branch: props.branch,
35878
36579
  sessionId: props.sessionId,
35879
36580
  title: view.title,
35880
- plan: view.plan,
36581
+ plan: view.plan || props.pendingPlan === true,
35881
36582
  permission: view.permission !== "" ? view.permission : props.permission,
35882
36583
  sandbox: view.sandbox,
35883
36584
  goal: view.goal === void 0 ? void 0 : {
@@ -35889,7 +36590,8 @@ function App(props) {
35889
36590
  stats: view.stats,
35890
36591
  busy,
35891
36592
  columns: terminalColumns,
35892
- items: statuslineItems
36593
+ items: statuslineItems,
36594
+ onRows: handleStatusRows
35893
36595
  })));
35894
36596
  }
35895
36597
  //#endregion
@@ -36144,6 +36846,7 @@ const internals = {
36144
36846
  if (process.stdin.isTTY === true) process.stdin.setRawMode?.(true);
36145
36847
  try {
36146
36848
  process.stdout.write((keyboardEnhanced ? KEYBOARD_ENHANCE_ENABLE : "") + BRACKETED_PASTE_ENABLE + (focusReporting ? TERMINAL_FOCUS_REPORT_ENABLE : ""));
36849
+ ensureVsCodeTabTitleSetting();
36147
36850
  const tuiStdin = createSplitStdin(process.stdin);
36148
36851
  const instance = render(element, {
36149
36852
  exitOnCtrlC: false,
@@ -37629,13 +38332,6 @@ function selectPermission(service, session, preset) {
37629
38332
  if (session !== void 0) service.set(session, preset);
37630
38333
  return preset;
37631
38334
  }
37632
- /** Cycle table order from the active, pending, or configured-default value. */
37633
- function cyclePermission(service, session, pending) {
37634
- if (service.names.length === 0) return "";
37635
- const at = service.names.indexOf(effectivePermission(service, session, pending));
37636
- const next = service.names[(at + 1) % service.names.length] ?? "";
37637
- return next === "" ? "" : selectPermission(service, session, next);
37638
- }
37639
38335
  /** Materialize a pre-session choice after Harness creates the first session. */
37640
38336
  function applyPendingPermission(service, session, pending) {
37641
38337
  if (pending !== void 0 && effectivePermission(service, session, void 0) !== pending) selectPermission(service, session, pending);
@@ -37684,6 +38380,120 @@ function listPluginRows(ctx) {
37684
38380
  return rows;
37685
38381
  }
37686
38382
  //#endregion
38383
+ //#region src/update.ts
38384
+ /**
38385
+ * Child-process adapter for the launcher update pipeline. The launcher
38386
+ * (bin/deepseek.mjs) stays the single owner of update semantics — plan,
38387
+ * guards, and the aligned install sequence — so the TUI only spawns
38388
+ * `update --json` (read-only probe) and `update --apply` (streamed run)
38389
+ * and never re-implements version-line decisions.
38390
+ */
38391
+ /** The launcher entrypoint that ships beside this bundle (lib/../bin). */
38392
+ function launcherUpdateCommand(args, moduleUrl = import.meta.url) {
38393
+ return {
38394
+ command: process.execPath,
38395
+ args: [fileURLToPath(new URL("../bin/deepseek.mjs", moduleUrl)), ...args]
38396
+ };
38397
+ }
38398
+ /**
38399
+ * Split a streamed chunk sequence into complete display lines: CR is
38400
+ * stripped, a chunk boundary may split a line, and the trailing partial
38401
+ * stays pending until its newline arrives (npm writes whole lines, but a
38402
+ * pipe may cut anywhere). Blank lines carry no progress information and
38403
+ * are dropped so the panel budget is not spent on gaps.
38404
+ */
38405
+ function createLineSplitter(onLine) {
38406
+ let pending = "";
38407
+ return (chunk) => {
38408
+ pending += chunk;
38409
+ for (let index = pending.indexOf("\n"); index >= 0; index = pending.indexOf("\n")) {
38410
+ const line = pending.slice(0, index).replace(/\r$/u, "");
38411
+ pending = pending.slice(index + 1);
38412
+ if (line !== "") onLine(line);
38413
+ }
38414
+ };
38415
+ }
38416
+ /**
38417
+ * Probe the aligned update status. Read-only: `update --json` never
38418
+ * installs anything. The probe is bounded (npm view may hang on a broken
38419
+ * network) and resolves with the parsed status.
38420
+ */
38421
+ async function probeLauncherUpdate(spawnProcess = spawn) {
38422
+ const command = launcherUpdateCommand(["update", "--json"]);
38423
+ return await new Promise((resolve, reject) => {
38424
+ const child = spawnProcess(command.command, command.args, {
38425
+ stdio: [
38426
+ "ignore",
38427
+ "pipe",
38428
+ "pipe"
38429
+ ],
38430
+ windowsHide: true
38431
+ });
38432
+ const timer = setTimeout(() => {
38433
+ child.kill();
38434
+ reject(/* @__PURE__ */ new Error("update probe timed out"));
38435
+ }, 3e4);
38436
+ let stdout = "";
38437
+ let stderr = "";
38438
+ child.stdout?.on("data", (chunk) => {
38439
+ stdout += chunk.toString();
38440
+ });
38441
+ child.stderr?.on("data", (chunk) => {
38442
+ stderr += chunk.toString();
38443
+ });
38444
+ child.once("error", (error) => {
38445
+ clearTimeout(timer);
38446
+ reject(/* @__PURE__ */ new Error(`update probe failed to start: ${error.message}`));
38447
+ });
38448
+ child.once("exit", (code) => {
38449
+ clearTimeout(timer);
38450
+ if (code === 0) {
38451
+ try {
38452
+ resolve(JSON.parse(stdout));
38453
+ } catch {
38454
+ reject(/* @__PURE__ */ new Error("update probe returned an unreadable status"));
38455
+ }
38456
+ return;
38457
+ }
38458
+ const tail = stderr.trim().split(/\r?\n/u).pop() ?? "";
38459
+ reject(/* @__PURE__ */ new Error(`update probe failed${tail === "" ? "" : `: ${tail}`}`));
38460
+ });
38461
+ });
38462
+ }
38463
+ /**
38464
+ * Run the aligned update (`update --apply`) as a child process and stream
38465
+ * its sanitized progress lines to the caller. Resolves with the child
38466
+ * exit code (0 success); rejects only when the process could not start.
38467
+ * No timeout: an npm install may legitimately take minutes.
38468
+ */
38469
+ function applyLauncherUpdate(onLine, spawnProcess = spawn) {
38470
+ const command = launcherUpdateCommand(["update", "--apply"]);
38471
+ return new Promise((resolve, reject) => {
38472
+ const child = spawnProcess(command.command, command.args, {
38473
+ stdio: [
38474
+ "ignore",
38475
+ "pipe",
38476
+ "pipe"
38477
+ ],
38478
+ windowsHide: true
38479
+ });
38480
+ const split = createLineSplitter(onLine);
38481
+ child.stdout?.on("data", (chunk) => {
38482
+ split(chunk.toString());
38483
+ });
38484
+ child.stderr?.on("data", (chunk) => {
38485
+ split(chunk.toString());
38486
+ });
38487
+ child.once("error", (error) => {
38488
+ reject(/* @__PURE__ */ new Error(`update failed to start: ${error.message}`));
38489
+ });
38490
+ child.once("close", (code, signal) => {
38491
+ split("\n");
38492
+ resolve(code ?? (signal === null ? 0 : 1));
38493
+ });
38494
+ });
38495
+ }
38496
+ //#endregion
37687
38497
  //#region src/settings-file.ts
37688
38498
  /**
37689
38499
  * Serialized, crash-atomic persistence for the small user-level JSON files
@@ -37876,6 +38686,32 @@ function submissionBelongsToSession(origin, activeSessionId) {
37876
38686
  return origin === void 0 || origin === "" || origin === activeSessionId;
37877
38687
  }
37878
38688
  /**
38689
+ * Decide the next Shift+Tab station. The cycle keeps the preset table's
38690
+ * own order (most restrictive first) and inserts ONE plan station between
38691
+ * the most restrictive preset and the wrap target: with the shipped three
38692
+ * presets the user sees workspace-write → danger-full-access → read-only
38693
+ * → plan → workspace-write. Plan IS the most restrictive preset plus the
38694
+ * plan prompt layer — entering it switches nothing (the cycle is already
38695
+ * parked on read-only), and leaving it lands on the next preset after the
38696
+ * most restrictive one. Without the /plan command the cycle is exactly the
38697
+ * preset table.
38698
+ */
38699
+ function planCycleDecision(input) {
38700
+ const names = input.names;
38701
+ if (names.length === 0) return void 0;
38702
+ const first = names[0];
38703
+ if (input.inPlan) return {
38704
+ kind: "plan-off",
38705
+ preset: names[1] ?? first
38706
+ };
38707
+ const at = names.indexOf(input.current);
38708
+ if (at === 0 && input.planAvailable) return { kind: "plan-on" };
38709
+ return {
38710
+ kind: "permission",
38711
+ preset: names[(at + 1) % names.length] ?? first
38712
+ };
38713
+ }
38714
+ /**
37879
38715
  * Order-preserving gate for composer input while the startup prompt/images
37880
38716
  * are still preparing. Anything submitted before the startup delivery settles
37881
38717
  * queues and flushes afterwards in submit order, so the initial request can
@@ -38061,6 +38897,37 @@ async function run(ctx, startup, io) {
38061
38897
  /** Permission preset selected before the first session exists. */
38062
38898
  let pendingPermission;
38063
38899
  /**
38900
+ * Plan-mode choice made before the first session exists: materialized as a
38901
+ * /plan registry command delivered ahead of the first queued input when the
38902
+ * session composes, so the first assembled step already plans.
38903
+ */
38904
+ let pendingPlan = false;
38905
+ /**
38906
+ * Whether the pre-session effective preset composes plan mode, answered by
38907
+ * the presets service composition inventory (minimal does not). Cached and
38908
+ * refreshed whenever the pending mode moves; unknown reads as unavailable
38909
+ * so one keypress at most lands before the answer arrives.
38910
+ */
38911
+ let preSessionPlanAvailable = false;
38912
+ let preSessionPlanKnown = false;
38913
+ const refreshPreSessionPlan = () => {
38914
+ if (presets === void 0) {
38915
+ preSessionPlanAvailable = false;
38916
+ preSessionPlanKnown = true;
38917
+ return;
38918
+ }
38919
+ preSessionPlanKnown = false;
38920
+ presets.compositionInventory().then((inventory) => {
38921
+ const id = pendingMode ?? normalizePresetId(presets.defaultId);
38922
+ preSessionPlanAvailable = inventory.some((composition) => composition.id === id && composition.rows.some((row) => row.moduleName === "@deepseek-ai/dsh-plan-mode" && row.enabled !== false));
38923
+ preSessionPlanKnown = true;
38924
+ }, () => {
38925
+ preSessionPlanAvailable = false;
38926
+ preSessionPlanKnown = true;
38927
+ });
38928
+ };
38929
+ refreshPreSessionPlan();
38930
+ /**
38064
38931
  * Monotonic session epoch: bumped on every successful activation, on every
38065
38932
  * first-session creation, and on quit. Async callbacks (mention prepares,
38066
38933
  * command executions) capture it at call time and drop their result when it
@@ -38438,6 +39305,10 @@ async function run(ctx, startup, io) {
38438
39305
  abortPendingControllers();
38439
39306
  epoch += 1;
38440
39307
  const queued = pendingInputs.splice(0);
39308
+ if (pendingPlan) {
39309
+ pendingPlan = false;
39310
+ deliverLine("/plan", "followup");
39311
+ }
38441
39312
  for (const item of queued) deliverLine(item.text, item.mode, item.images);
38442
39313
  } finally {
38443
39314
  creating = false;
@@ -38521,24 +39392,56 @@ async function run(ctx, startup, io) {
38521
39392
  return selected;
38522
39393
  };
38523
39394
  /**
38524
- * Cycle to the next permission preset (Shift+Tab). Before the first session,
38525
- * the choice remains process-local and is materialized when Harness creates
38526
- * that session; afterwards the canonical service writes durable events.
39395
+ * Shift+Tab mode cycle: permission presets in table order, then the plan
39396
+ * station when the composition offers the /plan command (preset-mounted,
39397
+ * so minimal sessions and the pre-session state cycle permissions only).
39398
+ * Plan transitions submit the upstream registry command — it stays the
39399
+ * single owner of plan state; the TUI renders the durable plan/mode event
39400
+ * it appends. Returns the notice label, or '' when nothing changed.
38527
39401
  */
38528
- const cyclePermission$1 = () => {
39402
+ const cycleMode = () => {
38529
39403
  if (permissionPresets === void 0 || permissionPresets.names.length === 0) {
38530
39404
  bridge.notify("permission presets are not mounted in this composition", "warning");
38531
39405
  return "";
38532
39406
  }
38533
39407
  try {
38534
- const next = cyclePermission(permissionPresets, session, pendingPermission);
38535
- if (session === void 0 && next !== "") {
38536
- pendingPermission = next;
39408
+ const preSession = session === void 0;
39409
+ if (preSession && !preSessionPlanKnown) refreshPreSessionPlan();
39410
+ const decision = planCycleDecision({
39411
+ names: permissionPresets.names,
39412
+ current: effectivePermission(permissionPresets, session, pendingPermission),
39413
+ inPlan: preSession ? pendingPlan : store.getView().plan === true,
39414
+ planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some((descriptor) => descriptor.name === "plan")
39415
+ });
39416
+ if (decision === void 0) return "";
39417
+ if (decision.kind === "permission") {
39418
+ const next = selectPermission(permissionPresets, session, decision.preset);
39419
+ if (preSession) {
39420
+ pendingPermission = next;
39421
+ renderCurrent();
39422
+ }
39423
+ return `permission → ${next}`;
39424
+ }
39425
+ if (decision.kind === "plan-on") {
39426
+ if (preSession) {
39427
+ pendingPlan = true;
39428
+ renderCurrent();
39429
+ return "plan → on (applies to the first session)";
39430
+ }
39431
+ send("/plan", "followup");
39432
+ return "plan → on";
39433
+ }
39434
+ if (preSession) {
39435
+ pendingPlan = false;
39436
+ pendingPermission = decision.preset;
38537
39437
  renderCurrent();
39438
+ return `plan → off · permission → ${decision.preset}`;
38538
39439
  }
38539
- return next;
39440
+ send("/plan off", "followup");
39441
+ selectPermission(permissionPresets, session, decision.preset);
39442
+ return `plan → off · permission → ${decision.preset}`;
38540
39443
  } catch (error) {
38541
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, "error");
39444
+ bridge.notify(`mode change failed: ${error instanceof Error ? error.message : String(error)}`, "error");
38542
39445
  return "";
38543
39446
  }
38544
39447
  };
@@ -38751,6 +39654,7 @@ async function run(ctx, startup, io) {
38751
39654
  subagents.reset();
38752
39655
  pendingMode = void 0;
38753
39656
  pendingPermission = void 0;
39657
+ pendingPlan = false;
38754
39658
  } catch (error) {
38755
39659
  active = previous;
38756
39660
  agent = previous?.agent;
@@ -38936,6 +39840,8 @@ async function run(ctx, startup, io) {
38936
39840
  resumed: active?.resumed ?? false,
38937
39841
  mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
38938
39842
  permission,
39843
+ /** Pre-session plan choice for the status badge until a session composes. */
39844
+ pendingPlan: session === void 0 && pendingPlan,
38939
39845
  dispatch,
38940
39846
  steer,
38941
39847
  interrupt,
@@ -38960,7 +39866,7 @@ async function run(ctx, startup, io) {
38960
39866
  prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get("attachments"), signal),
38961
39867
  inspectFiles: (paths) => inspectFilePaths(paths, ctx.get("attachments"), session?.header.cwd ?? cwd),
38962
39868
  prepareFiles: (paths, signal) => saveFilePaths(paths, ctx.get("attachments"), signal),
38963
- cyclePermission: cyclePermission$1,
39869
+ cycleMode,
38964
39870
  setPermission: setPermissionAction,
38965
39871
  selectModel,
38966
39872
  subagentModel: subagentModelLabel(),
@@ -38993,6 +39899,8 @@ async function run(ctx, startup, io) {
38993
39899
  switchSession,
38994
39900
  cancelSessionSwitch,
38995
39901
  loadPlugins: () => listPluginRows(ctx),
39902
+ probeUpdate: () => probeLauncherUpdate(),
39903
+ applyUpdate: (onLine) => applyLauncherUpdate(onLine),
38996
39904
  loadJobs: () => listJobs(ctx, active?.agent),
38997
39905
  statusline: statuslineItems,
38998
39906
  saveStatusline,
@@ -39088,4 +39996,4 @@ function apply(ctx, config) {
39088
39996
  });
39089
39997
  }
39090
39998
  //#endregion
39091
- export { Config, StartupInputGate, apply, exportSessionIdSuffix, inject, name, resolveTarget, runQuitSequence, submissionBelongsToSession };
39999
+ export { Config, StartupInputGate, apply, exportSessionIdSuffix, inject, name, planCycleDecision, resolveTarget, runQuitSequence, submissionBelongsToSession };