localcode 0.3.32__py3-none-any.whl → 0.3.34__py3-none-any.whl

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.
@@ -81,20 +81,20 @@ leaves ~125K tokens of generation headroom ≈ ~28 minutes of nonstop
81
81
  decode. A real 40-minute / no-output hang (thinking on, no answer emitted)
82
82
  was traced to exactly this.
83
83
 
84
- The correct backstop is the thinking runaway-guard (Feature.THINKING_CAPS
85
- + MAX_THINKING_SECONDS / MAX_THINKING_CHARS): it aborts a reasoning-only
86
- phase at 10 min / ~20k tokens regardless of ctx-size, well before the
87
- context ceiling, and surfaces a clear message. That is why the cap was
88
- re-enabled 2026-07-19. Leaving MAX_OUTPUT_TOKENS at -1 keeps valid long
89
- tool calls intact while the thinking guard handles the runaway case."""
84
+ The primary control is llama.cpp's per-request `thinking_budget_tokens`: it
85
+ forces the template's end-thinking sequence and lets the SAME generation move
86
+ on to a tool call. Feature.THINKING_CAPS + MAX_THINKING_SECONDS / CHARS are
87
+ compatibility backstops for templates whose thinking tags the server cannot
88
+ identify. Leaving MAX_OUTPUT_TOKENS at -1 keeps valid long tool calls intact
89
+ without leaving the reasoning channel unbounded."""
90
90
 
91
91
 
92
92
  # ── Thinking-phase safety caps ──────────────────────────────────────
93
93
  #
94
- # Bound how much reasoning the model emits before we force a
95
- # transition to content / tool_calls. Either cap trips a
96
- # `_thinking_abort`, which surfaces a clear user message and ends
97
- # the turn.
94
+ # Bound how much reasoning Python will accept if the server-side token budget
95
+ # cannot recognize the model's thinking delimiters. Either cap trips a
96
+ # `_thinking_abort`. Exact periodic loops retry once with thinking disabled;
97
+ # other runaways surface a clear user message.
98
98
  #
99
99
  # These are a RUNAWAY guard, not a reasoning budget. The earlier tight
100
100
  # values (90 s / 4000 chars ≈ 1000 tokens) aborted legitimate long
localcode/agent/loop.py CHANGED
@@ -262,11 +262,18 @@ def run_agent_loop(
262
262
  from ..app import is_online
263
263
  online = is_online()
264
264
  if online:
265
- network_status = "Network: ONLINE — you can download files, install packages, fetch URLs."
265
+ network_status = (
266
+ "Network: ONLINE — you can download files, install packages, fetch URLs. "
267
+ "PREFER your tools over writing from memory: web_search / web_fetch to "
268
+ "confirm current facts, versions, and library docs; your skills and other "
269
+ "tools for anything they cover. Don't hand-write what a tool can get right."
270
+ )
266
271
  else:
267
272
  network_status = (
268
273
  "Network: OFFLINE — NO internet. Do NOT attempt downloads, pip install, curl, wget, or any network requests. "
269
- "Use only local files and already-installed packages. Generate sample/mock data locally instead of downloading."
274
+ "Use only local files, already-installed packages, and your LOCAL skills and "
275
+ "tools (read/grep/edit, installed CLIs). Generate sample/mock data locally "
276
+ "instead of downloading."
270
277
  )
271
278
  def _current_task_stage_for_thinking() -> str:
272
279
  stage = str(_last_announced_task_stage or getattr(_task_state, "current_stage", "") or "").strip()
@@ -431,6 +438,9 @@ def run_agent_loop(
431
438
  _todo_stuck_count = 0
432
439
  _last_todo_remaining = 10**9
433
440
  _edit_recovery_nudges = 0
441
+ # Set when a round aborts on a detected reasoning loop; forces the very next
442
+ # round to decode with thinking off so the retry escapes the attractor.
443
+ _force_no_think_next_round = False
434
444
  _MAX_CONSECUTIVE_CORRECTIONS = 2
435
445
  _generic_correction_nudges = 0
436
446
  # Fires at most once per turn when an identical failing call crosses the
@@ -454,6 +464,11 @@ def run_agent_loop(
454
464
  # agent/recovery.py (T0.1-d split).
455
465
  _empty_rounds_this_turn = 0
456
466
  _MAX_EMPTY_ROUND_RETRIES = MAX_EMPTY_ROUND_RETRIES
467
+ # Loop-abort decode-recovery: how many times this turn a detected reasoning
468
+ # loop forced a no-thinking retry. Capped so a model that keeps degenerating
469
+ # even without the reasoning channel can't spin the turn forever.
470
+ _thinking_loop_recoveries = 0
471
+ _MAX_THINKING_LOOP_RECOVERIES = 2
457
472
  _last_round_signature: tuple[int, str] | None = None
458
473
  _same_round_signature_count = 0
459
474
  _same_round_synthetic_rejections = 0
@@ -785,6 +800,16 @@ def run_agent_loop(
785
800
  task_stage=round_task_stage,
786
801
  user_text=user_text,
787
802
  )
803
+ # Cycle-breaker: the PREVIOUS round degenerated into a reasoning loop and
804
+ # aborted. Retrying with the reasoning channel still on re-enters the same
805
+ # attractor (same prompt, same samplers, same template) — a text nudge
806
+ # alone doesn't change the decode. So force this one retry to decode with
807
+ # thinking OFF, which actually changes the generation path and lets the
808
+ # model emit a tool call. This is fault recovery, not a reinterpretation
809
+ # of the user's `on` setting: normal rounds keep thinking.
810
+ if _force_no_think_next_round:
811
+ round_use_thinking = False
812
+ _force_no_think_next_round = False
788
813
  try:
789
814
  _ctx_chars = None
790
815
  try:
@@ -962,9 +987,48 @@ def run_agent_loop(
962
987
  _loop_exit_reason = f"stream_error:{type(exc).__name__}"
963
988
  break
964
989
 
965
- # If we bailed on a stuck thinking loop, tell the user explicitly so
966
- # they know why the turn ended and can try a different prompt.
990
+ # If the server-side reasoning budget could not transition the model,
991
+ # recover a periodic loop automatically; report other cap aborts.
967
992
  if _stream_result.thinking_abort:
993
+ try:
994
+ from ..events import emit as _emit_ta
995
+ _emit_ta(
996
+ "thinking_abort",
997
+ round_idx=round_num,
998
+ reason=_stream_result.thinking_abort_reason or "unknown",
999
+ )
1000
+ except Exception:
1001
+ pass
1002
+ if (
1003
+ _stream_result.thinking_abort_reason == "loop"
1004
+ and _thinking_loop_recoveries < _MAX_THINKING_LOOP_RECOVERIES
1005
+ ):
1006
+ # Detected a degenerate repetition loop early. This is DECODE
1007
+ # recovery, not an optional textual nudge — do it explicitly and
1008
+ # independently of AUTO_NUDGE_RECOVERY. Drop the aborted (empty)
1009
+ # round entirely (never append its assistant message) and re-run
1010
+ # the SAME round with thinking off (consumed at the top of the
1011
+ # loop), which changes the generation path and breaks the loop.
1012
+ _thinking_loop_recoveries += 1
1013
+ _force_no_think_next_round = True
1014
+ out.notice(
1015
+ "Model reasoning started repeating itself — retrying this "
1016
+ "step without deep reasoning so it acts instead of looping."
1017
+ )
1018
+ out._stop_indicator()
1019
+ sys.stdout.write("\r\033[K")
1020
+ sys.stdout.flush()
1021
+ continue # next round: decodes with think=False
1022
+ if _stream_result.thinking_abort_reason == "loop":
1023
+ # Recovery budget exhausted: the model kept looping even without
1024
+ # the reasoning channel. End the turn with an honest message.
1025
+ out.notice(
1026
+ "Model kept repeating itself even without deep reasoning — "
1027
+ "ending the turn. Try `/model` to switch models or split the "
1028
+ "task into smaller steps."
1029
+ )
1030
+ _loop_exit_reason = "thinking_loop_exhausted"
1031
+ break
968
1032
  out.notice(
969
1033
  f"Stopped: model reasoning exceeded the per-round cap "
970
1034
  f"({MAX_THINKING_SECONDS}s or {MAX_THINKING_CHARS} chars) without "
@@ -122,6 +122,18 @@ def build_target_grounding_block(repo_root: Any, goal_state: Any) -> str:
122
122
  root = str(repo_root or "")
123
123
  if not root:
124
124
  return ""
125
+ # Don't anchor to $HOME. When localcode is launched outside any project
126
+ # (no .git/package.json/etc. marker found), repo_root falls back to the
127
+ # user's home dir. Telling the model "the project root is /Users/<name>,
128
+ # create all files under that root" then makes it try to build in $HOME —
129
+ # or conflict with the path the user actually named in the task ("work at
130
+ # root vs. the Anki/... path they gave"). In that unanchored case, emit no
131
+ # directive and let the model follow the location from the user's request.
132
+ try:
133
+ if Path(root) == Path.home():
134
+ return ""
135
+ except Exception:
136
+ pass
125
137
  return (
126
138
  "\n\nTarget location (work here, do not drift):\n"
127
139
  f"- The project root is: {root}\n"
@@ -68,6 +68,10 @@ VERIFY BEFORE YOU CLAIM DONE:
68
68
  - After building something runnable, actually run it (build, tests, or a real probe) and read the output. "It should work" is not verification.
69
69
  - Report outcomes faithfully: if a build/test fails, say so with the output; if you skipped a step, say that.
70
70
 
71
+ END WITH A SUMMARY, NOT A DEBUG NOTE:
72
+ - Your FINAL message (the one where you stop and hand back to the user) must be a short completion summary — never a low-level note like "cleared the cache" or "that fixed it". The user needs to know what they got and how to use it.
73
+ - Say, in a few lines: WHAT you built, WHERE it lives (the project path), and the EXACT commands to run it — e.g. `cd <project-dir> && npm install && npm run dev`, and the URL/port it serves on. For a library/script, show the run/import command. Note anything the user still needs to do.
74
+
71
75
  If the request is ambiguous in a way that changes your approach (stack, interface, scope), ask ONE short question first — otherwise pick the sensible default and proceed.
72
76
 
73
77
  Working directory: {cwd}
@@ -0,0 +1,79 @@
1
+ """Detect a degenerate reasoning loop in the model's thinking stream.
2
+
3
+ Small quantized local models sometimes collapse the reasoning channel into a
4
+ repetition loop: the same short phrase ("let me read the file", "OK let me call
5
+ read_file") is emitted over and over without ever transitioning to a tool call.
6
+ Token-level sampler penalties (presence/repeat) don't reliably break a tight
7
+ periodic oscillation, and the length cap (MAX_THINKING_CHARS) only fires after
8
+ tens of thousands of wasted characters — minutes later on a local model.
9
+
10
+ Detection is by STRUCTURE, not content: we look for exact *periodicity* in the
11
+ tail of the reasoning stream — a substring that repeats with a fixed period.
12
+ That is what an autoregressive degeneration loop actually is, and it catches the
13
+ phrase in any language on any task within a few repetitions.
14
+
15
+ Design notes (why this shape):
16
+ - Operate on a raw character tail, NOT on split lines. The reasoning arrives as
17
+ arbitrary streaming chunks; the repeated unit may contain no newline and the
18
+ chunk boundaries fall differently each time, so line-splitting is fragile.
19
+ - Anchor on the final n-gram and find its previous occurrence to derive the
20
+ period in one `str.rfind` (C-fast), then verify true periodicity by scanning
21
+ back. Cost is O(window), independent of total reasoning length, so the caller
22
+ can pass a bounded tail buffer and stay linear overall.
23
+ - Require the periodic run to be both long enough (absolute chars) and to repeat
24
+ enough times before firing, so genuine reasoning — which is not exactly
25
+ periodic — is never cut.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ __all__ = ["reasoning_is_looping"]
30
+
31
+ # Need this many chars of uninterrupted reasoning before we judge at all — a
32
+ # floor against cutting short, legitimately-repetitive planning.
33
+ _MIN_TAIL = 300
34
+ # Rolling tail we actually score. The caller passes a bounded buffer; we also
35
+ # clamp here so a full-transcript caller stays cheap.
36
+ _WINDOW = 2400
37
+ # Length of the trailing n-gram we anchor on to recover the period. Long enough
38
+ # to be specific (won't coincidentally reappear in prose), short enough that a
39
+ # tight loop still contains two copies inside the window.
40
+ _ANCHOR = 32
41
+ # Only fire early on TIGHT loops; a very long period is better left to the
42
+ # length/time cap (and is far likelier to be genuine varied reasoning).
43
+ _MAX_PERIOD = 400
44
+ # The periodic unit must repeat at least this many times...
45
+ _MIN_REPEATS = 4
46
+ # ...spanning at least this many characters. Whichever is larger gates.
47
+ _MIN_SPAN = 240
48
+
49
+
50
+ def reasoning_is_looping(text: str) -> bool:
51
+ """True when the tail of `text` has collapsed into an exact periodic loop.
52
+
53
+ Content-agnostic and chunk-boundary robust. Conservative: returns False on
54
+ short input and only fires on a genuinely periodic, sufficiently-repeated
55
+ tail, so varied reasoning is never aborted.
56
+ """
57
+ if not text or len(text) < _MIN_TAIL:
58
+ return False
59
+ tail = text[-_WINDOW:]
60
+ n = len(tail)
61
+ if n < _ANCHOR * 2:
62
+ return False
63
+ anchor = tail[-_ANCHOR:]
64
+ # Where did the final n-gram last occur before its trailing copy? The gap
65
+ # between the two occurrences is the loop period.
66
+ prev = tail.rfind(anchor, 0, n - _ANCHOR)
67
+ if prev == -1:
68
+ return False
69
+ period = (n - _ANCHOR) - prev
70
+ if period <= 0 or period > _MAX_PERIOD:
71
+ return False
72
+ # Verify the tail really is periodic with this period, measuring how far the
73
+ # exact repetition extends back from the end.
74
+ span = 0
75
+ i = n - 1
76
+ while i - period >= 0 and tail[i] == tail[i - period]:
77
+ span += 1
78
+ i -= 1
79
+ return span >= max(_MIN_SPAN, _MIN_REPEATS * period)
@@ -8,8 +8,16 @@ import time
8
8
  from typing import Any, TYPE_CHECKING
9
9
 
10
10
  from .constants import MAX_OUTPUT_TOKENS, MAX_THINKING_CHARS, MAX_THINKING_SECONDS
11
+ from .reasoning_loop import reasoning_is_looping
11
12
  from ..tools import ALL_SCHEMAS as TOOL_SCHEMAS
12
13
 
14
+ # Rolling reasoning-tail window handed to the loop detector, and how many new
15
+ # reasoning chars to accumulate between rescans. The window comfortably exceeds
16
+ # the detector's own clamp so a tight loop always has two copies to anchor on;
17
+ # the stride keeps detection to O(window) amortized per chunk.
18
+ _THINKING_TAIL_CHARS = 2600
19
+ _LOOP_SCAN_STRIDE = 200
20
+
13
21
  if TYPE_CHECKING:
14
22
  from ..app import LocalCodeApp
15
23
  from ..output import OutputManager
@@ -97,6 +105,16 @@ class StreamRoundResult:
97
105
  thinking_shown: bool = False
98
106
  content_streaming: bool = False
99
107
  thinking_abort: bool = False
108
+ # Why the thinking phase was aborted: "loop" (degenerate repetition detected
109
+ # early) or "length" (per-round char/time cap). Drives both the user-facing
110
+ # notice and the recovery decision (a loop-abort forces a no-thinking retry).
111
+ thinking_abort_reason: str = ""
112
+ # Bounded rolling tail of the reasoning stream, kept for the loop detector so
113
+ # detection stays O(window) per chunk instead of O(total) — see the thinking
114
+ # handler. Not part of the model transcript.
115
+ _thinking_tail: str = ""
116
+ _thinking_total_chars: int = 0
117
+ _last_loop_scan_total: int = 0
100
118
  finish_reason: str = ""
101
119
  raw_tail: str = ""
102
120
  content_chars: int = 0
@@ -177,14 +195,35 @@ def stream_model_round(
177
195
  out.feed_thinking(chunk)
178
196
  if result.content_streaming or result.tool_calls:
179
197
  return
198
+ # Running total (avoids re-summing thinking_parts every chunk) and a
199
+ # bounded rolling tail for the loop detector, so both guards stay
200
+ # O(window) per chunk regardless of how long reasoning gets.
201
+ result._thinking_total_chars += len(chunk)
202
+ tail = result._thinking_tail + chunk
203
+ if len(tail) > _THINKING_TAIL_CHARS:
204
+ tail = tail[-_THINKING_TAIL_CHARS:]
205
+ result._thinking_tail = tail
180
206
  from ..features import Feature, is_enabled
181
207
  if is_enabled(Feature.THINKING_CAPS):
182
- thinking_chars = sum(len(p) for p in result.thinking_parts)
208
+ # Degenerate-repetition guard fires FIRST: it catches a tight
209
+ # loop within a few repeats (~1s), where the length/time cap
210
+ # would burn minutes. Only rescan every _LOOP_SCAN_STRIDE new
211
+ # chars to bound cost.
212
+ if (
213
+ result._thinking_total_chars - result._last_loop_scan_total
214
+ >= _LOOP_SCAN_STRIDE
215
+ ):
216
+ result._last_loop_scan_total = result._thinking_total_chars
217
+ if reasoning_is_looping(result._thinking_tail):
218
+ result.thinking_abort = True
219
+ result.thinking_abort_reason = "loop"
220
+ return
183
221
  if (
184
222
  time.monotonic() - stream_start > MAX_THINKING_SECONDS
185
- or thinking_chars > MAX_THINKING_CHARS
223
+ or result._thinking_total_chars > MAX_THINKING_CHARS
186
224
  ):
187
225
  result.thinking_abort = True
226
+ result.thinking_abort_reason = "length"
188
227
  return
189
228
  if typ == "content":
190
229
  chunk = _strip_thinking_tokens(event.get("content", ""))
localcode/compaction.py CHANGED
@@ -113,6 +113,29 @@ def estimate_tokens(messages: list[dict[str, Any]]) -> int:
113
113
  return total_chars // _CHARS_PER_TOKEN
114
114
 
115
115
 
116
+ def _compact_fraction() -> float:
117
+ """The fraction of the usable window at which auto-compaction fires.
118
+
119
+ Default COMPACT_THRESHOLD_FRACTION (0.70). Overridable via
120
+ LOCALCODE_COMPACT_PCT (1-100, like Claude Code's CLAUDE_AUTOCOMPACT_PCT_
121
+ OVERRIDE) so a user with a big machine can let context grow closer to the
122
+ full window before summarising, or compact earlier on a small one. Always
123
+ relative to the machine's real RAM-scaled context window — never a fixed
124
+ token count.
125
+ """
126
+ import os
127
+ raw = os.environ.get("LOCALCODE_COMPACT_PCT")
128
+ if raw is None:
129
+ return COMPACT_THRESHOLD_FRACTION
130
+ try:
131
+ pct = float(raw)
132
+ except (TypeError, ValueError):
133
+ return COMPACT_THRESHOLD_FRACTION
134
+ # Accept either a percent (1-100) or a fraction (0-1); clamp to a sane band.
135
+ frac = pct / 100.0 if pct > 1.0 else pct
136
+ return min(max(frac, 0.30), 0.95)
137
+
138
+
116
139
  def should_compact(
117
140
  messages: list[dict[str, Any]],
118
141
  context_window: int,
@@ -121,13 +144,16 @@ def should_compact(
121
144
  """Return True when the prompt is about to crowd out new generation.
122
145
 
123
146
  `context_window` is the number of tokens llama-server was launched
124
- with (`-c`). `reserve_tokens` is headroom we promise to keep free.
147
+ with (`-c`) the machine's real RAM-scaled window. `reserve_tokens` is
148
+ headroom we promise to keep free. The trigger is always a fraction of THAT
149
+ window (see `_compact_fraction`), so a 256K-capable machine compacts near
150
+ 256K and a 64K one near 64K.
125
151
  """
126
152
  est = estimate_tokens(messages)
127
153
  available = context_window - reserve_tokens
128
154
  if available <= 0:
129
155
  return True
130
- return est > int(available * COMPACT_THRESHOLD_FRACTION)
156
+ return est > int(available * _compact_fraction())
131
157
 
132
158
 
133
159
  def _split_at_keep_recent(
localcode/entrypoint.py CHANGED
@@ -371,8 +371,16 @@ def main(argv: list[str] | None = None) -> None:
371
371
  # prior session's messages before showing the chat screen.
372
372
  if getattr(args, "resume", None):
373
373
  app._resume_session_id = args.resume
374
+ # Mouse capture OFF by default (same as tui.app.main). Capturing the mouse
375
+ # disables the terminal's NATIVE text selection, so Cmd+C on an empty
376
+ # native selection makes the terminal ring its bell — the persistent copy
377
+ # "beep". This is the entry point `localcode` actually uses; it previously
378
+ # called app.run() with Textual's mouse=True default, so the beep fix in
379
+ # tui.app.main never reached it. Opt back in with LOCALCODE_MOUSE=1.
380
+ import os as _os
381
+ _mouse = _os.environ.get("LOCALCODE_MOUSE", "0") == "1"
374
382
  try:
375
- app.run()
383
+ app.run(mouse=_mouse)
376
384
  finally:
377
385
  try:
378
386
  _reset_terminal_state()
localcode/model_config.py CHANGED
@@ -36,12 +36,30 @@ from __future__ import annotations
36
36
  # Qwen checkpoint path is disabled due a measured Metal SIGKILL, forcing a
37
37
  # full prompt prefill every round. 128K keeps four checkpoints and is faster
38
38
  # for long agentic tasks; 256K remains an explicit override.
39
+ # Dedicated tier for every Apple Silicon RAM variant (MBA/MBP/Studio/Mini).
40
+ # The VALIDATED anchors are 16→64K, 48→96K, 64→128K; every other tier is
41
+ # INTERPOLATED strictly between its validated neighbours (or below the 16 GB
42
+ # floor), so no tier ever exceeds a value that's already been validated on real
43
+ # hardware — a 32 GB machine at 80K uses less KV than the 48 GB-validated 96K
44
+ # and more than the 16 GB-validated 64K. Sizes are multiples of 8192 (clean
45
+ # batch alignment). 256K stays an explicit override, never an auto default
46
+ # (its checkpoint path SIGKILLs on Metal — see runtime.py).
39
47
  RAM_CTX_CEILING_TIERS: tuple[tuple[int, int], ...] = (
40
- (96, 131072), # 128Kcheckpointed fast path; 256K is experimental
41
- (64, 131072), # 128K (validated; 256K KV is tight beside a Q8 model on 64 GB)
42
- (48, 98304), # 96K
48
+ (192, 131072), # 192-512 GB (Studio/Pro) 128K default; 256K via override
49
+ (128, 131072), # 128 GB 128K
50
+ (96, 131072), # 96 GB — 128K
51
+ (64, 131072), # 64 GB — 128K (validated)
52
+ (48, 98304), # 48 GB — 96K (validated)
53
+ (36, 90112), # 36 GB — 88K (interp 64K@16 → 96K@48)
54
+ (32, 81920), # 32 GB — 80K (was lumped at 64K — the gap, now dedicated)
55
+ (24, 73728), # 24 GB — 72K (interp)
56
+ (18, 65536), # 18 GB — 64K
57
+ (16, 65536), # 16 GB — 64K (validated floor)
43
58
  )
44
- RAM_CTX_CEILING_DEFAULT = 65536 # 16-47 GB: 64K (validated on 16 GB)
59
+ # <16 GB is below LocalCode's supported floor; keep the validated 64K rather
60
+ # than a lower value, since _target_num_ctx floors there anyway (a smaller
61
+ # ceiling would just contradict it). 16 GB is the smallest supported Mac.
62
+ RAM_CTX_CEILING_DEFAULT = 65536
45
63
 
46
64
 
47
65
  def ram_ctx_ceiling(ram_gb: int) -> int:
@@ -57,12 +75,22 @@ def ram_ctx_ceiling(ram_gb: int) -> int:
57
75
  # (~4x heavier per token, no -fit guard). Much tighter than the turbo ladder
58
76
  # so a long unconditional-reasoning turn can't grow the KV cache until OOM.
59
77
  # Monotonic; same (min_ram_gb, ctx) high→low form.
78
+ # Dedicated per-variant tier for the cohere2moe model too. Validated anchors:
79
+ # 16→16K, 32→32K, 48→48K, 128→64K; intermediates interpolated between them.
80
+ # Uncompressed f16 KV (~4x heavier than turbo), so every value is much tighter
81
+ # than the turbo ladder above.
60
82
  COHERE_CTX_CEILING_TIERS: tuple[tuple[int, int], ...] = (
61
- (96, 65536), # 64K
62
- (48, 49152), # 48K
63
- (32, 32768), # 32K
83
+ (192, 65536), # 64K
84
+ (128, 65536), # 64K (validated)
85
+ (96, 65536), # 64K
86
+ (64, 57344), # 56K (interp 48K@48 → 64K@96)
87
+ (48, 49152), # 48K (validated)
88
+ (36, 40960), # 40K (interp 32K@32 → 48K@48)
89
+ (32, 32768), # 32K (validated)
90
+ (24, 24576), # 24K (interp 16K@16 → 32K@32)
91
+ (16, 16384), # 16K (validated)
64
92
  )
65
- COHERE_CTX_CEILING_DEFAULT = 16384 # <32 GB: 16K (32 GB+ is the recommended floor)
93
+ COHERE_CTX_CEILING_DEFAULT = 16384 # <16 GB: 16K (32 GB+ is the recommended floor)
66
94
 
67
95
 
68
96
  def cohere_ctx_ceiling(ram_gb: int) -> int:
localcode/runtime.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  import re
5
6
  from typing import Any, Iterator
6
7
 
@@ -10,37 +11,30 @@ from .config import RuntimeConfig
10
11
  from .runtime_diffusion import _DiffusionMixin
11
12
 
12
13
 
14
+ def _thinking_budget_tokens() -> int:
15
+ """Per-request reasoning-token budget sent to llama.cpp on thinking rounds.
16
+
17
+ 8192 tokens ≈ 32K chars of genuine planning, but caps a degenerate phrase
18
+ before it eats the remaining 100K+ context. Overridable per deployment /
19
+ model family via LOCALCODE_THINKING_BUDGET; <= 0 disables the budget (falls
20
+ back to the Python-side char/time/periodicity guards only). Not yet
21
+ calibrated per model — see the open item to tune it against eval data.
22
+ """
23
+ raw = os.environ.get("LOCALCODE_THINKING_BUDGET", "").strip()
24
+ if not raw:
25
+ return 8192
26
+ try:
27
+ return int(raw)
28
+ except ValueError:
29
+ return 8192
30
+
31
+
13
32
  from .model_families import (
14
33
  ModelFamily, get_adapter, infer_family_from_profile,
15
34
  strip_thinking_tokens as _family_strip,
16
35
  )
17
36
 
18
37
 
19
- #: Low default sampling temperature for coding. On quantized local models a
20
- #: low temperature is the single strongest anti-hallucination lever — Gemma's
21
- #: 1.0 and Qwen's 0.7 were tuned for prose, not code. Overridable per-user via
22
- #: the LOCALCODE_CODING_TEMP env var so the change is fully reversible
23
- #: (=0.7 restores the old Qwen cap, =1.0 the old Gemma behaviour).
24
- _CODING_TEMPERATURE_DEFAULT = 0.25
25
-
26
-
27
- def _coding_temperature() -> float:
28
- """The coding temperature ceiling (default 0.25), overridable via
29
- LOCALCODE_CODING_TEMP. A malformed value falls back to the default."""
30
- import os
31
- raw = os.environ.get("LOCALCODE_CODING_TEMP")
32
- if raw is None:
33
- return _CODING_TEMPERATURE_DEFAULT
34
- try:
35
- val = float(raw)
36
- except (TypeError, ValueError):
37
- return _CODING_TEMPERATURE_DEFAULT
38
- # Clamp to a sane range; temperature must be > 0 for the sampler.
39
- if val <= 0:
40
- return _CODING_TEMPERATURE_DEFAULT
41
- return min(val, 2.0)
42
-
43
-
44
38
  def _strip_thinking_tokens(text: str, family: ModelFamily | None = None) -> str:
45
39
  """Strip thinking-channel tokens that leak through in decoded text.
46
40
 
@@ -1088,56 +1082,25 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
1088
1082
  return min(num_ctx, self._model_max_ctx(model_path))
1089
1083
 
1090
1084
  def _options(self, num_ctx_override: int | None = None, num_predict_override: int | None = None) -> dict[str, Any]:
1091
- # Sampler stack that is ACTUALLY forwarded to llama-server by _payload:
1092
- # temperature + DRY + min_p. Nothing else. (This dict used to also carry
1093
- # top_p / top_k / repeat_penalty / repeat_last_n / presence_penalty and a
1094
- # num_ctx all DEAD: the llama_cpp _payload path never read them, and the
1095
- # ollama-shaped branch that did has been removed. Deleted to stop the
1096
- # confusion of "tuned" values that never reached the server.)
1097
- #
1098
- # Why only DRY + min_p as anti-loop, and NOT the top_p=0.8 / top_k=20 /
1099
- # repeat_penalty=1.10 bundle: re-adding that bundle caused the documented
1100
- # 2026-04-29 paraphrase-loop regression — repeat_penalty starved the EOS
1101
- # token so the model rephrased the same answer 2-3× instead of stopping.
1102
- # * DRY (Don't Repeat Yourself) penalises repeated N-GRAMS, scaling the
1103
- # penalty exponentially with match length. Unlike repeat_penalty it
1104
- # is EOS-neutral (single tokens aren't N-grams), so it breaks phrasal
1105
- # loops without suppressing the stop token.
1106
- # * min_p trims the low-probability tail that token-collapse loops feed
1107
- # on (Unsloth's Gemma-4 repetition fix recommends 0.05 for heavily-
1108
- # quantized MoE; Qwen 0.0).
1085
+ # Temperature + num_predict only. The FULL sampler (top_p/top_k/min_p/
1086
+ # presence_penalty/repeat_penalty) is family- and thinking-aware and is
1087
+ # assembled in _payload via _sampler_params the single source of truth,
1088
+ # forwarded verbatim to llama-server. `temperature` here mirrors that
1089
+ # family value (thinking mode, localcode's default) so the retry/
1090
+ # escalation path that reads _options()["temperature"] stays consistent.
1109
1091
  family = infer_family_from_profile(
1110
1092
  getattr(self.config, "model", "") or getattr(self.config, "profile", "") or ""
1111
1093
  )
1112
- # Coding temperature ceiling low temp is the single strongest anti-
1113
- # hallucination lever on quantized models, so we cap ALL families to a
1114
- # low coding default (Gemma's 1.0 / Qwen's 0.7 were prose tunings). This
1115
- # only ever LOWERS temperature (a user who set a lower config temperature
1116
- # keeps it) and the ceiling is overridable via LOCALCODE_CODING_TEMP, so
1117
- # the change is fully reversible.
1118
- temperature = min(float(self.config.temperature), _coding_temperature())
1119
- # Qwen wants min_p off; Gemma-4 wants 0.05 (Unsloth's quantized-MoE fix).
1120
- min_p = 0.0 if family == ModelFamily.QWEN else 0.05
1094
+ # Vendor-recommended temperature for this family; a user-set config
1095
+ # temperature (LOCALCODE_TEMPERATURE) only ever LOWERS it, never raises.
1096
+ _vendor_t = self._sampler_params(family, thinking=True)["temperature"]
1097
+ try:
1098
+ temperature = min(_vendor_t, float(self.config.temperature))
1099
+ except Exception:
1100
+ temperature = _vendor_t
1121
1101
 
1122
1102
  opts: dict[str, Any] = {
1123
1103
  "temperature": temperature,
1124
- "min_p": min_p,
1125
- "dry_multiplier": 1.5,
1126
- "dry_base": 1.75,
1127
- # allowed_length=2 catches phrasal loops (which span many tokens)
1128
- # but lets single tokens like "marc" or path components repeat.
1129
- "dry_allowed_length": 2,
1130
- # Limit DRY to the last 256 GENERATED tokens — NOT the whole prompt
1131
- # (-1). With -1, DRY penalised any string anywhere in the system
1132
- # prompt / file paths / prior tool results — including the user's
1133
- # username when it tried to emit a path (2026-04-27: mangled username
1134
- # variants). Scoping to 256 generated tokens catches loops the model
1135
- # CREATES this round while leaving prompt-side repetition untouched.
1136
- "dry_penalty_last_n": 256,
1137
- # Sequence breakers RESET DRY's n-gram matching at these tokens so a
1138
- # repeated file PATH / identifier isn't treated as a penalizable loop
1139
- # (llama.cpp's default breakers don't include path chars).
1140
- "dry_sequence_breakers": ["\n", "/", ".", "_", "-", " ", ":", "\"", "'"],
1141
1104
  }
1142
1105
  if self.config.mode == "fast":
1143
1106
  opts["num_predict"] = 4096 # cap generation for speed
@@ -1162,6 +1125,49 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
1162
1125
  opts["num_predict"] = cap
1163
1126
  return opts
1164
1127
 
1128
+ def _sampler_params(self, family: "ModelFamily", thinking: bool) -> dict[str, Any]:
1129
+ """Vendor-recommended sampling for the active family + mode.
1130
+
1131
+ These are the EXACT values forwarded to llama-server — no "tuned but
1132
+ never sent" dead knobs. LocalCode is a coding agent, so families with a
1133
+ distinct coding profile use it. Sources (verified 2026-07):
1134
+ * Qwen3.6-35B-A3B model card + Unsloth guide — thinking/precise-coding
1135
+ vs general vs instruct.
1136
+ * Gemma team / Unsloth Gemma inference config.
1137
+
1138
+ Key point on the loop we hit: Qwen's coding profile turns penalties OFF
1139
+ (presence_penalty 0, repeat_penalty 1.0) and relies on a TIGHT sampler
1140
+ (temp 0.6, top_k 20) to stay on-track. A repeat_penalty (the old 1.05)
1141
+ penalises the EOS token → the model never stops → endless reasoning. And
1142
+ top_k had been left at the server default 40, twice Qwen's 20, widening
1143
+ the pool the loop wandered in. min_p 0.0 is a REAL value Qwen wants (keep
1144
+ the tail), so it is forwarded explicitly, never dropped as falsy.
1145
+ """
1146
+ if family == ModelFamily.QWEN:
1147
+ if thinking:
1148
+ # Thinking, precise coding (WebDev) — the localcode default.
1149
+ return {"temperature": 0.6, "top_p": 0.95, "top_k": 20,
1150
+ "min_p": 0.0, "presence_penalty": 0.0, "repeat_penalty": 1.0}
1151
+ # Non-thinking / instruct.
1152
+ return {"temperature": 0.7, "top_p": 0.80, "top_k": 20,
1153
+ "min_p": 0.0, "presence_penalty": 1.5, "repeat_penalty": 1.0}
1154
+ if family == ModelFamily.GEMMA4:
1155
+ # Gemma team optimal: temp 1.0, top_k 64, top_p 0.95, min_p 0, rp 1.0.
1156
+ return {"temperature": 1.0, "top_p": 0.95, "top_k": 64,
1157
+ "min_p": 0.0, "presence_penalty": 0.0, "repeat_penalty": 1.0}
1158
+ if family == ModelFamily.COHERE:
1159
+ # Cohere North-Mini-Code official (used in their own coding
1160
+ # benchmarks): temp 1.0, top_p 0.95, top_k disabled (0 → use p).
1161
+ # It reasons every turn, so the per-turn generation cap in _options
1162
+ # (cohere_generation_cap) is the runaway backstop, not a penalty.
1163
+ return {"temperature": 1.0, "top_p": 0.95, "top_k": 0,
1164
+ "min_p": 0.0, "presence_penalty": 0.0, "repeat_penalty": 1.0}
1165
+ # Llama / DeepSeek / unknown — safe coding default.
1166
+ # (DiffusionGemma does NOT reach here: it runs through the diffusion
1167
+ # runner with its own Entropy-Bound sampler, not llama-server sampling.)
1168
+ return {"temperature": 0.6, "top_p": 0.95, "top_k": 40,
1169
+ "min_p": 0.05, "presence_penalty": 0.0, "repeat_penalty": 1.05}
1170
+
1165
1171
  def chat_once(
1166
1172
  self,
1167
1173
  messages: list[dict[str, Any]],
@@ -2175,11 +2181,23 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
2175
2181
  # body. (The old ollama-shaped `{"options": opts, ...}` branch and the
2176
2182
  # never-merged `extra` dict were dead code and have been removed.)
2177
2183
  opts = self._options(num_ctx_override=num_ctx, num_predict_override=num_predict)
2184
+ family = infer_family_from_profile(
2185
+ getattr(self.config, "model", "") or getattr(self.config, "profile", "") or ""
2186
+ )
2187
+ sampler = self._sampler_params(family, bool(think))
2188
+ # A user-set temperature only ever LOWERS the vendor value (anti-
2189
+ # hallucination), never raises it. config.temperature defaults to 1.0,
2190
+ # so by default the vendor value stands.
2191
+ try:
2192
+ _user_t = float(self.config.temperature)
2193
+ except Exception:
2194
+ _user_t = sampler["temperature"]
2195
+ temperature = min(sampler["temperature"], _user_t)
2178
2196
  payload: dict[str, Any] = {
2179
2197
  "model": self.config.model,
2180
2198
  "stream": stream,
2181
2199
  "messages": messages,
2182
- "temperature": opts["temperature"],
2200
+ "temperature": temperature,
2183
2201
  "chat_template_kwargs": {"enable_thinking": think},
2184
2202
  # Explicit KV prefix-cache opt-in. Recent llama.cpp builds default
2185
2203
  # this to true for /v1/chat/completions, but TurboQuant is a fork —
@@ -2191,40 +2209,38 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
2191
2209
  # few hundred new tokens.
2192
2210
  "cache_prompt": True,
2193
2211
  }
2194
- # Single-knob anti-rephrase: `repeat_penalty=1.05` only.
2195
- # Added 2026-04-29 after stripping the full sampler stack
2196
- # killed multi-round looping but left intra-decode rephrase
2197
- # intact ("hows weather in tokyo" 3 paraphrased sentences
2198
- # in one 80-token decode). Why ONLY this one and at 1.05
2199
- # (not the previous 1.10): the loop with the full stack was
2200
- # penalty=1.10 + top_p=0.8 + top_k=20 + DRY, which trapped
2201
- # the model into rephrasing because EOS was penalised AND
2202
- # the candidate pool was tiny. Server defaults give us
2203
- # top_p=0.95 / top_k=40 plenty of escape room — so a
2204
- # mild 1.05 penalty discourages exact phrase repeats
2205
- # without starving EOS. If this re-introduces a loop,
2206
- # revert this hunk and accept occasional rephrase as a
2207
- # model property of Qwen3.6 IQ2_M.
2208
- payload["repeat_penalty"] = 1.05
2209
- # Forward the EOS-NEUTRAL anti-loop samplers (DRY + min_p). DRY
2210
- # penalises repeated N-GRAMS — not single tokens — so unlike
2211
- # repeat_penalty it does NOT suppress the EOS/sentence-end tokens that
2212
- # caused the 2026-04-29 paraphrase regression; min_p trims the low-
2213
- # probability tail that token-collapse loops feed on. llama-server has
2214
- # DRY OFF by default, so NOT forwarding these lets repeat-collapse loops
2215
- # through. We deliberately do NOT re-add the aggressive repeat_penalty
2216
- # /top_k/top_p bundle (that was the regression). A/B back to
2217
- # temperature-only with LOCALCODE_SAMPLER_MINIMAL=1.
2212
+ if think:
2213
+ # Bound the reasoning channel at the sampler, before the stream
2214
+ # reaches the Python-side runaway guards. The bundled llama.cpp
2215
+ # recognizes the active chat template's think tags and, when this
2216
+ # budget is exhausted, forces the matching end tag so the SAME
2217
+ # generation can proceed to text/tool calls. This preserves the
2218
+ # one-pass reasoning -> action protocol used by Codex, OpenCode,
2219
+ # and pi while adopting Claude Code's separately bounded thinking
2220
+ # budget. It is the primary transition mechanism; the streaming
2221
+ # char/time/periodicity checks remain compatibility backstops for
2222
+ # templates whose reasoning tags cannot be identified.
2223
+ #
2224
+ # 8K tokens is deliberately not a terse "reasoning quality" cap:
2225
+ # it allows roughly 32K chars of genuine planning, but prevents a
2226
+ # degenerate phrase from consuming the remaining 100K+ context.
2227
+ # Named + env-overridable (LOCALCODE_THINKING_BUDGET); <=0 omits it.
2228
+ _budget = _thinking_budget_tokens()
2229
+ if _budget > 0:
2230
+ payload["thinking_budget_tokens"] = _budget
2231
+ # Forward the FULL vendor-recommended sampler (see _sampler_params).
2232
+ # Previously top_k / top_p / presence_penalty were never sent (server
2233
+ # defaults top_k=40 / top_p=0.95 stood) and min_p=0.0 was dropped as
2234
+ # falsy so Qwen never got its tight top_k=20 / min_p=0 profile and ran
2235
+ # away. These are the exact vendor values; min_p 0.0 is forwarded
2236
+ # explicitly. A/B back to temperature-only with LOCALCODE_SAMPLER_MINIMAL=1.
2218
2237
  import os as _os
2219
2238
  if _os.environ.get("LOCALCODE_SAMPLER_MINIMAL") != "1":
2220
- payload["dry_multiplier"] = opts["dry_multiplier"]
2221
- payload["dry_base"] = opts["dry_base"]
2222
- payload["dry_allowed_length"] = opts["dry_allowed_length"]
2223
- payload["dry_penalty_last_n"] = opts["dry_penalty_last_n"]
2224
- if opts.get("dry_sequence_breakers"):
2225
- payload["dry_sequence_breakers"] = opts["dry_sequence_breakers"]
2226
- if opts.get("min_p", 0):
2227
- payload["min_p"] = opts["min_p"]
2239
+ payload["top_p"] = sampler["top_p"]
2240
+ payload["top_k"] = sampler["top_k"]
2241
+ payload["min_p"] = sampler["min_p"]
2242
+ payload["presence_penalty"] = sampler["presence_penalty"]
2243
+ payload["repeat_penalty"] = sampler["repeat_penalty"]
2228
2244
  if "num_predict" in opts:
2229
2245
  _np = opts["num_predict"]
2230
2246
  # llama-server treats max_tokens=-1 as "use default", which on
localcode/thinking.py CHANGED
@@ -80,7 +80,17 @@ def should_use_thinking(
80
80
  if policy in {"0", "false", "no", "off", "none", ""}:
81
81
  return False
82
82
  if policy in {"1", "true", "yes", "on"}:
83
- return True
83
+ # `on` forces reasoning on genuine decision rounds — but still skips the
84
+ # channel on rounds we've explicitly marked mechanical (post-write
85
+ # implement / verify / running / complete): there is nothing to reason
86
+ # about there, and forcing the degeneration-prone channel onto a
87
+ # mechanical round is what lets a small quantized model spiral into a
88
+ # repetition loop. Gating loop INCIDENCE here at the policy layer
89
+ # composes with the sampler thinking-budget + periodicity detector +
90
+ # no-think retry, which catch any residual. Reasoning stages and
91
+ # un-staged rounds still think, so `on` still means "reason deeply".
92
+ stage = (task_stage or "").strip().lower()
93
+ return stage not in _NO_THINKING_STAGES
84
94
  if policy == "legacy":
85
95
  return runtime_mode.endswith("-think")
86
96
  if policy == "auto":
localcode/tui/app.py CHANGED
@@ -85,6 +85,11 @@ class LocalCodeTUI(App):
85
85
 
86
86
  BINDINGS = [
87
87
  Binding("ctrl+c", "copy_or_quit", "Copy/Quit", show=False, priority=True),
88
+ # Attach an image from the OS clipboard. Cmd+V can't be relied on for
89
+ # images: most terminals send NO paste event when the clipboard holds
90
+ # an image and no text, so the paste hook never fires. This dedicated
91
+ # key reads the clipboard directly, terminal-independent.
92
+ Binding("ctrl+g", "attach_image", "Attach image", show=False, priority=True),
88
93
  ]
89
94
 
90
95
  SCREENS = {
@@ -395,6 +400,19 @@ class LocalCodeTUI(App):
395
400
  if callable(_hint):
396
401
  _hint()
397
402
 
403
+ def action_attach_image(self) -> None:
404
+ """Ctrl+G: pull an image off the OS clipboard and attach it (works even
405
+ when the terminal won't deliver a Cmd+V paste event for an image)."""
406
+ screen = self.screen
407
+ handler = getattr(screen, "_attach_clipboard_image", None)
408
+ if callable(handler):
409
+ try:
410
+ if not handler():
411
+ log = screen.query_one("#chat-log")
412
+ log.append_info("[dim]No image on the clipboard — copy or screenshot one first.[/]")
413
+ except Exception:
414
+ pass
415
+
398
416
  # Route bridge messages to the active screen
399
417
  def on_agent_event(self, event: AgentEvent) -> None:
400
418
  screen = self.screen
@@ -699,6 +699,7 @@ _SLASH_COMMANDS = [
699
699
  ("/model", "List available models / switch (e.g. /model qwen)"),
700
700
  ("/delete", "Delete a downloaded model to free disk space (asks first)"),
701
701
  ("/hooks", "Show this repo's .localcode/hooks.toml and trust it (runs shell)"),
702
+ ("/paste", "Attach an image/screenshot from the clipboard (or press Ctrl+G)"),
702
703
  ("/thinking", "Show / set hidden reasoning policy (off|auto)"),
703
704
  ("/sounds", "Toggle completion + approval notification sounds"),
704
705
  ("/voice", "Toggle voice mode (push-to-talk dictation into the input box)"),
@@ -718,7 +719,7 @@ _SLASH_COMMANDS = [
718
719
  # /Users/you/project — is sent to the model as a normal message instead of
719
720
  # being rejected as an "Unknown command". Only `!` enters shell mode.
720
721
  _KNOWN_COMMANDS = {name for name, _desc in _SLASH_COMMANDS} | {
721
- "/quit", "/search", "/copy",
722
+ "/quit", "/search", "/copy", "/image",
722
723
  }
723
724
 
724
725
 
@@ -1589,15 +1590,6 @@ class ChatScreen(Screen):
1589
1590
  model = "local gguf"
1590
1591
  else:
1591
1592
  model = raw_model or "no model selected"
1592
- task_stage = ""
1593
- try:
1594
- task = getattr(getattr(self.tui.engine, "session", None), "current_task", None)
1595
- if task is not None and getattr(task, "current_stage", ""):
1596
- task_stage = str(getattr(task, "current_stage", "")).strip()
1597
- elif getattr(self.tui.engine, "_last_turn_task_stage", ""):
1598
- task_stage = str(getattr(self.tui.engine, "_last_turn_task_stage", "")).strip()
1599
- except Exception:
1600
- task_stage = ""
1601
1593
  # Server status — short, plain-English action label ("ready",
1602
1594
  # "loading", "stopped", "not connected") — the value word IS the state.
1603
1595
  provider = (config.runtime.provider or "").lower()
@@ -1725,7 +1717,6 @@ class ChatScreen(Screen):
1725
1717
  + f" · {server_label} · permissions: {self._permissions_label()} · "
1726
1718
  f"context: {pct_remaining}% free · "
1727
1719
  f"thinking: {thinking_label}"
1728
- + (f" · task: {task_stage}" if task_stage else "")
1729
1720
  + f" · model: {short_model}"
1730
1721
  )
1731
1722
  # Use the adaptive `build_tag` chosen above; honour the
@@ -2247,6 +2238,10 @@ class ChatScreen(Screen):
2247
2238
  self._handle_delete_command(text)
2248
2239
  elif text == "/hooks" or text.startswith("/hooks "):
2249
2240
  self._handle_hooks_command(text)
2241
+ elif text == "/paste" or text == "/image":
2242
+ log = self.query_one("#chat-log", ChatLog)
2243
+ if not self._attach_clipboard_image():
2244
+ log.append_info("[dim]No image on the clipboard — copy or screenshot one first.[/]")
2250
2245
  elif text == "/thinking" or text.startswith("/thinking "):
2251
2246
  self._handle_thinking_command(text)
2252
2247
  elif text == "/status":
@@ -2912,6 +2907,31 @@ class ChatScreen(Screen):
2912
2907
  except Exception:
2913
2908
  png = None
2914
2909
  if not png:
2910
+ # Empty paste, no readable image. If the user was trying to paste an
2911
+ # image they'd otherwise get NO feedback — show a one-time grey hint
2912
+ # on how image pasting works, tailored to the current model. Guarded
2913
+ # so an accidental empty Cmd+V doesn't spam it.
2914
+ if not getattr(self, "_image_paste_hint_shown", False):
2915
+ try:
2916
+ from ...models_catalog import current as current_choice
2917
+ log = self.query_one("#chat-log", ChatLog)
2918
+ choice = current_choice(self.tui.config)
2919
+ vis_on = bool(getattr(self.tui.config.runtime, "vision_enabled", False))
2920
+ if choice is not None and getattr(choice, "supports_vision", False):
2921
+ if not vis_on:
2922
+ log.append_info(
2923
+ "[dim]To paste an image, run /vision to enable image "
2924
+ "support, then paste again.[/]"
2925
+ )
2926
+ self._image_paste_hint_shown = True
2927
+ else:
2928
+ log.append_info(
2929
+ "[dim]To paste images, switch to a vision model "
2930
+ "(Gemma 4 / Qwen 3.6) with /model, then run /vision.[/]"
2931
+ )
2932
+ self._image_paste_hint_shown = True
2933
+ except Exception:
2934
+ pass
2915
2935
  return False
2916
2936
  import base64
2917
2937
  b64 = base64.b64encode(png).decode("ascii")
@@ -116,6 +116,13 @@ class ModelPickerScreen(Screen):
116
116
  overflow-y: auto; /* scroll instead of clipping */
117
117
  padding: 1 2;
118
118
  border: round #5f87ff;
119
+ /* Grey scrollbar (matches the app); the blue border stays as the brand
120
+ accent, but the scroller must not be the clashing default blue. */
121
+ scrollbar-color: #333333;
122
+ scrollbar-color-hover: #555555;
123
+ scrollbar-color-active: #666666;
124
+ scrollbar-background: ansi_default;
125
+ scrollbar-size-vertical: 1;
119
126
  }
120
127
  #picker-list {
121
128
  background: ansi_default;
@@ -21,6 +21,20 @@ Screen {
21
21
  overflow: hidden;
22
22
  }
23
23
 
24
+ /* ── Global scrollbar colour ──
25
+ Every scrollable widget's scrollbar is the app's muted grey, not the
26
+ textual-ansi theme's blue accent. Applied with the universal selector so
27
+ NO widget (chat log, input box, model picker, setup, diff panes, or any
28
+ future one) can reintroduce the "wrong blue" scroller — the per-widget
29
+ declarations elsewhere are now just belt-and-suspenders. */
30
+ * {
31
+ scrollbar-color: #333333;
32
+ scrollbar-color-hover: #555555;
33
+ scrollbar-color-active: #666666;
34
+ scrollbar-background: ansi_default;
35
+ scrollbar-corner-color: ansi_default;
36
+ }
37
+
24
38
 
25
39
 
26
40
  /* ── Brand bar (bottom, screens without a status row) ──
@@ -415,7 +415,11 @@ class ChatLog(RichLog):
415
415
  def _osc52() -> bool:
416
416
  try:
417
417
  fd = os.open("/dev/tty", os.O_WRONLY)
418
- os.write(fd, f"\033]52;c;{encoded}\a".encode())
418
+ # Terminate the OSC with ST (ESC \) rather than BEL (\a). Both are
419
+ # valid OSC terminators, but a terminal that doesn't consume the
420
+ # OSC 52 clipboard sequence renders a stray BEL as an audible
421
+ # beep on every copy. ST avoids that.
422
+ os.write(fd, f"\033]52;c;{encoded}\033\\".encode())
419
423
  os.close(fd)
420
424
  return True
421
425
  except OSError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localcode
3
- Version: 0.3.32
3
+ Version: 0.3.34
4
4
  Summary: High-performance AI coding on consumer hardware.
5
5
  Author: LocalCode contributors
6
6
  License-Expression: Apache-2.0
@@ -9,13 +9,13 @@ localcode/bootstrap.py,sha256=H12U60LLnVQek8jSgzZPf_aHONxEjNjTljG0s55lGBU,54110
9
9
  localcode/cache.py,sha256=iAb5u6nf6Xc_sbwvTpBvTo3HTo1wIzcQMLMnmFEVIY8,7949
10
10
  localcode/checkpoint.py,sha256=_XEb1WK0vrDTLj-hKsFM3lczNgEPREfgPCz3Ef6astc,15572
11
11
  localcode/compact.py,sha256=IONLoBxrcEbTm0Jv_AWLJIo-hcsHDJKDWMZkzIx6Nvc,2648
12
- localcode/compaction.py,sha256=oY6Ac7Wd_o9RQnolvjNkNQ-NI3IjnHGaM7zPX9nKpjI,10319
12
+ localcode/compaction.py,sha256=QTskL_xVqjyIYf7oJIg2Q-6knjhfQk8pE4WVaw6XTIE,11421
13
13
  localcode/composer.py,sha256=xNL48AcXXiDLWja2LIqRm6NgxwaE8_HmHtvIVmXwk_0,2600
14
14
  localcode/config.py,sha256=Z-zt3Df8KSmlUXAQY7bm-OwTS_hw-w5hJ9mtbGg5kvw,24181
15
15
  localcode/context.py,sha256=67ir_rfvMzn0KGTrZOQtAnrkGJX6Vx3XyM17J56oBbc,5528
16
16
  localcode/display.py,sha256=3U4RHUTwbRRloOtnen-BiRGeRKYgzwd-6eAwtLsyGCg,1591
17
17
  localcode/embeddings.py,sha256=Zb9VppcGp3WjJTD4K9WgWwL6B0QfBwzdNANMkpiPDe8,17613
18
- localcode/entrypoint.py,sha256=JklE0hvKMFe1aOc92reR2N8pjPssJiRJELsSBTTLi9g,20023
18
+ localcode/entrypoint.py,sha256=Qhrj57QkggjbgRkGMltb3PgWqAB2ksFkvZpU7dhwMCs,20578
19
19
  localcode/errors.py,sha256=PwvM-Ur2cTeXAuyJ_J-cGeezxkMl8Sq2dMPCh6IORW4,16342
20
20
  localcode/events.py,sha256=eSC7fKcGGWXxGUvxlMzZw6GrxPsNkjwC5a8QRv5r_1I,15125
21
21
  localcode/evidence.py,sha256=47B4hbfHckmE_UHgZlNW2Z0mC6BTdZMWqMwnIcKJQBw,3489
@@ -33,7 +33,7 @@ localcode/launcher.py,sha256=uDsIy66q-VexbvdIb7iwzYwDmmYhWRP10SNX4GiEWQ4,11462
33
33
  localcode/logging_utils.py,sha256=mFn_LO26IkT7OLxKs1ke4mND8BJeDLf-UULCYSSxyz0,920
34
34
  localcode/lsp.py,sha256=WTtdbdgforZdWMRqlzCQp2ndkKRbfIE1f5GfGeRH1xU,5939
35
35
  localcode/memory_guard.py,sha256=Upi6x6zNceTZeY7m8HrZktiURX-1NbTg89RSvfPl9l8,12315
36
- localcode/model_config.py,sha256=rTYsnst5mxs1eXRObcz5OVbUTJSRwE9P1IJigseZz7Q,14410
36
+ localcode/model_config.py,sha256=Z6N3TDsSJ3jiX6QXhzjnZ30IrFVKjfqD-kQD004_9vA,16122
37
37
  localcode/model_delete.py,sha256=8niDYcXxthHWZmcYREEUGBCqn2z9Z1gcCFXb9zJ67KI,17203
38
38
  localcode/model_families.py,sha256=mEBIoEMe-qL93Twxm-4mYv76dWEkKVp5CiQlY43mkWA,11203
39
39
  localcode/models.py,sha256=fetDQSmOR3GrMMv_H9ecAZSmHa2TPhsYw8eSYKDl5YA,8243
@@ -49,7 +49,7 @@ localcode/plans.py,sha256=Tih7WzRmkWO0cF4fEDwjI07978mJZ_l9jXTSTidLb9k,7424
49
49
  localcode/process_registry.py,sha256=Chmqv5WGMmdQmaAGQ1mZ6ySS5BKt7UDYbb9xS5TDOyk,6125
50
50
  localcode/recommendations.py,sha256=HVh8u2L3xbwc2qxH7Syec2cEtlEoFV9n1c33SqWF3ng,3184
51
51
  localcode/recovery.py,sha256=QUSsBA9IS5AJ6W4m4nIv2RaktTO9TJeL0RcDeUSWHJk,5237
52
- localcode/runtime.py,sha256=IMHwpgBOn_AMTRV-PPXwc9-Ful2yCzO06Uh75ddwp8s,122236
52
+ localcode/runtime.py,sha256=YEqevf3d8Yh8Tu-3vMaF5Vl1f2GY7JSm4L61gQCst3o,122975
53
53
  localcode/runtime_diffusion.py,sha256=bVopikSdQeaV9mAuzWYoXtoXpUb7U0yMXU5VFTpbsBc,39741
54
54
  localcode/server_manager.py,sha256=JHLEdG7FS46K9HIh6utEm-Nqx8W_gHMvicyK29q_GwE,41412
55
55
  localcode/session.py,sha256=taEyOOiCcNXEr8oOeszyWvzvn-oAZ7knXQ7BxRew08Q,11970
@@ -60,7 +60,7 @@ localcode/sounds.py,sha256=7AfQYyX7FJ76IDCikP7G8C5KDobdkK75fdeAqotsJXg,1406
60
60
  localcode/telemetry.py,sha256=FzChuRetLpvlzMFAwkPLUjJTdicmgec3fJqS_6rjPZ0,9489
61
61
  localcode/theme.py,sha256=q88NoDRvcxd71vWgZ95RkZKpzenR8Ov8vFmRUVmBZyc,6172
62
62
  localcode/thermal.py,sha256=q2A-HlIs-Uaqpf6bUswE2q9qbvdZCMKe6b9Fg--yHV4,6229
63
- localcode/thinking.py,sha256=588EujKT3CyA6logo2rTtS1KKEvE5GGJWa7nWzBPYlY,3345
63
+ localcode/thinking.py,sha256=qZCW0Gr9BnR8AzXVz02wxzoOTXRc9Gv0W5YM7qKpl4A,4113
64
64
  localcode/tool_router.py,sha256=bmCwTAhFqo8oadJZ-FDSTdEvfwu3fHok8qVOBbxnQG4,9832
65
65
  localcode/toolkit.py,sha256=PEToa9AUjp4D1xcdSBk9DT-0bU0zGigKhBR6aiwf4OA,57843
66
66
  localcode/turn_diff.py,sha256=ls5soQdYthLaep3eP20Xc5giFAlAx0ObVAzz7egy6Jk,6101
@@ -69,18 +69,19 @@ localcode/verification.py,sha256=UExbGJSB0Ts_PB68yrrqpBAE84rfHfjPAgkELBDGdDs,301
69
69
  localcode/voice.py,sha256=ZAh1GmJiQJF4cXQ2YCclJZB77Sa9yflox22MnDMl7t8,39178
70
70
  localcode/agent/__init__.py,sha256=-aQIBiYIojvwl9nDf_wcyiT5UQTmcEQbEBwnBEsrjEU,7349
71
71
  localcode/agent/app_tasks.py,sha256=8fkC0wErSvP8lc8Hk5GhWSk5IZ4iiEx8pO7H4WEPs0k,5372
72
- localcode/agent/constants.py,sha256=SGffEjumr-9kn5ZBFl2C6L2J8NLcZ1PgMmkKLf0MNtg,12780
72
+ localcode/agent/constants.py,sha256=Qt6uIgTMld_L0cRtUU5dNZzHJoKfStRZqzm_IuoiOXk,12860
73
73
  localcode/agent/context.py,sha256=_zSg3teS-7gIbQcIqQDLamUDowYA7ErEdI2YslytY38,50427
74
74
  localcode/agent/goal.py,sha256=Hy2DMXS3dRcNQoNlifUKYHQqkQTl1VRVdoA_VfLX8QQ,8097
75
75
  localcode/agent/helpers.py,sha256=Hk5E_MX2XXlogSBYfHy8IsDOJHmQVu7fB4DH90hdDSs,26899
76
76
  localcode/agent/hooks.py,sha256=n01uWdt3elOaalURyx3vHg94yCmgL8YMzDohkxT0JAk,16695
77
- localcode/agent/loop.py,sha256=ilr2ACErrKHXYhAcsPPf3Eo5EGR8hE7HtH0GuGKoXv0,124584
78
- localcode/agent/prompt_context.py,sha256=WFCJy5OX2cF1kvpniPDPaYo5RroC0al8V4VOgiaog_0,10397
79
- localcode/agent/prompts.py,sha256=VyFaHYDGvJIZ1a-wMpiUkhthZYu8iwZcQ0SIb4Rffs8,10417
77
+ localcode/agent/loop.py,sha256=ZbYe-Hh0kos6fjaprgwbiSDQPGKvdzOrvfP4IODGvXk,128180
78
+ localcode/agent/prompt_context.py,sha256=FHihqTxTg7djEwV2EArts88AxNxShCja93NYw3w86ww,11046
79
+ localcode/agent/prompts.py,sha256=ru_4gBEwY7TT9QysEFdRYJOFkErRhsZKwJHNciBMxhU,10981
80
+ localcode/agent/reasoning_loop.py,sha256=f71RSAeeoW4gEUCY1cqp2QF2ryJOpaTaJ6g_-KOKx68,3676
80
81
  localcode/agent/recovery.py,sha256=Nn5frHkROavHoJ96j2drNrK_VD_kM549AQUr-Hl7t4Q,26445
81
82
  localcode/agent/sections.py,sha256=9hnwaNritQHgn7qIGFBZ3ChZgMQXQcqtmpdbR1wafA0,8998
82
83
  localcode/agent/state_machine.py,sha256=g99sKobwWN8y7dySlkcYTydWp0dPihOZrnLjJmQ9Ph0,3537
83
- localcode/agent/streaming.py,sha256=tW3LxUJlUyh3vRzeUsEo8lQcbc3DBvRtWF_2VLLQ9H8,11331
84
+ localcode/agent/streaming.py,sha256=8Uy7Bq-YEcXb8pdSKIAbutUmgKAAKn6aR9fvUmsLnH8,13528
84
85
  localcode/agent/tool_execution.py,sha256=W7uleQ1Xf4e04njKOLGH4atth1xBG3bYOtLhwx_gxSA,17570
85
86
  localcode/agent/tool_orchestration.py,sha256=LVKNNHg59XLBlakz8J3zCwubXoWDiaXum8AS7HXB0sE,1711
86
87
  localcode/agent/turn_finalization.py,sha256=xieXqfpggfAYw2vwgouGXg2vay1ZFzDLCUrcpsZqjKQ,5361
@@ -127,26 +128,26 @@ localcode/tools/web_fetch.py,sha256=UPhMrLipwN5xJx5-zr634gL4p6nIRqYI2_7tbUw_8Go,
127
128
  localcode/tools/web_search.py,sha256=ILfBBx50p00OjA939HzdDmdao4QLL8sRuxlBoUDjmC8,1122
128
129
  localcode/tools/write_file.py,sha256=Rk1d7P5IyBG2zLwfe97vw2leJE-brdr0axusET7IJDE,12370
129
130
  localcode/tui/__init__.py,sha256=Yfy9bvw3l1iY4FDhRX8qZVgnZLuYiq8lc9SDYl4cWwc,61
130
- localcode/tui/app.py,sha256=c4Ahli1z6XUd_MmpJRKbeQywdwrjrSieX2hwMvfV5NE,18020
131
+ localcode/tui/app.py,sha256=uPqY9H38Swvs7uYz18lsP5OR4EwaYQBUbzUy_HDBVto,18999
131
132
  localcode/tui/bridge.py,sha256=urPPCqC_nisAYawWk3juvMI8IaoJfv4g7USpGOZOqQc,2803
132
133
  localcode/tui/clipboard_image.py,sha256=xa-Oe7eGYqJ_hH_T3f8a_FJ8YR05IWG00jnh4j8S7h0,4172
133
134
  localcode/tui/paste_collapse.py,sha256=b6vWcxNzbrCFp-r209cpB5_hhXJqEmJDK3Sg_F_LLzo,3472
134
135
  localcode/tui/screens/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
135
- localcode/tui/screens/chat.py,sha256=sKmj3O8PKttt7aBGBNERjtlLJqrYvYsUYSWLqhbfND4,211733
136
+ localcode/tui/screens/chat.py,sha256=6MXgiP8cjI62ikVd1aqEStocQB1EFeZf9UYUIqYL44s,213002
136
137
  localcode/tui/screens/mode_picker.py,sha256=cK_xNo7ytOwJXJwzUevMTcb5dwQKtjMfh60of-ntRz4,2347
137
- localcode/tui/screens/model_picker.py,sha256=VkhyREIT2W1J8A6tUe0ix5TbWXUa2XSe9qKzcXgjjz4,35991
138
+ localcode/tui/screens/model_picker.py,sha256=xeILKiAK4rmT0DbfoAz5o4uehfbltY7olosk2JsHhLM,36344
138
139
  localcode/tui/screens/setup.py,sha256=6sSKU1KagLyf5DtkQWOSBA7inPFZMTF570t-20PIwaE,40844
139
140
  localcode/tui/styles/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
140
- localcode/tui/styles/app.tcss,sha256=I1rgltTrBJFRWRVG9zWNtiyYQ3FqOUffu4AlUD0yAuQ,4962
141
+ localcode/tui/styles/app.tcss,sha256=qdXwj8xbHFYK6weF9m9Joc5P_mQAbRQ-huhCO8AwlHw,5557
141
142
  localcode/tui/widgets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
142
143
  localcode/tui/widgets/approval.py,sha256=nFyGEaIW4yIVxE8Pi-TAXlCA-geE0E23RJ5ucWhNhgE,105
143
- localcode/tui/widgets/chat_log.py,sha256=7OSo_L838CB5-g1M6Kd7_BQ5LN1GwNowbGDqHraOwHg,81630
144
+ localcode/tui/widgets/chat_log.py,sha256=CPXFSZQp4sSFdInTOr6UNYkQkPifzRVwdsJysFO5aUQ,81930
144
145
  localcode/tui/widgets/voice_visualizer.py,sha256=zOhrIxaMcg-F2Y8LvHJTddq227phmiTjtEyRbkx1XxU,4709
145
146
  localcode/tui/widgets/messages/__init__.py,sha256=952AZ1qVMGUIqPIry7mwxnYbMkgPlcY5FAwZtEa5YPg,967
146
147
  localcode/tui/widgets/messages/diff.py,sha256=khDf_MThrQz0S62-t-m7i3OU5cdNolUk1TCeKMRPDQI,10910
147
- localcode-0.3.32.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
148
- localcode-0.3.32.dist-info/METADATA,sha256=Hvq3aj9EPziPfRZwVE7vw4YGDsPdgalg8JP54dSdTWY,7897
149
- localcode-0.3.32.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
150
- localcode-0.3.32.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
151
- localcode-0.3.32.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
152
- localcode-0.3.32.dist-info/RECORD,,
148
+ localcode-0.3.34.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
149
+ localcode-0.3.34.dist-info/METADATA,sha256=bA_mm9xYAqZUZNSLrEDkwkh5AI80ykG19LWI2j_IBpA,7897
150
+ localcode-0.3.34.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
151
+ localcode-0.3.34.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
152
+ localcode-0.3.34.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
153
+ localcode-0.3.34.dist-info/RECORD,,