Skip to content

Commit f76a3d0

Browse files
committed
Merge branch 'escape'
2 parents 7bbd074 + b13a587 commit f76a3d0

18 files changed

Lines changed: 940 additions & 146 deletions

‎config.example‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ current = "baseten.glm5"
2424
; patient_retry = off ; disable patient retry of 429/5xx/network errors (on by default: ~36h hold so a long-running session rides out a usage limit or network dropout without dropping to the prompt)
2525
; sandbox_wall_warn = off ; silence the Windows "wall not set up" warning (host rules in policy, no `3code wall setup-windows` yet)
2626
; bash_path = "C:\\tools\\msys64\\usr\\bin\\bash.exe" ; Windows only: override bash detection
27+
;
28+
; [shortcuts] remaps editor commands. Value is a space-separated list of
29+
; keys; empty string unbinds. Defaults match today's hardcoded behaviour.
30+
; Key syntax: ESC / Escape, CtrlX / Ctrl+X, AltX / Alt+X, Home, End, Up,
31+
; Down, Left, Right, Insert, Delete, Backspace, Tab, ShiftTab. Prefix
32+
; Double for two consecutive presses (DoubleCtrlC, DoubleESC).
33+
; Commands: cancel, clear, quit-if-empty, home, end, left, right,
34+
; word-left, word-right, up, down, history-previous, history-next,
35+
; backspace, delete, insert, delete-word-left, delete-to-boundary-left,
36+
; clear-screen, suspend, complete, reverse-complete, edit-in-editor,
37+
; insert-newline. `clear` also adds the discarded text to history.
38+
; [shortcuts]
39+
; cancel = "DoubleESC CtrlC"
40+
; clear = "ESC"
2741

2842
# Colour tone. 3code detects dark vs light background automatically by
2943
# querying the terminal background (OSC 11). Set `tone` to pin it instead

‎src/threecode/api.nim‎

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,18 +1799,34 @@ proc stripInternalFields*(messages: JsonNode): JsonNode =
17991799
## rejected by strict validators (fireworks, glm-5p1, etc.). `finish_reason`
18001800
## is attached to empty assistant turns so the turn loop can branch on it
18011801
## but is not a wire field. `interrupted` marks user-cancelled turns.
1802+
## Empty assistant `content` is filled with a placeholder: OpenAI-style
1803+
## validators (xAI, OpenAI, Moonshot) 400 on `role: assistant` with an
1804+
## empty content string, which is how a thinking-only / length-starved
1805+
## turn looks after `reasoning_content` is stripped for non-DeepSeek.
18021806
if messages == nil or messages.kind != JArray: return messages
18031807
result = newJArray()
18041808
for m in messages:
1805-
if m.kind != JObject or
1806-
("usage" notin m and "interrupted" notin m and "finish_reason" notin m):
1807-
result.add m
1808-
continue
1809-
var clean = newJObject()
1810-
for k, v in m.pairs:
1811-
if k != "usage" and k != "interrupted" and k != "finish_reason":
1812-
clean[k] = v
1813-
result.add clean
1809+
var node = m
1810+
if m.kind == JObject and
1811+
("usage" in m or "interrupted" in m or "finish_reason" in m):
1812+
var clean = newJObject()
1813+
for k, v in m.pairs:
1814+
if k != "usage" and k != "interrupted" and k != "finish_reason":
1815+
clean[k] = v
1816+
node = clean
1817+
if node.kind == JObject and node{"role"}.getStr == "assistant":
1818+
let tcs = node{"tool_calls"}
1819+
let hasToolCalls = tcs != nil and tcs.kind == JArray and tcs.len > 0
1820+
if not hasToolCalls and node{"content"}.getStr.strip.len == 0:
1821+
if node != m:
1822+
node["content"] = %EmptyReplyMsg
1823+
else:
1824+
var filled = newJObject()
1825+
for k, v in node.pairs:
1826+
filled[k] = v
1827+
filled["content"] = %EmptyReplyMsg
1828+
node = filled
1829+
result.add node
18141830

18151831
proc ensureReasoningField(messages: JsonNode) =
18161832
## DeepSeek-R1 with thinking mode rejects any request whose history
@@ -1854,8 +1870,13 @@ proc applyGlmReasoning(p: Profile, body: JsonNode) =
18541870
## - `chat_template_kwargs.enable_thinking` (bool) on vLLM stacks (nvidia,
18551871
## hetzner); other vLLM GLM providers (nebius, deepinfra, fireworks)
18561872
## accept the same knob but always think when it's omitted.
1857-
## Inert stacks (baseten, cerebras) accept nothing and always think, so
1858-
## `off` is silently a no-op there.
1873+
## - GLM-5.3 on every remaining hosted stack (baseten, novita, deepinfra,
1874+
## fireworks, venice, nanogpt, cheaperinference, ...) gets a top-level
1875+
## `reasoning_effort` (`low`/`high`/`max`). 5.3 is forced-thinking: with
1876+
## no effort knob the default high/max thinking burns the whole
1877+
## max_tokens budget and the turn comes back as an empty `length` reply.
1878+
## Inert stacks (cerebras) accept nothing and always think, so `off` is
1879+
## silently a no-op there.
18591880
# GLM-5.2 and 5.3 are the GLMs with a graded effort knob (variants "2"
18601881
# and "3"); every other GLM is on/off. Variant encodes the minor version
18611882
# digit (4.7 -> "7", 5.1 -> "1", 5.2 -> "2", 5.3 -> "3").
@@ -1876,7 +1897,11 @@ proc applyGlmReasoning(p: Profile, body: JsonNode) =
18761897
of "max": body["thinking"] = %*{"type": "enabled", "effort": "max"}
18771898
else: discard
18781899
of "together":
1879-
if glm52:
1900+
if glm53:
1901+
case p.reasoning
1902+
of "low", "high", "max": body["reasoning_effort"] = %p.reasoning
1903+
else: discard
1904+
elif glm52:
18801905
case p.reasoning
18811906
of "off": body["reasoning"] = %*{"enabled": false}
18821907
of "max": body["reasoning_effort"] = %"max"
@@ -1893,10 +1918,22 @@ proc applyGlmReasoning(p: Profile, body: JsonNode) =
18931918
of "max": body["reasoning"] = %*{"effort": "xhigh"}
18941919
else: body["reasoning"] = %*{"effort": "high"}
18951920
of "nvidia", "hetzner":
1896-
case p.reasoning
1897-
of "off": body["chat_template_kwargs"] = %*{"enable_thinking": false}
1898-
else: discard
1899-
else: discard
1921+
if glm53:
1922+
case p.reasoning
1923+
of "low", "high", "max": body["reasoning_effort"] = %p.reasoning
1924+
else: discard
1925+
else:
1926+
case p.reasoning
1927+
of "off": body["chat_template_kwargs"] = %*{"enable_thinking": false}
1928+
else: discard
1929+
else:
1930+
# Hosted OpenAI-compatible stacks that don't have a dedicated branch
1931+
# (baseten, novita, deepinfra, fireworks, venice, nanogpt, ...).
1932+
# GLM-5.3 needs the effort knob or thinking starves the output budget.
1933+
if glm53:
1934+
case p.reasoning
1935+
of "low", "high", "max": body["reasoning_effort"] = %p.reasoning
1936+
else: discard
19001937

19011938
proc applyStreamingOptions*(p: Profile, body: JsonNode) =
19021939
## Provider-specific additions for SSE fidelity.
@@ -2401,7 +2438,7 @@ proc callModel*(p: Profile, messages: JsonNode, usage: var Usage,
24012438
"chat/completions rejects it, use a chat variant instead")
24022439
if p.family == "deepseek":
24032440
ensureReasoningField(messages)
2404-
let wireMessages = stripInternalFields(messages)
2441+
let wireMessages = repairToolCallPairing(stripInternalFields(messages))
24052442
if p.family != "deepseek":
24062443
for m in wireMessages:
24072444
if m.kind == JObject and m{"role"}.getStr == "assistant" and m.contains("reasoning_content"):

‎src/threecode/compact.nim‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,26 @@ proc applySummary*(messages: JsonNode, summary: string,
6767
if messages[0]{"role"}.getStr != "system": return 0
6868
if summary.strip.len == 0: return 0
6969
let system = messages[0]
70-
let tailStart = messages.len - keepRecent
71-
var tail = newSeq[JsonNode](keepRecent)
72-
for i in 0 ..< keepRecent:
73-
tail[i] = messages[tailStart + i]
70+
var tailStart = messages.len - keepRecent
71+
# Never start the retained suffix on a `role:tool` message: that orphans
72+
# the result from its assistant `tool_calls` and Kimi/Moonshot 400 with
73+
# `tool_call_id is not found`. Walk back to the owning assistant.
74+
while tailStart > 1 and messages[tailStart]{"role"}.getStr == "tool":
75+
dec tailStart
76+
var tail = newSeq[JsonNode]()
77+
for i in tailStart ..< messages.len:
78+
tail.add messages[i]
7479
let collapsed = tailStart - 1 # messages dropped from the middle
7580
let synthetic = %*{"role": "user",
7681
"content": SummaryPrefix & summary.strip}
7782
let rebuilt = newJArray()
7883
rebuilt.add system
7984
rebuilt.add synthetic
8085
for m in tail: rebuilt.add m
86+
# Drop any leftover leading tool results and pair unanswered tool_calls.
87+
let paired = repairToolCallPairing(rebuilt)
88+
rebuilt.elems.setLen 0
89+
for m in paired: rebuilt.add m
8190
# Replace `messages` contents in place so callers holding the ref see it.
8291
# elems is a public exported field on JArray; this is the canonical
8392
# way to clear a JArray while keeping ref identity for callers.

‎src/threecode/config.nim‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import std/[os, parsecfg, sequtils, streams, strformat, strutils, tables, terminal, uri]
1515
when defined(posix):
1616
import std/posix except SocketHandle
17-
import types, prompts, util, auth_openai, modelname
17+
import types, prompts, util, auth_openai, modelname, minline
1818

1919
type
2020
ProviderRec* = object
@@ -101,6 +101,10 @@ var activeSearchEngine*: string = "exa"
101101
## used as-is, and a missing key is an error only if that engine needs one
102102
## (brave does; exa and parallel are keyless by default).
103103

104+
var activeShortcuts*: Table[string, string]
105+
## [shortcuts] map loaded from the active config, exposed so minline can
106+
## apply the user's bindings at startup and after config reloads.
107+
104108
var bashPathOverride*: string
105109
## Windows-only. `[settings]` `bash_path = "..."` overrides MSYS2
106110
## detection in `streamexec.resolveBash` for users with bash at a
@@ -282,7 +286,7 @@ type
282286
## can report `path:line:` for a bad section/key/value without re-reading.
283287

284288
const
285-
PermittedSections = ["settings", "search", "colors", "provider"]
289+
PermittedSections = ["settings", "search", "colors", "provider", "shortcuts"]
286290
SettingsKeys = ["current", "notify", "streaming", "sandbox",
287291
"sandbox_enabled", "patient_retry", "patient-retry",
288292
"sandbox_wall_warn",
@@ -307,6 +311,7 @@ proc permittedKey(section, key: string): bool =
307311
let base = if key.endsWith("-light"): key[0 ..< key.len - 6] else: key
308312
base in ColorKeys
309313
of "provider": key in ProviderKeys
314+
of "shortcuts": key in minline.ShortcutNames
310315
else: false
311316

312317
proc validateConfig*(path: string; entries: seq[RawEntry]): string =
@@ -325,8 +330,10 @@ proc validateConfig*(path: string; entries: seq[RawEntry]): string =
325330
if ent.value.strip == "":
326331
# Empty key is fine for auth=oauth providers (and is also a structural
327332
# gap loadProfile diagnoses). Empty current/url/models likewise.
333+
# Empty [shortcuts] values are explicit unbinds, so they are allowed.
328334
if not (ent.section == "provider" and ent.key == "key") and
329-
not (ent.section == "settings" and ent.key == "current"):
335+
not (ent.section == "settings" and ent.key == "current") and
336+
not (ent.section == "shortcuts"):
330337
return &"{path}:{ent.line}: empty value for '{ent.key}' in [{ent.section}]"
331338
case ent.section
332339
of "search":
@@ -345,12 +352,18 @@ proc validateConfig*(path: string; entries: seq[RawEntry]): string =
345352
ent.value.strip.toLowerAscii notin ColorModes:
346353
return &"{path}:{ent.line}: unknown tone '{ent.value}' " &
347354
"(expected one of: auto, dark, light)"
355+
of "shortcuts":
356+
if ent.value.strip != "":
357+
try:
358+
discard minline.parseShortcutSpec(ent.value)
359+
except ValueError:
360+
return &"{path}:{ent.line}: invalid shortcut value '{ent.value}' for '{ent.key}'"
348361
else: discard
349362
""
350363

351-
proc parseConfigFile*(path: string): (string, seq[ProviderRec], Table[string, string], Table[string, string], string) =
364+
proc parseConfigFile*(path: string): (string, seq[ProviderRec], Table[string, string], Table[string, string], string, Table[string, string]) =
352365
## Streaming parse so that repeated [provider] sections accumulate as a list.
353-
## Returns `(current, providers, colors, searchKeys, searchEngine)`.
366+
## Returns `(current, providers, colors, searchKeys, searchEngine, shortcuts)`.
354367
## `searchKeys` maps engine name -> key for each `[search] exa-key` /
355368
## `brave-key` set (empty table when none). A legacy bare `[search] key`
356369
## is accepted and filed under the active engine for backward compat.
@@ -363,6 +376,7 @@ proc parseConfigFile*(path: string): (string, seq[ProviderRec], Table[string, st
363376
var searchEngine = ""
364377
var providers: seq[ProviderRec]
365378
var colors: Table[string, string]
379+
var shortcuts: Table[string, string]
366380
var section = ""
367381
var prov: ProviderRec
368382
var inProvider = false
@@ -480,13 +494,15 @@ proc parseConfigFile*(path: string): (string, seq[ProviderRec], Table[string, st
480494
of "reasonings": prov.reasonings = splitModels(v).mapIt(it.toLowerAscii)
481495
of "auth": prov.auth = v.strip.toLowerAscii
482496
else: discard
497+
of "shortcuts":
498+
shortcuts[e.key] = v
483499
else: discard
484500
of cfgError:
485501
die &"{path}: {e.msg}", ExitConfig
486502
p.close
487503
let verr = validateConfig(path, entries)
488504
if verr != "": die verr, ExitConfig
489-
(current, providers, colors, searchKeys, searchEngine)
505+
(current, providers, colors, searchKeys, searchEngine, shortcuts)
490506

491507
func quoteVal(s: string): string =
492508
result = "\""
@@ -532,6 +548,10 @@ proc writeConfigFile*(path: string, current: string,
532548
if colorModePref != cmAuto:
533549
let label = if colorModePref == cmDark: "dark" else: "light"
534550
buf.add "tone = " & quoteVal(label) & "\n"
551+
if activeShortcuts.len > 0:
552+
buf.add "\n[shortcuts]\n"
553+
for cmd, spec in activeShortcuts:
554+
buf.add cmd & " = " & quoteVal(spec) & "\n"
535555
for pr in providers:
536556
buf.add "\n[provider]\n"
537557
buf.add "name = " & quoteVal(pr.name) & "\n"
@@ -565,14 +585,16 @@ proc resolveSearchKey*(engine: string; keys: Table[string, string]): string =
565585

566586
proc loadStateOrEmpty*(path: string): (string, seq[ProviderRec], Table[string, string]) =
567587
## Returns `(current, providers, colors)` and updates `activeSearchKey` /
568-
## `activeSearchEngine` / `activeSearchKeys` as a side effect when the config
569-
## sets them. `colors` is the flat `[colors]` map for the caller to route
570-
## through the cascade. Missing file is benign.
588+
## `activeSearchEngine` / `activeSearchKeys` / `activeShortcuts` as a side
589+
## effect when the config sets them. `colors` is the flat `[colors]` map
590+
## for the caller to route through the cascade. Missing file is benign.
571591
if not fileExists(path): return ("", @[], initTable[string, string]())
572-
let (current, providers, colors, searchKeys, searchEngine) = parseConfigFile(path)
592+
let (current, providers, colors, searchKeys, searchEngine, shortcuts) = parseConfigFile(path)
573593
if searchEngine != "": activeSearchEngine = searchEngine
574594
activeSearchKeys = searchKeys
575595
activeSearchKey = resolveSearchKey(activeSearchEngine, activeSearchKeys)
596+
activeShortcuts = shortcuts
597+
minline.configuredShortcuts = activeShortcuts
576598
(current, providers, colors)
577599

578600
proc resolveFamily*(prov: ProviderRec, prof: Profile): string =
@@ -657,10 +679,12 @@ proc loadProfile*(wanted: string): Profile =
657679
stderr.writeLine ""
658680
stderr.writeLine ConfigExample
659681
quit ExitConfig
660-
let (current, providers, _, searchKeys, searchEngine) = parseConfigFile(path)
682+
let (current, providers, _, searchKeys, searchEngine, shortcuts) = parseConfigFile(path)
661683
if searchEngine != "": activeSearchEngine = searchEngine
662684
activeSearchKeys = searchKeys
663685
activeSearchKey = resolveSearchKey(activeSearchEngine, activeSearchKeys)
686+
activeShortcuts = shortcuts
687+
minline.configuredShortcuts = activeShortcuts
664688
if providers.len == 0:
665689
die &"no [provider] section in {path}", ExitConfig
666690
var pick = wanted

‎src/threecode/display.nim‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
## since it drops below readable contrast on light backgrounds and has no
2323
## clean mode switch.
2424

25-
import std/[critbits, exitprocs, json, os, strformat, strutils, terminal]
25+
import std/[critbits, exitprocs, json, os, strformat, strutils, tables, terminal]
2626
import types, util, config, prompts, session, actions, minline, toolstream
2727
import terminal as termui
2828

@@ -662,20 +662,45 @@ var navigatedUp*: bool = false
662662
var origDown, origUp: proc(ed: var LineEditor) {.closure.}
663663

664664
proc installEditorTweaks*() =
665+
# The default Up/Down bindings move by visual row and fall through to
666+
# history navigation at the boundary. Wrap those command callbacks so
667+
# the UI can tell whether the user has moved into history. Make sure
668+
# KEYMAP is populated (including cmd:* entries from applyShortcuts)
669+
# before we try to wrap its entries. welcome() runs before the first
670+
# readLineWith, so initKeyTables alone is not enough.
671+
initKeyTables()
672+
applyShortcuts(configuredShortcuts)
673+
# Arrow keys still go through KEYMAP["up"]/"down"] (handleEscape
674+
# fallback) and CommandProcsAll (SEQCMDS). Wrap all three so history
675+
# navigation is tracked regardless of which path fires.
665676
origUp = KEYMAP["up"]
666677
origDown = KEYMAP["down"]
667-
KEYMAP["up"] = proc(ed: var LineEditor) =
678+
let wrapUp = proc(ed: var LineEditor) =
668679
origUp(ed)
669680
navigatedUp = true
670-
KEYMAP["down"] = proc(ed: var LineEditor) =
681+
let wrapDown = proc(ed: var LineEditor) =
671682
origDown(ed)
672683
if navigatedUp:
673684
navigatedUp = false
674-
# also reset the flag when the line is cleared via ctrl+u
675-
let origClear = KEYMAP["ctrl+u"]
676-
KEYMAP["ctrl+u"] = proc(ed: var LineEditor) =
685+
KEYMAP["up"] = wrapUp
686+
KEYMAP["down"] = wrapDown
687+
KEYMAP["cmd:up"] = wrapUp
688+
KEYMAP["cmd:down"] = wrapDown
689+
# CommandProcs is the source applyShortcuts copies into each thread's
690+
# CommandProcsAll / KEYMAP["cmd:*"], so wrapping it here survives the
691+
# input thread's later applyShortcuts.
692+
CommandProcs["up"] = wrapUp
693+
CommandProcs["down"] = wrapDown
694+
CommandProcsAll["up"] = wrapUp
695+
CommandProcsAll["down"] = wrapDown
696+
# also reset the flag when the line is cleared
697+
let origClear = KEYMAP["cmd:clear"]
698+
let wrapClear = proc(ed: var LineEditor) =
677699
origClear(ed)
678700
navigatedUp = false
701+
KEYMAP["cmd:clear"] = wrapClear
702+
CommandProcs["clear"] = wrapClear
703+
CommandProcsAll["clear"] = wrapClear
679704

680705
proc welcome*(p: Profile): minline.LineEditor =
681706
termui.setSteadyCursor()

‎src/threecode/fatprompt/runtime.nim‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,11 +1447,11 @@ when defined(posix):
14471447
if n > 0:
14481448
for i in 0 ..< n.int:
14491449
let b = buf[i].uint8
1450-
if b == 0x03 or b == 0x1b:
1451-
{.cast(gcsafe).}:
1450+
{.cast(gcsafe).}:
1451+
if b in cancelWatchSnapshot:
14521452
requestTurnInterrupt()
14531453
restoreCancelTermios()
1454-
return
1454+
return
14551455

14561456
proc drainCancelInput() =
14571457
if isatty(0.cint) == 0: return
@@ -1529,7 +1529,7 @@ proc installWizardVerifyCancel*(jobDone: proc(): bool {.closure.};
15291529
if n > 0:
15301530
for i in 0 ..< n.int:
15311531
let b = buf[i].uint8
1532-
if b == 0x03 or b == 0x1b:
1532+
if b in cancelWatchSnapshot:
15331533
cancelJob()
15341534
return true
15351535
return false

0 commit comments

Comments
 (0)