Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,21 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Ctrl-T during a `chat` or `run` reply closes the model's open thinking
block early (as if the thinking budget had just run out) so the answer
starts now.

### Changed

### Fixed

- CLI polish: `--help`/`--help-all` now win even after a value-taking flag,
the short `-h` pages gained the typical sampling/config/streaming flags,
tab completion offers flag values (choices, themes, profiles).
- Chat: cancelling or stopping mid-round no longer leaves rejected draft KV
in the persistent chat cache.

## [0.2.0] - 2026-08-02

Expand Down
158 changes: 104 additions & 54 deletions gmlx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
spinner that resolves to the same payoff. **Ctrl-O** toggles expand<->collapse
live during a reply; ``/reasoning [show|hide|raw]`` (startup ``--reasoning``)
sets the default and reaches ``raw`` (verbatim). The stored turn keeps the raw
text, so this is display-only;
text, so this is display-only. **Ctrl-T** while the model is thinking closes
the thinking block early (as if the thinking budget had just run out) so the
answer starts now;
* ``/load <file>`` prefills the *next* prompt with a text file's contents
(via the readline startup hook), so it can be edited before Enter sends it;
Tab completes ``/command`` names, ``/history`` arguments, and file paths
Expand Down Expand Up @@ -715,6 +717,7 @@ def _print_shim_help(state: ChatState) -> None:
"- '/reasoning [show|hide|raw]' control how thinking is displayed "
"(Ctrl-O collapses/expands it live during a reply)"
)
print("- Ctrl-T while the model is thinking: wrap up and answer now")
print("- '/render [plain|lite|rich]' set reply markdown rendering")
print("- '/theme [NAME] [cb]' set the color theme ('cb' = colorblind accents)")
print("- '/model' show the loaded model card - '/stats' session totals")
Expand Down Expand Up @@ -1169,6 +1172,11 @@ def _slash_thinking_budget(cmd, arg, state):
print(f"[chat] /thinking-budget needs an int or 'off', got {arg!r}")
return None
print(f"[chat] thinking-budget = {arg} (next reply)")
if (state.model_info or {}).get("drafter"):
print(
"[chat] note: not applied on MTP-decoded replies "
"(restart with --no-mtp to honor it)"
)
return None
if arg == "off":
state.thinking_budget = None
Expand Down Expand Up @@ -1676,8 +1684,10 @@ class _EscCancel:
immediately, not echoed into the streaming text; Ctrl-C still signals).
``pressed()`` polls stdin without blocking and drains whatever arrived,
so arrow-key escape tails don't leak into the next prompt. Esc cancels;
Ctrl-O (``\\x0f``) fires ``on_toggle`` (collapse/expand thinking). Inert
when stdin is not a tty.
Ctrl-O (``\\x0f``) fires ``on_toggle`` (collapse/expand thinking); Ctrl-T
(``\\x14``) finishes thinking early - normally it arrives as SIGINFO (the
tty's status character survives cbreak), the raw byte is the fallback for
terminals with the status character unset. Inert when stdin is not a tty.
"""

def __init__(self, on_toggle=None):
Expand Down Expand Up @@ -1709,6 +1719,10 @@ def pressed(self) -> bool:
hit = True
elif ch == b"\x0f" and self._on_toggle is not None:
self._on_toggle()
elif ch == b"\x14":
from .thinking_budget import finish_thinking_now

finish_thinking_now()
return hit

def __exit__(self, *exc):
Expand Down Expand Up @@ -3159,6 +3173,9 @@ def _apply_session(doc: dict, name: str) -> None:
_apply_session(doc, spath.stem)

from .cli import _DIFFUSION_MAX_TOKENS, _UNCAPPED_MAX_TOKENS
from .thinking_budget import install_finish_thinking_key

install_finish_thinking_key() # ^T closes an open thinking block

while True:
parts = []
Expand Down Expand Up @@ -3358,31 +3375,56 @@ def _apply_session(doc: dict, name: str) -> None:
)
# No cross-turn KV cache on the VLM path yet: each turn
# re-prefills the whole conversation (and re-encodes media).
reply, _canceled = _stream_reply(
vlm_stream(
model,
processor,
prompt,
image=list(vlm_images) or None,
audio=list(vlm_audios) or None,
max_tokens=eff_max,
temperature=s["temp"],
top_p=s["top_p"],
top_k=s["top_k"],
min_p=s["min_p"],
repetition_penalty=None if rep in (0.0, 1.0) else rep,
repetition_context_size=s["repetition_context_size"],
presence_penalty=s["presence_penalty"] or None,
frequency_penalty=s["frequency_penalty"] or None,
logit_bias=logit_bias,
resize_shape=resize_shape,
thinking_budget=state.thinking_budget,
**kv_kwargs,
),
state,
stops=args.stop,
start_in_thinking=_opens_thinking(prompt),
# mlx-vlm's own criteria enforces thinking_budget here; the
# budget-less interruptible processor rides along only so ^T can
# close a thinking block (mlx-vlm's generate_step applies extra
# logits_processors with the same (tokens, logits) contract).
from .thinking_budget import (
clear_finish_key_target,
make_thinking_budget_processor,
prompt_opens_thinking,
set_finish_key_target,
think_tokenizer_for,
)

vtok = think_tokenizer_for(processor)
tbp = make_thinking_budget_processor(
vtok,
None,
start_in_thinking=isinstance(prompt, str)
and prompt_opens_thinking(prompt, tokenizer=vtok),
interruptible=True,
)
set_finish_key_target(tbp)
try:
reply, _canceled = _stream_reply(
vlm_stream(
model,
processor,
prompt,
image=list(vlm_images) or None,
audio=list(vlm_audios) or None,
max_tokens=eff_max,
temperature=s["temp"],
top_p=s["top_p"],
top_k=s["top_k"],
min_p=s["min_p"],
repetition_penalty=None if rep in (0.0, 1.0) else rep,
repetition_context_size=s["repetition_context_size"],
presence_penalty=s["presence_penalty"] or None,
frequency_penalty=s["frequency_penalty"] or None,
logit_bias=logit_bias,
resize_shape=resize_shape,
thinking_budget=state.thinking_budget,
logits_processors=[tbp] if tbp is not None else None,
**kv_kwargs,
),
state,
stops=args.stop,
start_in_thinking=_opens_thinking(prompt),
)
finally:
clear_finish_key_target()
if reply:
vlm_msgs.append(_vlm_message(model_type, reply, "assistant"))
_end_turn(state, reply, _canceled)
Expand Down Expand Up @@ -3512,33 +3554,41 @@ def _apply_session(doc: dict, name: str) -> None:
# Thinking-token cap (fresh per turn - the processor is stateful). Honored
# regardless of enable_thinking; seed the in-thinking state from whether
# the rendered prompt actually opens a <think> block (see generation.generate).
if state.thinking_budget is not None:
from .thinking_budget import (
make_thinking_budget_processor,
prompt_opens_thinking,
)
# Built even with no budget set (interruptible): it never trips on its
# own, but ^T can close the thinking block through it mid-reply.
from .thinking_budget import (
clear_finish_key_target,
make_thinking_budget_processor,
prompt_opens_thinking,
set_finish_key_target,
)

tbp = make_thinking_budget_processor(
tok,
state.thinking_budget,
start_in_thinking=prompt_opens_thinking(prompt_text),
)
if tbp is not None:
logits_processors = list(logits_processors) + [tbp]
first_turn = False # the system prompt is in the cache either way
reply, canceled = _stream_reply(
stream_generate(
model,
tok,
prompt,
max_tokens=eff_max,
sampler=sampler,
logits_processors=logits_processors,
prompt_cache=cache,
**kv_kwargs,
),
state,
stops=args.stop,
start_in_thinking=_opens_thinking(prompt_text),
tbp = make_thinking_budget_processor(
tok,
state.thinking_budget,
start_in_thinking=prompt_opens_thinking(prompt_text),
interruptible=True,
)
if tbp is not None:
logits_processors = list(logits_processors) + [tbp]
first_turn = False # the system prompt is in the cache either way
set_finish_key_target(tbp)
try:
reply, canceled = _stream_reply(
stream_generate(
model,
tok,
prompt,
max_tokens=eff_max,
sampler=sampler,
logits_processors=logits_processors,
prompt_cache=cache,
**kv_kwargs,
),
state,
stops=args.stop,
start_in_thinking=_opens_thinking(prompt_text),
)
finally:
clear_finish_key_target()
_end_turn(state, reply, canceled, cache=cache)
52 changes: 41 additions & 11 deletions gmlx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ def mtp_dropped_chat_flags(args) -> list[str]:
# mtp_dropped_run_flags).
("--quantized-kv-start", args.quantized_kv_start != 0),
("--max-kv-size", args.max_kv_size is not None),
("--thinking-budget", getattr(args, "thinking_budget", None) is not None),
)
return [name for name, on in pairs if on]

Expand Down Expand Up @@ -1310,6 +1311,10 @@ def _run_generate(args) -> int:
logit_bias = parse_logit_bias(args.logit_bias)
preset_native_fp_wire_env(args)

from .thinking_budget import install_finish_thinking_key

install_finish_thinking_key() # ^T closes an open thinking block

if args.seed is not None:
import mlx.core as mx

Expand Down Expand Up @@ -1542,21 +1547,44 @@ def _run_vlm(args) -> int:
if args.prefill_step_size is not None:
extra["prefill_step_size"] = args.prefill_step_size

# ^T closes an open thinking block: mlx-vlm's generate_step applies extra
# logits_processors with the mlx-lm (tokens, logits) contract, so the
# budget-less interruptible processor rides along (mlx-vlm's own criteria
# still enforces --thinking-budget).
from .thinking_budget import (
clear_finish_key_target,
install_finish_thinking_key,
make_thinking_budget_processor,
set_finish_key_target,
think_tokenizer_for,
)

install_finish_thinking_key()
tbp = make_thinking_budget_processor(
think_tokenizer_for(processor), None, interruptible=True
)
if tbp is not None:
extra["logits_processors"] = [tbp]

print(
f"[generate] max_tokens={max_tokens_label(args)} temp={args.temp} "
f"images={len(images)} audios={len(audios)}\n"
)
result = generate(
model,
processor,
prompt,
image=images or None,
audio=audios or None,
max_tokens=args.max_tokens,
temperature=args.temp,
verbose=True,
**extra,
)
set_finish_key_target(tbp)
try:
result = generate(
model,
processor,
prompt,
image=images or None,
audio=audios or None,
max_tokens=args.max_tokens,
temperature=args.temp,
verbose=True,
**extra,
)
finally:
clear_finish_key_target()
if getattr(result, "finish_reason", None) == "length":
warn_cap_hit(args, getattr(result, "generation_tokens", None))
return 0
Expand All @@ -1573,7 +1601,9 @@ def _run_vlm_mtp(args) -> int:
from .chat import fold_thinking_flag, parse_template_config
from .generation import generate_speculative
from .mtp_load import load_vlm_mtp_model
from .thinking_budget import install_finish_thinking_key

install_finish_thinking_key() # ^T prints the MTP-path notice here
if args.seed is not None:
import mlx.core as mx

Expand Down
Loading