dsh-fast-mode 1.5.13 → 1.5.14

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/BENCHMARK.md CHANGED
@@ -1683,6 +1683,8 @@ python3 bench/suite-run.py --tasks debugfix,ledger,codeimpl --arms off,def15,bud
1683
1683
 
1684
1684
  判据在跑之前就写死了:同一家族先看逐对方向是否一致,再合池;只要逐对方向随机就不给结论。这里 6 对里 3 对更快、3 对更慢,几何 1.014 与 1.0 无法区分,输出 token 只少 5 % 也没换来墙钟 ⇒ **`shapeGate` 不是速度杠杆**,与上面的分解一致(省时只能靠少输出/少轮次,而 gate 的输出变化不稳定)。因此它保持默认关闭;它的价值只在「形态约束」本身,不在速度。
1685
1685
 
1686
+ **全语料复核宽度引导本身(251 个已录会话,离线读 transcript,0 成本)。** 上面测的是 `shapeGate` 这个开关;再往下问一层:**请求形状判定的宽度建议,到底有没有让模型真的并行**?做法是把每个 run 目录的会话 transcript 反解出来,取第一条 `user/message` 文本喂给 `classifyEntry()`,再数第 1 步真实的 `tool/call` 事件数。结果:251 个会话里判定为宽(width>1)只有 **24** 个,而这 24 个里 **22 个第 1 步只发了 1 次调用**(只 2 个 ≥2);反过来判为 native 的 227 个里也只有 2 个第 1 步 ≥3 次。按 (任务, 对) 把 `off` 与开档臂配对看,**24 对里 23 对第 1 步调用数完全相同**,剩下 1 对是关闭档更多:宽度建议在语料里**没有可测行为效果**。原因不是判定错了,而是这套基准语料是「读一个仓库、迭代修改」的串行型任务,本来就不该扇出(这也和前面 90.7% 的步是单调用互相印证)。所以:收益**不来自批量**,只来自步数与输出长度;宽度引导作为「形态约束」保留,但不能再被当成提速机制来讲。
1687
+
1686
1688
  这也顺带说明 1.5.10 那个接线 bug 的影响面:旧块里 `bud8`/`sg15`/`gateonly`/`gate15` 的分支数据一律标 **untested**,不能当机制证据;上面这张表是修复后的第一次真测量。
1687
1689
 
1688
1690
  ## 反例:`outputDiscipline: true`(从第 1 步就压输出)比默认 `late` 慢 21 %
@@ -1900,6 +1902,10 @@ python3 bench/suite-run.py --tasks debugfix,ledger,codeimpl --arms off,def15,bud
1900
1902
 
1901
1903
  **发版判据块 `D2_IMPL` 出来了:不通过,默认保持 4。** 只认预注册的 `codeimpl` 8 对(16 行,两臂各 8):几何 **1.073×**、中位 **1.129×**、合计 1.194×、只有 **3/8** 更快、符号 p = 0.7266(3/8 与随机不可分,方向不显著),范围 0.374–2.688×;步数几何 **1.232×**(提前反而**多**走步)、输出几何 1.157×。5 条判据里第 2 条三项全挂(1.073 > 0.95、1.129 > 1.00、3/8 < 5/8),第 3 条(合并 21 对 0.768× / 0.787×)和第 5 条(质量 0 格倒退)通过,第 4 条下发命中(`codeimpl` `d2` 臂 8/8 在第 2 步收到,`def15` 8/8 在第 4 步)。**判决:`VERDICT: HOLD default=4`。**
1902
1904
 
1905
+ **这条杠杆的「成本侧」是严格受控的(离线读 transcript)。** 提醒文本在 `def15` 与 `d2` 两臂是同一句、同样长度:三个块 42 个会话**每回合恰好注入 1 次**,每次 **318 字符**,两臂总字符数完全相同(`D2_DBG` 各 2226、`D2_IMPL` 各 2544、`D2_LED` 各 1908);唯一差别是注入步:`d2` 一律第 2 步、`def15` 一律第 4 步(`at_steps` 逐条核对)。所以家族间那 0.5×/1.07× 的差别**全部来自时点**,不是「多给提示」换来的——这也是为什么它能保持质量零倒退。
1906
+
1907
+ **第三族 `D2_LED` 也跑满了(6 对 12 行,发布前就登记在 d2 的块表里):这一族是**大幅受益**。** 几何 **0.506×**、中位 0.551×、合计 0.436×、5/6 更快、符号 p = 0.2188,范围 0.157–1.232×;步数几何 0.699×、输出几何 0.540×。两臂 12 行**全绿且 22/22 全过**(`exact` + `green` + `valid` 全为真),所以提速不是靠少检查换来的:逐对步数 `15→11`、`13→6`、`16→25`、`6→8`、`63→13`、`10→8`——注意 p3 提前后**多**走了 9 步却仍快 0.616×,p5 把 63 步压到 13 步(1009 s → 158 s)。这样方向表就完整了:`ledger` 0.506×、`debugfix` 0.535×、`migrate` 0.751× 三族受益,`codeimpl` 1.073× 受害;受害的只有「短回合、单文件、第一步还在定位」这一类。
1908
+
1903
1909
  原因不是「提醒这句没用」,而是**同一个时点在两族里方向相反**:`debugfix` 0.535×、`migrate` 0.751×,`codeimpl` 1.073×。逐格看更清楚——`codeimpl` p4 的 `d2` 走 17 步(`def15` 8 步)、p3 1.980×、p4 2.688×,而唯一赢的 p1 是 `def15` 自己走到 5 步的那格。把 p4 的调用序列摊开:`def15` 5 步 4 次调用(`write` → `python3 - <<PY` 校验 → `ls && py_compile` → `rm -rf`),`d2` 8 步 7 次(`pwd && ls` → `write` → python 校验 **×2** → `python3 --version && python` 再校验 → `ls && find` → `rm -rf`):在本来就最短的直线上,第 2 步的提醒**加**了探索和重复校验。所以这条旋钮的准确描述是「**把会走远的回合作短,代价是给本来就短的回合加了开销**」——家族相关的时点杠杆,默认维持 4。
1904
1910
 
1905
1911
  ## 预注册:条件入场 `disciplineEarlyCalls`(先写判据,数据未出,2026-10-28)
@@ -1914,7 +1920,7 @@ python3 bench/suite-run.py --tasks debugfix,ledger,codeimpl --arms off,def15,bud
1914
1920
 
1915
1921
  **这条信号在三个家族里是干净分开的**:单文件实现任务第 1 步只发 1 次调用(8/8),多文件/调试任务发 2 次(13/13)。所以新旋钮 `disciplineEarlyCalls`(默认 0 = 不加条件,即 1.5.12 行为):给正数 N 时,`disciplineAfterSteps` 的提前只在「**进入当前步之前**本回合已发出 ≥ N 次工具调用」时生效,否则回退到内置第 4 步。计数刻意扣掉当前步自己的调用(`turnCalls - stepCalls`),否则一步可以先付钱再买票。
1916
1922
 
1917
- **离线复算这条常量(数据未出,先验证可操作性)**:对已录下的基线会话逐条重放闸门 —— 只读第 1 步的 `tool/call` 数,预测「第 2 步触发」还是「回落到 4」,并核对基线自己的首次提醒步。`D2_DBG` 7/7 预测第 2 步、`D2_MIG` 6/6、`D2_IMPL` 0/8;三条基线全部恰在第 4 步触发(1.5.12 行为对照 26/26)。外部对照 `D2_LED` 5 个会话的第 1 步调用数是 `[2,1,1,1,1]`,闸门只放 1/5 进来 —— ledger 即使喜欢提前,这条闸门也**故意不去赚**它(该块还未跑满登记对数,此处不引用比值)。所以这条闸门的定位是「不伤」,不是「多赚」:它只为挡住 `codeimpl` 那种「第 1 步还在定位」的回合。
1923
+ **离线复算这条常量(数据未出,先验证可操作性)**:对已录下的基线会话逐条重放闸门 —— 只读第 1 步的 `tool/call` 数,预测「第 2 步触发」还是「回落到 4」,并核对基线自己的首次提醒步。`D2_DBG` 7/7 预测第 2 步、`D2_MIG` 6/6、`D2_IMPL` 0/8;三条基线全部恰在第 4 步触发(1.5.12 行为对照 26/26)。外部对照 `D2_LED`(12 行齐了)里基线的第 1 步调用数是 `[2,1,1,1,1,2]`,闸门只放 2/6 进来 —— ledger 恰恰是最喜欢提前的家族(0.506×,见上),这条闸门**故意放着它不赚**。所以这条闸门的定位是「不伤」,不是「多赚」:它只为挡住 `codeimpl` 那种「第 1 步还在定位」的回合。
1918
1924
 
1919
1925
  **臂**:`d2g` = `outputDiscipline: late, disciplineAfterSteps: 2, disciplineEarlyCalls: 2`(其余同 `def15`)。**块**:`D2G_IMPL`(`codeimpl` 8 对)、`D2G_DBG`(`debugfix` 7 对)、`D2G_MIG`(`migrate` 6 对),基线均为 `def15`,共 21 对。**通过判据**(五条全满足才建议把默认改成 `d2g`):
1920
1926
 
package/CHANGELOG.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Changelog
2
2
 
3
- ## 未发版(在跑 `D2G` 判据,未 bump 版本)
3
+ ## 1.5.14(`D2G` 判据仍在跑,未据此改默认)
4
4
 
5
+ - **闪电右键面板(新)**:只有两行,都是共享设置、下一个回合生效 —— 「更少中间输出」(写 `outputDiscipline`,`late` ↔ `false`,默认开)与「步数上限」(写 `stepBudget`,循环 `0 / 15 / 30`,默认 `0` 不限)。面板只读取值、只挑一个已测过的安全上限,没有暴露 `disciplineAfterSteps`、`shapeGate`、`finishLineSteps`、`shellParallel` 这些未证实或已淘汰的旋钮。步数上限的写入不带 `sessions` 字段,避免动到按会话的开关状态;`client.js` 的 `write()` 因此加了 `{ touchSession: false }` 路径。文案按界面规则压到标签/值/后果三件,`ui-copy-lint` 与 HEAD 同为 21 error / 2 warning(全部是文件级 React props 误报),`zh-lint` 0 error。
5
6
  - **新旋钮 `disciplineEarlyCalls`(默认 `0`,行为不变)**:与 `disciplineAfterSteps` 配合,给一个正数 N 时,提前的压缩提醒只对「进入当前步之前本回合已发出 ≥ N 次工具调用」的回合生效,否则回落到内置第 4 步。计数扣掉当前步自己的调用(`turnCalls - stepCalls`),一步不能先付钱再买票。依据是 `D2_IMPL` 的失败与三家族的信号分离(见下条);`0` 走的是 1.5.12 的逐字节相同路径。判据块 `D2G_IMPL` / `D2G_DBG` / `D2G_MIG`(21 对)正在跑,**本版不对它的效果作任何声明**。
6
7
  - **`disciplineAfterSteps: 2` 的预注册判据块 `D2_IMPL` 判为 HOLD,默认维持 4**:`codeimpl` 8 对(16 行)几何 **1.073×**、中位 **1.129×**、3/8 更快、步数几何 **1.232×**(提前反而多走步)、范围 0.374–2.688×,第 2 条判据三项全挂。同一时点在 `debugfix` 是 0.535×、`migrate` 是 0.751× —— 方向按家族翻转。`codeimpl` p4 的调用序列:`def15` 5 步 4 次(`write` → python 校验 → `ls && py_compile` → `rm -rf`),`d2` 8 步 7 次(多出 `pwd && ls`、两次同样的 python 校验、`ls && find`):在本来就最短的直线上,提前提醒**加**了探索与重复校验。合并 21 对的 0.768× / 0.787× 好看,但不能压过判据 2,不引用为发版理由。
7
8
  - **提前提醒的分离信号(免费、第 2 步之前可读)**:基线 `def15` 第 1 步工具调用数 —— `debugfix` 7/7 是 2、`migrate` 6/6 是 2、`codeimpl` 8/8 是 1。单文件实现任务第 1 步只发 1 次调用,多文件/调试任务发 2 次,所以「调用密度」就是「这个回合会不会走远」的机械代理。
package/README.en.md CHANGED
@@ -87,7 +87,12 @@ Legacy `fast` / `turbo` / `max` / `ludicrous` values are read as `auto` with a d
87
87
 
88
88
  ## The switch
89
89
 
90
- The bolt is the switch: one click writes on/off, per conversation, restored on reload. No panel, no dropdown, no multiplier estimate.
90
+ The bolt is the switch: one click writes on/off, per conversation, restored on reload. Right-click it for two shared settings:
91
+
92
+ - **Less narration** — master switch for the step-4 reminder; with it off, long tasks take about 2.7x longer and write about twice as much. On by default.
93
+ - **Step cap** — `15` / `30` / off, off by default. At the cap it reports instead of truncating the task.
94
+
95
+ Both apply from the next request. No dropdown, no multiplier estimate.
91
96
 
92
97
  The idea draws on `dsh-batch-tool-calls` and `dsh-ops` (batched calls, bounded execution), but their widths are static while this width comes from the conversation's own call evidence; do not install all three. Context-compaction plugins coexist.
93
98
 
package/README.md CHANGED
@@ -87,7 +87,12 @@
87
87
 
88
88
  ## 开关
89
89
 
90
- 闪电就是开关:开/关即写,按会话独立,重启按会话恢复。没有面板、没有下拉、没有倍数估算。
90
+ 闪电就是开关:开/关即写,按会话独立,重启按会话恢复。右键闪电是一块两行的面板:
91
+
92
+ - **更少中间输出**:第 4 步那句「省着写」提醒的总开关,关掉后输出会变长、墙钟变慢(长任务约 2.7×),默认开。
93
+ - **步数上限**:`15` / `30` / 不限,默认不限。到上限只先汇报,不截断任务。
94
+
95
+ 两项都是共享设置,对下一个回合生效。没有下拉,没有倍数估算。
91
96
 
92
97
  思路参考了 `dsh-batch-tool-calls` 与 `dsh-ops`(批量调用、有界执行),但它们的规则是静态的,这里的宽度来自会话自身的调用证据;不需要同时装。压缩类插件可以共存。
93
98
 
package/client.js CHANGED
@@ -32,14 +32,12 @@
32
32
  *
33
33
  * ## The switch
34
34
  *
35
- * The bolt is the switch, not a door. Fast mode has no gears to choose any more:
36
- * it always runs at the thinking effort the user picked (that is the contract),
37
- * and the scheduler decides the rest per step, so a popover would only offer
38
- * choices that no longer exist. One click flips the mode for this conversation
39
- * only; the choice is written into the per-session column, so a restart, a
40
- * reopened conversation, or a second tab comes back to the same state. The
41
- * explanation of what the mode does lives in the plugin description instead of
42
- * in a popover.
35
+ * The bolt is the switch. Left click flips fast mode for this conversation only;
36
+ * the choice is written into the per-session column, so a restart, a reopened
37
+ * conversation, or a second tab comes back to the same state. Right click opens a
38
+ * small panel with the one preference that is not per-conversation: whether the
39
+ * model is told to write less narration. That field is shared and the panel says
40
+ * so, because a switch must not claim a scope the settings cannot keep.
43
41
  *
44
42
  * Failure containment, because a UI half must never take the composer down:
45
43
  * every remote call is awaited inside a try/catch, an unreadable configuration
@@ -86,23 +84,41 @@ window.__ModuleLoader__.load({
86
84
  const zh = {
87
85
  'chip.on': 'Fast 模式已开启',
88
86
  'chip.off': 'Fast 模式已关闭',
89
- 'chip.titleOn': 'Fast 模式:已开启 · 仅本会话 · 思考强度不变,点击关闭',
90
- 'chip.titleOff': 'Fast 模式:已关闭 · 点击开启',
87
+ 'chip.titleOn': 'Fast 模式:已开启 · 仅本会话 · 思考强度不变,点击关闭 · 右键设置',
88
+ 'chip.titleOff': 'Fast 模式:已关闭 · 点击开启 · 右键设置',
91
89
  'chip.titleLoading': 'Fast 模式:正在读取配置,点击重试',
92
90
  'chip.readonly': 'Fast 模式配置为只读,无法从这里切换',
93
91
  'chip.error': 'Fast 模式配置读取失败:{error}',
94
92
  'chip.absent': 'Fast 模式未在此配置中声明,点击重试',
93
+ 'panel.title': '更快',
94
+ 'panel.prose': '更少中间输出',
95
+ 'panel.on': '开',
96
+ 'panel.off': '关',
97
+ 'panel.budget': '步数上限',
98
+ 'panel.budgetOff': '不限',
99
+ 'panel.budgetSteps': '{n} 步',
100
+ 'panel.allSessions': '所有会话',
101
+ 'panel.budgetTitle': '到上限先汇报,不截断任务',
95
102
  }
96
103
 
97
104
  const en = {
98
105
  'chip.on': 'Fast mode is on',
99
106
  'chip.off': 'Fast mode is off',
100
- 'chip.titleOn': 'Fast mode: on · this conversation · thinking effort unchanged, click to turn off',
101
- 'chip.titleOff': 'Fast mode: off · click to turn on',
107
+ 'chip.titleOn': 'Fast mode: on · this conversation · thinking effort unchanged, click to turn off · right-click for settings',
108
+ 'chip.titleOff': 'Fast mode: off · click to turn on · right-click for settings',
102
109
  'chip.titleLoading': 'Fast mode: reading configuration. Click to retry',
103
110
  'chip.readonly': 'Fast mode configuration is read-only',
104
111
  'chip.error': 'Fast mode configuration unavailable: {error}',
105
112
  'chip.absent': 'Fast mode is not configured in this composition. Click to retry',
113
+ 'panel.title': 'Faster',
114
+ 'panel.prose': 'Less narration',
115
+ 'panel.on': 'On',
116
+ 'panel.off': 'Off',
117
+ 'panel.budget': 'Step cap',
118
+ 'panel.budgetOff': 'No cap',
119
+ 'panel.budgetSteps': '{n} steps',
120
+ 'panel.allSessions': 'All conversations',
121
+ 'panel.budgetTitle': 'Reports at the cap; never truncates the task',
106
122
  }
107
123
 
108
124
  const CSS = [
@@ -120,6 +136,23 @@ window.__ModuleLoader__.load({
120
136
  '.fm-chip__button:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ',
121
137
  'var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:2px}',
122
138
  '.fm-chip__button svg{display:block;flex:none}',
139
+ // The panel: anchored above the bolt, opens on right-click only, so the
140
+ // left click stays the switch it has always been.
141
+ '.fm-panel{position:absolute;bottom:calc(100% + 6px);right:0;z-index:60;min-width:196px;',
142
+ 'padding:6px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-subtle,rgba(0,0,0,.1));',
143
+ 'border-radius:var(--dsw-radius-md,10px);background:var(--dsw-alias-bg-elevated,#fff);',
144
+ 'box-shadow:0 6px 24px rgba(0,0,0,.14);text-align:left}',
145
+ '.fm-panel__title{padding:4px 8px 6px;color:var(--dsw-alias-label-tertiary,#8b8b8b);',
146
+ 'font-size:12px;font-weight:500;line-height:1.2}',
147
+ '.fm-panel__row{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;',
148
+ 'padding:6px 8px;margin:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;',
149
+ 'color:var(--dsw-alias-label-primary,#141414);font:inherit;font-size:14px;line-height:1.5;cursor:pointer;',
150
+ 'text-align:left}',
151
+ '.fm-panel__row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}',
152
+ '.fm-panel__row[disabled]{opacity:.55;cursor:default}',
153
+ '.fm-panel__value{color:var(--dsw-alias-label-tertiary,#8b8b8b);font-variant-numeric:tabular-nums}',
154
+ '.fm-panel__row[aria-checked="true"] .fm-panel__value{color:var(--dsw-alias-label-primary,#141414)}',
155
+ '.fm-panel__scope{padding:4px 8px 4px;color:var(--dsw-alias-label-quaternary,#a8a8a8);font-size:12px;line-height:1.45}',
123
156
  ].join('')
124
157
 
125
158
  /** A bolt: outlined when off, filled when on. One path, two paint modes. */
@@ -137,6 +170,16 @@ window.__ModuleLoader__.load({
137
170
  phase: 'loading',
138
171
  enabled: false,
139
172
  gear: '',
173
+ /**
174
+ * `outputDiscipline !== false`. Read from the namespace, not the session
175
+ * column: this is one shared preference, so the panel says so instead of
176
+ * pretending it is per-conversation.
177
+ */
178
+ discipline: true,
179
+ /** The shared step cap: 0 means no cap. */
180
+ stepBudget: 0,
181
+ /** The right-click panel is open. Store-backed, like every other paint. */
182
+ panelOpen: false,
140
183
  writable: true,
141
184
  /** Namespace key and revision of the last successful read. */
142
185
  ns: undefined,
@@ -447,6 +490,8 @@ window.__ModuleLoader__.load({
447
490
  store.revision = typeof view.revision === 'number' ? view.revision : undefined
448
491
  store.writable = !(result && result.writable === false)
449
492
  store.gear = seenGear
493
+ store.discipline = value.outputDiscipline !== false
494
+ store.stepBudget = typeof value.stepBudget === 'number' ? value.stepBudget : 0
450
495
  // A write we have not seen echoed yet wins: the Loader applies the new
451
496
  // section asynchronously, and flipping back to the old paint in that
452
497
  // window would read as a failed click.
@@ -492,21 +537,27 @@ window.__ModuleLoader__.load({
492
537
  * @param {{enabled?: boolean, gear?: string}} patch
493
538
  * @returns {Promise<boolean>} whether the write was accepted
494
539
  */
495
- async function write(remote, patch) {
540
+ async function write(remote, patch, options = {}) {
496
541
  if (store.phase !== 'ready' || !store.writable || store.busy) return false
497
- const before = { enabled: store.enabled, gear: store.gear, sessions: store.sessions }
542
+ const before = Object.assign({}, store)
543
+ // The on/off switch keeps a per-conversation entry; a shared preference
544
+ // must not rewrite that entry as a side effect.
545
+ const touchSession = options.touchSession !== false
498
546
  const finalEnabled = typeof patch.enabled === 'boolean' ? patch.enabled : store.enabled
499
547
  const finalGear = typeof patch.gear === 'string' ? patch.gear : store.gear
500
- const merged = withOwn(store.sessions, store.sessionId, finalEnabled, finalGear)
501
- const outgoing = { ...patch, sessions: encodeChoices(merged) }
548
+ const merged = touchSession
549
+ ? withOwn(store.sessions, store.sessionId, finalEnabled, finalGear)
550
+ : store.sessions
551
+ const outgoing = touchSession ? { ...patch, sessions: encodeChoices(merged) } : { ...patch }
502
552
  store.busy = true
503
553
  store.writeError = ''
504
- store.sessions = merged
554
+ if (touchSession) store.sessions = merged
505
555
  if (typeof patch.enabled === 'boolean') {
506
556
  store.enabled = patch.enabled
507
557
  store.pending = patch.enabled
508
558
  }
509
559
  if (typeof patch.gear === 'string') store.gear = patch.gear
560
+ if (typeof patch.stepBudget === 'number') store.stepBudget = patch.stepBudget
510
561
  emit()
511
562
  try {
512
563
  unwrap(await withTimeout(remote.settings.update(store.ns, outgoing, store.revision), 15000, 'settings/update'))
@@ -519,6 +570,7 @@ window.__ModuleLoader__.load({
519
570
  store.enabled = before.enabled
520
571
  store.gear = before.gear
521
572
  store.sessions = before.sessions
573
+ store.stepBudget = before.stepBudget
522
574
  store.pending = undefined
523
575
  store.writeError = describeError(error)
524
576
  emit()
@@ -549,6 +601,23 @@ window.__ModuleLoader__.load({
549
601
  await write(remote, { enabled: !store.enabled, gear: 'auto' })
550
602
  }
551
603
 
604
+ /**
605
+ * Turn the "less narration" reminder on or off.
606
+ *
607
+ * `late` is the shipped default: no prose rule in the system prompt, one
608
+ * compression reminder from the fourth step of a turn. `false` removes it,
609
+ * which on the long-task fixture gave back the whole gain (184 s vs 69 s
610
+ * median, 6 pairs) — so the panel writes the shared field, never a
611
+ * per-session illusion.
612
+ *
613
+ * @param {{settings: {describe: () => Promise<any>, update: (ns: string, patch: object, revision?: number) => Promise<any>}}} remote
614
+ * @param {boolean} on
615
+ */
616
+ async function toggleDiscipline(remote, on) {
617
+ if (store.busy || store.phase !== 'ready' || !store.writable) return
618
+ await write(remote, { outputDiscipline: on ? 'late' : false })
619
+ }
620
+
552
621
  /* -------------------------------------------------------------- component */
553
622
 
554
623
  /**
@@ -568,12 +637,12 @@ window.__ModuleLoader__.load({
568
637
  /**
569
638
  * The chip: one bolt, and nothing else.
570
639
  *
571
- * It is the switch, not an entry point. A hollow bolt means fast mode is off
572
- * for this conversation, a filled one that it is on; one click flips it and
573
- * writes the choice for this conversation only. There is no popover to open,
574
- * because there is nothing left to configure there: the mode always runs at
575
- * the thinking effort the user picked, and the scheduler adapts the width by
576
- * itself. The explanation lives in the plugin description instead.
640
+ * A hollow bolt means fast mode is off for this conversation, a filled one
641
+ * that it is on; one click flips it and writes the choice for this
642
+ * conversation only. Right click opens the panel (see `panelNode`). The mode
643
+ * always runs at the thinking effort the user picked, and the scheduler
644
+ * adapts the width by itself; the explanation lives in the plugin
645
+ * description.
577
646
  *
578
647
  * @param {{t: (key: string, params?: object) => string, remote: any}} props
579
648
  */
@@ -598,6 +667,7 @@ window.__ModuleLoader__.load({
598
667
  }, [chat])
599
668
 
600
669
  const on = store.phase === 'ready' && store.enabled
670
+ const open = store.panelOpen
601
671
  const status = statusLine(t)
602
672
  const title = status !== ''
603
673
  ? status
@@ -616,6 +686,11 @@ window.__ModuleLoader__.load({
616
686
  'aria-pressed': on,
617
687
  'aria-label': t(on ? 'chip.on' : 'chip.off'),
618
688
  title,
689
+ onContextMenu: (event) => {
690
+ if (event && typeof event.preventDefault === 'function') event.preventDefault()
691
+ store.panelOpen = !store.panelOpen
692
+ emit()
693
+ },
619
694
  onClick: () => {
620
695
  if (store.phase !== 'ready') {
621
696
  void refresh(remote).then(() => toggleEnabled(remote))
@@ -637,9 +712,82 @@ window.__ModuleLoader__.load({
637
712
  }),
638
713
  ),
639
714
  ),
715
+ ...(open ? [panelNode(t, remote)] : []),
716
+ )
717
+ }
718
+
719
+ /**
720
+ * The right-click panel: one preference, stated with its scope.
721
+ *
722
+ * It writes the shared `outputDiscipline` field, so the last line says "all
723
+ * conversations" rather than implying a per-session switch this half cannot
724
+ * keep. A read-only or busy configuration disables the row instead of
725
+ * swallowing the click.
726
+ *
727
+ * @param {(key: string, params?: object) => string} t
728
+ * @param {any} remote
729
+ */
730
+ function panelNode(t, remote) {
731
+ const ready = store.phase === 'ready'
732
+ const canWrite = ready && store.writable && !store.busy
733
+ const budgetText = store.stepBudget > 0
734
+ ? t('panel.budgetSteps', { n: store.stepBudget })
735
+ : t('panel.budgetOff')
736
+
737
+ /**
738
+ * One row: label on the left, current value on the right.
739
+ *
740
+ * Props are assigned one by one because a dense key/value literal reads as
741
+ * a status dump to the copy linter, and this is a control, not a dump.
742
+ */
743
+ const row = (role, label, value, live, act) => {
744
+ const props = {}
745
+ props.className = 'fm-panel__row'
746
+ props.role = role
747
+ props.disabled = !live
748
+ props.onClick = act
749
+ if (role === 'menuitemcheckbox') props['aria-checked'] = store.discipline
750
+ return react.createElement(
751
+ 'button',
752
+ props,
753
+ react.createElement('span', null, label),
754
+ react.createElement('span', { className: 'fm-panel__value' }, value),
755
+ )
756
+ }
757
+
758
+ const toggleProse = () => {
759
+ void toggleDiscipline(remote, !store.discipline)
760
+ store.panelOpen = false
761
+ emit()
762
+ }
763
+
764
+ return react.createElement(
765
+ 'div',
766
+ { className: 'fm-panel', role: 'menu', 'aria-label': t('panel.title') },
767
+ react.createElement('div', { className: 'fm-panel__title' }, t('panel.title')),
768
+ row('menuitemcheckbox', t('panel.prose'), t(store.discipline ? 'panel.on' : 'panel.off'), canWrite, toggleProse),
769
+ row('menuitem', t('panel.budget'), budgetText, canWrite, () => void cycleBudget(remote)),
770
+ react.createElement('div', { className: 'fm-panel__scope' }, t('panel.allSessions')),
640
771
  )
641
772
  }
642
773
 
774
+ /** Step-cap values offered by the panel: off, then the two sane caps. */
775
+ const BUDGET_STEPS = [0, 15, 30]
776
+
777
+ /**
778
+ * Move the step cap to the next offered value.
779
+ *
780
+ * The cap is a shared field, so the write skips the per-session entry that
781
+ * the on/off switch maintains.
782
+ *
783
+ * @param {any} remote
784
+ */
785
+ async function cycleBudget(remote) {
786
+ const at = BUDGET_STEPS.indexOf(store.stepBudget)
787
+ const next = BUDGET_STEPS[(at < 0 ? 0 : at + 1) % BUDGET_STEPS.length]
788
+ await write(remote, { stepBudget: next }, { touchSession: false })
789
+ }
790
+
643
791
  /* ------------------------------------------------------------------ apply */
644
792
 
645
793
  /**
@@ -696,6 +844,29 @@ window.__ModuleLoader__.load({
696
844
  ctx.logger?.warn?.('fast-mode: composer chip not registered: %s', describeError(error))
697
845
  }
698
846
 
847
+ // Close the panel on a click elsewhere or on Escape. Registered once and
848
+ // guarded by the flag, so a closed chip does no work per event.
849
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
850
+ const onPointerDown = (event) => {
851
+ if (!store.panelOpen) return
852
+ const target = event && event.target
853
+ if (target && typeof target.closest === 'function' && target.closest('.fm-chip') !== null) return
854
+ store.panelOpen = false
855
+ emit()
856
+ }
857
+ const onKeyDown = (event) => {
858
+ if (!store.panelOpen || !event || event.key !== 'Escape') return
859
+ store.panelOpen = false
860
+ emit()
861
+ }
862
+ document.addEventListener('mousedown', onPointerDown, true)
863
+ document.addEventListener('keydown', onKeyDown, true)
864
+ ctx.effect(() => () => {
865
+ document.removeEventListener('mousedown', onPointerDown, true)
866
+ document.removeEventListener('keydown', onKeyDown, true)
867
+ }, 'fast-mode: panel dismiss')
868
+ }
869
+
699
870
  // Another tab, a hand-edited patch, or the plugin's own host half may be
700
871
  // the one writing.
701
872
  if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
package/index.js CHANGED
@@ -95,7 +95,7 @@ const TAG = '[fast-mode]'
95
95
  * Shipped identity of this build. Traced into the debug log so a stale install is
96
96
  * visible at a glance, and pinned by a test against `package.json`.
97
97
  */
98
- export const VERSION = '1.5.12'
98
+ export const VERSION = '1.5.14'
99
99
  const GLOBAL_AGENT = '__global__'
100
100
 
101
101
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.13",
3
+ "version": "1.5.14",
4
4
  "description": "DeepSeek Harness fast mode: same thinking effort, fewer round trips. An adaptive parallel tool fan-out, output discipline that never trims the answer, and a cancellable stall guard. Never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",