lampson 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -132,6 +132,18 @@ In the REPL, `/` lists every command (`/agent`, `/ask` `/yolo` `/strict`, `/mode
132
132
  `/files`, `/procs`, `/logs <name>`, `/stop <name>`, `/kill <pid>`, `/skills`, `/sessions`,
133
133
  `/resume <id>`, `/tokens`, `/flags`). `!cmd` runs a command yourself.
134
134
 
135
+ The prompt is a real line editor (Synsema ≥ 0.6.11, falls back to plain `read_line` without a TTY):
136
+ typing `/` opens the command menu (recent ones first, filtered as you type), `Tab` completes the
137
+ command and then its arguments (files for `/image`, sessions, providers, processes, lamps, MCP/LSP
138
+ servers, flags), `↑↓` browse history or the menu, `Alt+Enter` inserts a newline, `Ctrl+O` shows the
139
+ last tool result in full, `Ctrl+U`/`Ctrl+W` clear the line/word, `Esc` closes the menu. Approvals are
140
+ an arrow-key menu (`permitir`/`denegar`, or `p`/`d`).
141
+
142
+ The terminal renders the model's markdown (headings, lists, tables, code fences) and shows every tool
143
+ result: `edit`/`write` print a line diff (`- red / + green`, line numbers, 2 lines of context); other
144
+ tools are collapsed to 15 lines. `/out [n]` prints the n-th last result of the turn in full and
145
+ `/verbose` toggles full output for every tool (saved in `.lampson/config.json`).
146
+
135
147
  The project is mounted as `lampson/workspace` (an NTFS junction on Windows, a symlink elsewhere)
136
148
  and every tool declares `file("workspace/*")` — that literal, named scope is what makes the
137
149
  confinement real. Config, sessions and process logs live in the `lampson` folder, never in your project.
package/chat.syn CHANGED
@@ -43,6 +43,9 @@ use "./lib/tools/memo.syn" as memo
43
43
  use "./lib/update.syn" as update
44
44
  use "./lib/settings.syn" as settings
45
45
  use "./lib/trace.syn" as trace
46
+ use "./lib/md.syn" as md
47
+ use "./lib/diff.syn" as diff
48
+ use "./lib/line.syn" as ed
46
49
  use "./lib/mcp.syn" as mcp
47
50
  use "./lib/lamps.syn" as lamps
48
51
  use "./lib/lsp.syn" as lsp
@@ -53,6 +56,12 @@ use "./lib/tools/todo.syn" as todo
53
56
 
54
57
  let ESC be decode(bytes("1b", "hex"))
55
58
  let COLOR be env("LAMPSON_NO_COLOR", "") == ""
59
+ -- /verbose: mostrar el output completo de cada tool (persistido en .lampson/config.json); si no, se
60
+ -- colapsa a OUT_LINES líneas y /out [n] muestra el resultado n-ésimo (desde el último) del turno completo
61
+ let saved_cfg be settings.load()
62
+ let VERBOSE be contains(saved_cfg, "verbose") and saved_cfg["verbose"] == true
63
+ let OUT_LINES be 15
64
+ let turn_outputs be []
56
65
 
57
66
  task c(code, s)
58
67
  when not COLOR
@@ -86,37 +95,83 @@ task summarize(name, out)
86
95
  let lines be split(out, "\n")
87
96
  when starts_with(out, "ERROR") or starts_with(out, "DENIED")
88
97
  give first_line(out, 140)
89
- when name == "read"
98
+ when name == "read" and not VERBOSE
90
99
  give text(length(lines)) + " líneas"
91
- when name == "ls" or name == "find" or name == "grep"
92
- give text(length(lines)) + " resultados · " + first_line(out, 90)
93
- when name == "bash"
94
- -- hasta 5 líneas del output, el resto colapsado
100
+ when (name == "ls" or name == "find") and not VERBOSE
101
+ -- las primeras entradas en una línea (hasta ~100 chars), y cuántas más hay
102
+ let shown be []
103
+ let used be 0
104
+ each l in lines
105
+ when used < 100 and trim(l) != ""
106
+ set shown to append(shown, trim(l))
107
+ set used to used + length(l) + 2
108
+ let rest be length(lines) - length(shown)
109
+ give text(length(lines)) + (when name == "ls" then " entradas: " otherwise " archivos: ") + join(shown, " ") + (when rest > 0 then dim(" +" + text(rest)) otherwise "")
110
+ when name == "grep" and not VERBOSE
111
+ let files be {}
112
+ each l in lines
113
+ let f be split(l, ":")[0]
114
+ when trim(l) != ""
115
+ set files[f] to true
116
+ give text(length(lines)) + " coincidencias en " + text(length(keys(files))) + " archivo" + (when length(keys(files)) == 1 then "" otherwise "s") + " · " + first_line(out, 90)
117
+ when name == "bash" or name == "process" or VERBOSE
118
+ -- hasta OUT_LINES líneas del output (todas con /verbose), el resto colapsado → /out
95
119
  let shown be []
96
120
  each e in enumerate(lines)
97
- when e["index"] < 5
98
- when trim(e["item"]) != ""
99
- set shown to append(shown, slice(e["item"], 0, 140))
100
- let extra be when length(lines) > 5 then dim(" (+" + text(length(lines) - 5) + " líneas)") otherwise ""
121
+ when VERBOSE or e["index"] < OUT_LINES
122
+ set shown to append(shown, when VERBOSE then e["item"] otherwise slice(e["item"], 0, 160))
123
+ let extra be when not VERBOSE and length(lines) > OUT_LINES then "\n " + dim("(+" + text(length(lines) - OUT_LINES) + " líneas · /out para ver todo)") otherwise ""
101
124
  give join(shown, "\n ") + extra
102
125
  give first_line(out, 120)
103
126
 
127
+ -- diff de un edit/write (antes/después publicado por la tool en el blackboard) — nothing si no hay
128
+ task diff_of(name, out)
129
+ when name != "edit" and name != "write"
130
+ give nothing
131
+ when starts_with(out, "ERROR") or starts_with(out, "DENIED")
132
+ give nothing
133
+ observe "lampson:ui:diff" as d
134
+ when d == nothing
135
+ give nothing
136
+ give diff.diff(d["old"], d["new"], 2, COLOR)
137
+
138
+ task print_diff(path, d)
139
+ let head be path + " · " + green("+" + text(d["added"])) + " " + red("−" + text(d["removed"]))
140
+ print(" " + green("✓") + " " + head)
141
+ each l in d["lines"]
142
+ print(" " + l)
143
+
144
+ -- /out: resultado completo de una tool del turno
145
+ task print_full(o)
146
+ when o["diff"] != nothing
147
+ print_diff(o["path"], o["diff"])
148
+ otherwise
149
+ each l in split(o["output"], "\n")
150
+ print(" " + l)
151
+
104
152
  -- tag = "" para el agente principal; los subagentes en background NO pasan por acá (escriben su log)
105
153
  task on_event(kind, data, tag)
106
154
  trace.event(sid, kind, data, tag)
107
155
  when kind == "assistant"
108
156
  print("")
109
- print(data)
157
+ print(md.render(data, COLOR))
110
158
  print("")
111
159
  otherwise when kind == "inbox"
112
160
  print(" " + cyan("✉ " + first_line(data, 140)))
113
161
  otherwise when kind == "tool_call"
114
- print(" " + yellow(" " + permission.describe_call(data["name"], data["args"])))
162
+ print(" " + yellow(" " + permission.describe_call(data["name"], data["args"])))
115
163
  otherwise when kind == "tool_result"
116
164
  let out be data["output"]
165
+ let name be data["call"]["name"]
117
166
  let bad be starts_with(out, "ERROR") or starts_with(out, "DENIED")
118
- let mark be when bad then red("✗") otherwise green("✓")
119
- print(" " + mark + " " + (when bad then red(summarize(data["call"]["name"], out)) otherwise dim(summarize(data["call"]["name"], out))))
167
+ let d be diff_of(name, out)
168
+ let path be when d != nothing then text(data["call"]["args"]["path"]) otherwise ""
169
+ set turn_outputs to append(turn_outputs, {"name": name, "args": data["call"]["args"], "output": out, "diff": d, "path": path})
170
+ when d != nothing
171
+ print_diff(path, d)
172
+ otherwise
173
+ let mark be when bad then red("✗") otherwise green("✓")
174
+ print(" " + mark + " " + (when bad then red(summarize(name, out)) otherwise dim(summarize(name, out))))
120
175
  otherwise when kind == "tool_denied"
121
176
  set data to data
122
177
  otherwise when kind == "error"
@@ -134,8 +189,12 @@ task ask_user(name, args, why)
134
189
  print(" " + yellow("⚠ requiere tu aprobación · " + why))
135
190
  print(" " + permission.describe_call(name, args))
136
191
  flush()
137
- let ok be approve " ¿permitir?" within 10m
138
- give ok
192
+ -- menú ↑↓/Enter (o p/d, 1/2, Esc = no) cuando hay TTY; sin TTY, el approve nativo (fail-closed)
193
+ let pick be ed.choose(" ¿permitir?", ["permitir", "denegar"], COLOR)
194
+ when pick == -1
195
+ let ok be approve " ¿permitir?" within 10m
196
+ give ok
197
+ give pick == 0
139
198
 
140
199
  let LINE be " ────────────────────────────────────────────────────────────────────────"
141
200
 
@@ -164,6 +223,8 @@ let COMMANDS be [
164
223
  ["/mcp", "[add <nombre> <comando…> [--project] | remove <nombre>]", "servers MCP: listar, conectar o quitar (global: lampson/.lampson/mcp.json · proyecto: .lampson/mcp.json)"],
165
224
  ["/lamps", "[on <nombre> | off <nombre> | run <lámpara> <tool> [json] | remove <nombre>]", "lámparas (plugins de tools): listar, encender o apagar (global: lampson/lamps/ · proyecto: .lampson/lamps/)"],
166
225
  ["/lsp", "[add <typescript|python|rust|go|css|html> [--project] | add <nombre> <comando…> --ext .x=lang | remove <nombre>]", "language servers (navegación semántica: symbols/definition/references/hover); arrancan en la primera consulta"],
226
+ ["/out", "[n]", "resultado completo de la última tool del turno (n = contar hacia atrás: /out 2 es la anteúltima)"],
227
+ ["/verbose", "", "alternar: mostrar SIEMPRE el output completo de cada tool (queda guardado en config.json)"],
167
228
  ["/trace", "[n]", "traza legible de esta sesión (pasos, tools, tiempos, tokens, errores): .lampson/trace/<sesión>.log"],
168
229
  ["/tokens", "", "tamaño del contexto y tokens gastados en esta sesión"],
169
230
  ["/new", "", "empezar una sesión nueva (historial vacío)"],
@@ -182,10 +243,138 @@ task pad(s, n)
182
243
  set out to out + " "
183
244
  give out
184
245
 
246
+ -- entrada multilínea sin modo raw (read_key llegará a Synsema): una línea que termina en "\" continúa
247
+ -- en la siguiente; una línea que EMPIEZA con """ abre un bloque que cierra con una línea """ sola
248
+ -- (ideal para pegar código o logs). EOF corta el bloque con lo acumulado.
249
+ -- candidatos para completar el argumento de un comando (Tab / menú del editor de línea).
250
+ -- head = argumentos ya escritos antes del último token; last = lo que se está escribiendo
251
+ task complete_args(cmd, head, last)
252
+ let first be trim(head) == ""
253
+ when cmd == "/image"
254
+ give workspace_paths(last)
255
+ when cmd == "/resume" or cmd == "/delete"
256
+ give apply((s) => s["id"], session.list())
257
+ when cmd == "/provider"
258
+ when first
259
+ give apply((p) => p["name"], provider.providers())
260
+ give []
261
+ when cmd == "/logs" or cmd == "/stop"
262
+ when first
263
+ give apply((p) => p["name"], proc.list())
264
+ give []
265
+ when cmd == "/memory"
266
+ give apply((m) => m["name"], memo.list())
267
+ when cmd == "/lamps"
268
+ when first
269
+ give ["on", "off", "run", "remove"]
270
+ give apply((l) => l["name"], lamps.summary())
271
+ when cmd == "/mcp"
272
+ when first
273
+ give ["add", "remove"]
274
+ when starts_with(trim(head), "remove")
275
+ give apply((m) => m["name"], mcp.summary())
276
+ give ["--project"]
277
+ when cmd == "/lsp"
278
+ when first
279
+ give ["add", "remove"]
280
+ when starts_with(trim(head), "remove")
281
+ give apply((s) => s["name"], lsp.summary())
282
+ when trim(head) == "add"
283
+ give sort_by(keys(lsp.PRESETS), (x) => x)
284
+ give ["--project", "--ext"]
285
+ when cmd == "/agent"
286
+ give ["build", "plan", "review", "explore", "worker"]
287
+ when cmd == "/model"
288
+ give []
289
+ give []
290
+
291
+ -- rutas del workspace que empiezan con el prefijo escrito (directorios con "/" final para seguir completando)
292
+ task workspace_paths(prefix)
293
+ let dir be ""
294
+ let base be prefix
295
+ let cut be -1
296
+ let i be length(prefix) - 1
297
+ while i >= 0 and cut == -1
298
+ when slice(prefix, i, i + 1) == "/" or slice(prefix, i, i + 1) == "\\"
299
+ set cut to i
300
+ set i to i - 1
301
+ when cut >= 0
302
+ set dir to slice(prefix, 0, cut + 1)
303
+ set base to slice(prefix, cut + 1, length(prefix))
304
+ let out be []
305
+ try
306
+ each e in list_dir(when dir == "" then "workspace" otherwise "workspace/" + dir)
307
+ when starts_with(e["name"], base) and e["name"] != ".git"
308
+ set out to append(out, dir + e["name"] + (when e["is_dir"] then "/" otherwise ""))
309
+ recover err
310
+ set out to []
311
+ give sort_by(out, (x) => x)
312
+
313
+ -- mientras el humano no escribe: ¿hay avisos de subagentes en background? → cortar la lectura y
314
+ -- dejar que el bucle principal los inyecte (turno automático)
315
+ task idle()
316
+ let pending be false
317
+ try
318
+ each c in agents.list_children()
319
+ when c["status"] != "running" and not c["delivered"]
320
+ set pending to true
321
+ recover err
322
+ set pending to false
323
+ give pending
324
+
325
+ let input_history be []
326
+
327
+ task read_input(prompt)
328
+ let first be read_line(prompt)
329
+ when first == nothing
330
+ give nothing
331
+ let more be []
332
+ when trim(first) == "\"\"\"" or starts_with(first, "\"\"\"")
333
+ let body be slice(first, 3, length(first))
334
+ when trim(body) != ""
335
+ set more to append(more, body)
336
+ let open be true
337
+ while open
338
+ let l be read_line(dim(" … "))
339
+ when l == nothing or trim(l) == "\"\"\""
340
+ set open to false
341
+ otherwise
342
+ set more to append(more, l)
343
+ give join(more, "\n")
344
+ let cur be first
345
+ while ends_with(cur, "\\")
346
+ set more to append(more, slice(cur, 0, length(cur) - 1))
347
+ let l be read_line(dim(" … "))
348
+ set cur to when l == nothing then "" otherwise l
349
+ set more to append(more, cur)
350
+ give join(more, "\n")
351
+
352
+ -- últimos comandos usados (config.json "recent"): "/" solo los muestra primero
353
+ task remember_command(input)
354
+ let name be split(trim(input), " ")[0]
355
+ when name == "/" or name == "/help"
356
+ give nothing
357
+ let doc be settings.load()
358
+ let old be when contains(doc, "recent") then doc["recent"] otherwise []
359
+ let recent be [name]
360
+ each r in old
361
+ when r != name and length(recent) < 3
362
+ set recent to append(recent, r)
363
+ set doc["recent"] to recent
364
+ settings.save(doc)
365
+ give nothing
366
+
185
367
  task help()
368
+ let doc be settings.load()
369
+ let recent be when contains(doc, "recent") then doc["recent"] otherwise []
186
370
  print("")
371
+ when length(recent) > 0
372
+ print(" recientes " + join(apply((r) => cyan(r), recent), " "))
373
+ print("")
187
374
  print(" Escribí lo que querés hacer en el proyecto. El agente lee, busca, edita y corre comandos con tools")
188
375
  print(" acotadas al workspace; cada paso queda a la vista. Los comandos empiezan con /:")
376
+ print(" Teclas: Tab completa · ↑↓ historial/menú · Alt+Enter salto de línea · Ctrl+O último resultado completo · Esc cierra el menú")
377
+ print(" Sin TTY (pipe): terminá la línea con \\ para continuar, o un bloque entre líneas \"\"\".")
189
378
  print("")
190
379
  each c in COMMANDS
191
380
  print(" " + pad(c[0] + " " + c[1], 38) + c[2])
@@ -238,6 +427,21 @@ task banner(ws, cfg, profile, mode, sid)
238
427
  print(" workspace " + ws)
239
428
  print(" agente " + profile + " permisos " + mode + " modelo " + cfg["model"])
240
429
  print(" sesión " + sid + " " + git.summary())
430
+ -- extensiones en una línea (detalle con /lamps, /mcp, /lsp): encendidas/total
431
+ let ls be lamps.summary()
432
+ let lamps_on be length(where(ls, (l) => l["enabled"]))
433
+ let ms be mcp.summary()
434
+ let mcp_on be length(where(ms, (m) => m["status"] == "ready"))
435
+ let ss be lsp.summary()
436
+ let ext be []
437
+ when length(ls) > 0
438
+ set ext to append(ext, (when lamps_on > 0 then green("lamps " + text(lamps_on) + "/" + text(length(ls))) otherwise dim("lamps 0/" + text(length(ls)))))
439
+ when length(ms) > 0
440
+ set ext to append(ext, (when mcp_on == length(ms) then green("mcp " + text(mcp_on) + "/" + text(length(ms))) otherwise red("mcp " + text(mcp_on) + "/" + text(length(ms)))))
441
+ when length(ss) > 0
442
+ set ext to append(ext, dim("lsp " + text(length(ss))))
443
+ when length(ext) > 0
444
+ print(" extras " + join(ext, dim(" · ")) + dim(" (/lamps /mcp /lsp)"))
241
445
  let upd be update.line()
242
446
  when upd != ""
243
447
  print(" " + yellow("⬆ " + upd) + dim(" (o /update acá)"))
@@ -402,14 +606,13 @@ let system_msg be system_for(profile)
402
606
  -- servers MCP (globales en .lampson/mcp.json, del proyecto en workspace/.lampson/mcp.json): arrancan antes
403
607
  -- de armar el catálogo de tools; hasta 8 s de espera a que estén listos
404
608
  let mcp_servers be mcp.start_all(8)
609
+ -- mcp / lsp / lámparas van resumidos en el banner ("extras"); acá solo se avisa lo que está ROTO
405
610
  each ms in mcp.summary()
406
- print(" " + (when ms["status"] == "ready" then green("● mcp " + ms["name"]) otherwise red("○ mcp " + ms["name"] + " " + ms["status"])) + dim(" (" + ms["scope"] + ") " + text(length(ms["tools"])) + " tools" + (when ms["error"] != nothing then " · " + text(ms["error"]) otherwise "")))
407
- -- language servers (lib/lsp.syn): solo se anuncian; arrancan lazy en la primera consulta
408
- each s in lsp.summary()
409
- print(" " + dim("○ lsp " + s["name"] + " (" + s["scope"] + ") " + join(s["extensions"], " ") + " · arranca en la primera consulta"))
410
- -- lámparas (plugins de tools, lib/lamps.syn): solo se anuncian; apagadas no entran al catálogo
611
+ when ms["status"] != "ready"
612
+ print(" " + red("○ mcp " + ms["name"] + " " + ms["status"]) + dim(when ms["error"] != nothing then " · " + text(ms["error"]) otherwise ""))
411
613
  each l in lamps.summary()
412
- print(" " + (when l["enabled"] then green("● lamp " + l["name"]) otherwise dim("○ lamp " + l["name"] + " off")) + dim(" (" + l["scope"] + ", " + l["kind"] + ") " + text(length(l["tools"])) + " tools" + (when l["error"] != nothing then " · " + text(l["error"]) otherwise "")))
614
+ when l["error"] != nothing
615
+ print(" " + red("○ lamp " + l["name"] + " rota") + dim(" · " + text(l["error"])))
413
616
  let opts be opts_for(profile, lower(env("LAMPSON_PERMISSION", "ask")))
414
617
 
415
618
  -- marca de corrida (blackboard): session.save la estampa en meta.run; reanudar una sesión guardada por
@@ -465,14 +668,24 @@ while running
465
668
  session.save(sid, messages, {"title": session.title_of(messages)})
466
669
  flush()
467
670
  otherwise
468
- set line to read_line("\n" + (when length(pending_images) > 0 then dim("📎" + text(length(pending_images)) + " ") otherwise "") + cyan("❯ "))
671
+ let saved be settings.load()
672
+ let ctx be {"commands": COMMANDS, "recent": (when contains(saved, "recent") then saved["recent"] otherwise []), "history": input_history, "complete": complete_args, "idle": idle, "fallback": read_input}
673
+ set line to ed.read((when length(pending_images) > 0 then dim("📎" + text(length(pending_images)) + " ") otherwise "") + cyan("❯ "), COLOR, ctx)
674
+ when line != nothing and line != ed.INTERRUPT and trim(line) != ""
675
+ set input_history to append(input_history, line)
469
676
  when inbox != nothing
470
677
  set running to running
678
+ otherwise when line == ed.INTERRUPT
679
+ set running to running
471
680
  otherwise when line == nothing
472
681
  set running to false
473
682
  otherwise
474
683
  set followups to 0
684
+ when not starts_with(trim(line), "/")
685
+ set turn_outputs to []
475
686
  let input be trim(line)
687
+ when starts_with(input, "/") and length(input) > 1
688
+ remember_command(input)
476
689
  when input == ""
477
690
  set running to running
478
691
  otherwise when input == "/exit" or input == "/quit"
@@ -491,6 +704,23 @@ while running
491
704
  print(" " + update.apply())
492
705
  otherwise when input == "/config"
493
706
  show_config(cfg, env_info["cwd"], profile, mode, opts)
707
+ otherwise when input == "/verbose"
708
+ set VERBOSE to not VERBOSE
709
+ let doc be settings.load()
710
+ set doc["verbose"] to VERBOSE
711
+ settings.save(doc)
712
+ print(" " + (when VERBOSE then green("verbose ON") + dim(" · cada tool muestra su output completo") otherwise dim("verbose OFF · outputs colapsados a " + text(OUT_LINES) + " líneas; /out para ver uno completo")))
713
+ otherwise when starts_with(input, "/out")
714
+ let n be trim(slice(input, 4, length(input)))
715
+ let back be when n == "" then 1 otherwise floor(number(n))
716
+ when length(turn_outputs) == 0
717
+ print(dim(" ninguna tool corrió en este turno"))
718
+ otherwise when back < 1 or back > length(turn_outputs)
719
+ print(dim(" hay " + text(length(turn_outputs)) + " resultados en este turno: /out 1 … /out " + text(length(turn_outputs))))
720
+ otherwise
721
+ let o be turn_outputs[length(turn_outputs) - back]
722
+ print(" " + yellow("▸ " + permission.describe_call(o["name"], o["args"])))
723
+ print_full(o)
494
724
  otherwise when starts_with(input, "/trace")
495
725
  let n be trim(slice(input, 6, length(input)))
496
726
  let lines be when n == "" then 40 otherwise floor(number(n))
package/lib/diff.syn ADDED
@@ -0,0 +1,142 @@
1
+ -- lib/diff.syn — diff de líneas para mostrar en la terminal qué cambió edit/write
2
+ -- diff(old, new, ctx, color) → {"added": n, "removed": m, "lines": [texto ya formateado…]}
3
+ -- Algoritmo: se recorta el prefijo y sufijo comunes (una edición es local) y se hace LCS solo sobre el
4
+ -- medio. Si el medio es enorme (> LIMIT² celdas) se muestra como bloque quitado + bloque agregado.
5
+
6
+ let ESC be decode(bytes("1b", "hex"))
7
+ let LIMIT be 600
8
+
9
+ task sgr(color, code, s)
10
+ when not color
11
+ give s
12
+ give ESC + "[" + code + "m" + s + ESC + "[0m"
13
+
14
+ task lpad(s, n)
15
+ let out be s
16
+ while length(out) < n
17
+ set out to " " + out
18
+ give out
19
+
20
+ -- LCS clásico: devuelve la lista de ops {"op": " "|"-"|"+", "a": idx_old, "b": idx_new}
21
+ task lcs_ops(a, b)
22
+ let n be length(a)
23
+ let m be length(b)
24
+ -- tabla (n+1)×(m+1) aplanada, fila por fila
25
+ let table be []
26
+ let size be (n + 1) * (m + 1)
27
+ while length(table) < size
28
+ set table to append(table, 0)
29
+ let i be n - 1
30
+ while i >= 0
31
+ let j be m - 1
32
+ while j >= 0
33
+ when a[i] == b[j]
34
+ set table[i * (m + 1) + j] to table[(i + 1) * (m + 1) + j + 1] + 1
35
+ otherwise
36
+ let down be table[(i + 1) * (m + 1) + j]
37
+ let right be table[i * (m + 1) + j + 1]
38
+ set table[i * (m + 1) + j] to (when down >= right then down otherwise right)
39
+ set j to j - 1
40
+ set i to i - 1
41
+ let ops be []
42
+ set i to 0
43
+ let j be 0
44
+ while i < n and j < m
45
+ when a[i] == b[j]
46
+ set ops to append(ops, {"op": " ", "a": i, "b": j})
47
+ set i to i + 1
48
+ set j to j + 1
49
+ otherwise when table[(i + 1) * (m + 1) + j] >= table[i * (m + 1) + j + 1]
50
+ set ops to append(ops, {"op": "-", "a": i, "b": j})
51
+ set i to i + 1
52
+ otherwise
53
+ set ops to append(ops, {"op": "+", "a": i, "b": j})
54
+ set j to j + 1
55
+ while i < n
56
+ set ops to append(ops, {"op": "-", "a": i, "b": j})
57
+ set i to i + 1
58
+ while j < m
59
+ set ops to append(ops, {"op": "+", "a": i, "b": j})
60
+ set j to j + 1
61
+ give ops
62
+
63
+ export task diff(old, new, ctx, color)
64
+ let a be when old == "" then [] otherwise split(old, "\n")
65
+ let b be when new == "" then [] otherwise split(new, "\n")
66
+ -- prefijo común
67
+ let pre be 0
68
+ while pre < length(a) and pre < length(b) and a[pre] == b[pre]
69
+ set pre to pre + 1
70
+ -- sufijo común (sin pisar el prefijo)
71
+ let suf be 0
72
+ while suf < length(a) - pre and suf < length(b) - pre and a[length(a) - 1 - suf] == b[length(b) - 1 - suf]
73
+ set suf to suf + 1
74
+ let mid_a be slice(a, pre, length(a) - suf)
75
+ let mid_b be slice(b, pre, length(b) - suf)
76
+ let ops be []
77
+ when length(mid_a) * length(mid_b) > LIMIT * LIMIT
78
+ each e in enumerate(mid_a)
79
+ set ops to append(ops, {"op": "-", "a": e["index"], "b": 0})
80
+ each e in enumerate(mid_b)
81
+ set ops to append(ops, {"op": "+", "a": length(mid_a), "b": e["index"]})
82
+ otherwise
83
+ set ops to lcs_ops(mid_a, mid_b)
84
+ let added be 0
85
+ let removed be 0
86
+ each o in ops
87
+ when o["op"] == "+"
88
+ set added to added + 1
89
+ otherwise when o["op"] == "-"
90
+ set removed to removed + 1
91
+ -- render: ctx líneas de contexto antes y después del bloque cambiado (números de línea del archivo NUEVO
92
+ -- para "+"/" ", del viejo para "-"), con "⋯" entre hunks
93
+ let width be length(text(length(b)))
94
+ let lines be []
95
+ -- contexto previo (del prefijo común)
96
+ let start be when pre - ctx > 0 then pre - ctx otherwise 0
97
+ when start > 0
98
+ set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
99
+ let k be start
100
+ while k < pre
101
+ set lines to append(lines, sgr(color, "2", lpad(text(k + 1), width) + " │ " + a[k]))
102
+ set k to k + 1
103
+ -- medio: contexto interno acotado (ctx a cada lado de un cambio)
104
+ let last_change be -1
105
+ let idx be 0
106
+ each o in ops
107
+ when o["op"] != " "
108
+ set last_change to idx
109
+ set idx to idx + 1
110
+ set idx to 0
111
+ let skipping be false
112
+ each o in ops
113
+ let near be false
114
+ let w be idx - ctx
115
+ while w <= idx + ctx and not near
116
+ when w >= 0 and w < length(ops) and ops[w]["op"] != " "
117
+ set near to true
118
+ set w to w + 1
119
+ when o["op"] == " "
120
+ when near
121
+ set lines to append(lines, sgr(color, "2", lpad(text(pre + o["b"] + 1), width) + " │ " + mid_b[o["b"]]))
122
+ set skipping to false
123
+ otherwise when not skipping
124
+ set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
125
+ set skipping to true
126
+ otherwise when o["op"] == "-"
127
+ set lines to append(lines, sgr(color, "2", lpad(text(pre + o["a"] + 1), width) + " │ ") + sgr(color, "31", "- " + mid_a[o["a"]]))
128
+ set skipping to false
129
+ otherwise
130
+ set lines to append(lines, sgr(color, "2", lpad(text(pre + o["b"] + 1), width) + " │ ") + sgr(color, "32", "+ " + mid_b[o["b"]]))
131
+ set skipping to false
132
+ set idx to idx + 1
133
+ -- contexto posterior (del sufijo común)
134
+ let after_start be length(b) - suf
135
+ let upto be when after_start + ctx < length(b) then after_start + ctx otherwise length(b)
136
+ set k to after_start
137
+ while k < upto
138
+ set lines to append(lines, sgr(color, "2", lpad(text(k + 1), width) + " │ " + b[k]))
139
+ set k to k + 1
140
+ when upto < length(b)
141
+ set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
142
+ give {"added": added, "removed": removed, "lines": lines}
package/lib/line.syn ADDED
@@ -0,0 +1,365 @@
1
+ -- lib/line.syn — editor de línea para la terminal (Synsema v0.6.11+: term_open/term_recv)
2
+ -- read(prompt, color, ctx) → texto (puede tener "\n"), INTERRUPT si ctx["idle"]() pidió cortar,
3
+ -- nothing en EOF. Sin TTY (pipe/CI) delega en ctx["fallback"](prompt).
4
+ -- choose(question, options, color) → índice elegido, -1 sin TTY (usar approve), nothing en EOF/Esc.
5
+ -- Teclas: ←→ Home End · ↑↓ historial o menú · Tab completa · Alt+Enter salto de línea · Enter envía
6
+ -- Ctrl+A/E inicio/fin · Ctrl+U borra la línea · Ctrl+W borra palabra · Ctrl+O = /out · Esc cierra el menú
7
+ -- Menú: al escribir "/" aparecen los comandos (primero los recientes); con "/cmd " los argumentos que
8
+ -- devuelva ctx["complete"](cmd, prefijo) (archivos, servers, flags…). Máx MENU_ROWS filas.
9
+ -- MIGA: dibujar SIEMPRE con term_write (print queda buffereado). El runtime restaura la terminal al cerrar.
10
+
11
+ let ESC be decode(bytes("1b", "hex"))
12
+ let MENU_ROWS be 8
13
+ export let INTERRUPT be ESC + "interrupt"
14
+
15
+ task sgr(color, code, s)
16
+ when not color or s == ""
17
+ give s
18
+ give ESC + "[" + code + "m" + s + ESC + "[0m"
19
+
20
+ task vis(s)
21
+ give length(strip_ansi(s))
22
+
23
+ task rep(ch, n)
24
+ let out be ""
25
+ while length(out) < n
26
+ set out to out + ch
27
+ give out
28
+
29
+ task starts(s, p)
30
+ give starts_with(s, p)
31
+
32
+ -- ---------- candidatos ----------
33
+
34
+ -- buf = texto del editor. give {"kind": "cmd"|"arg"|"", "items": [{"label","fill","desc"}], "token": prefijo}
35
+ task candidates(buf, ctx)
36
+ when not starts(buf, "/") or contains(buf, "\n")
37
+ give {"kind": "", "items": [], "token": ""}
38
+ let sp be capture(buf, "^(/\S*)\s+(.*)$")
39
+ when sp == nothing
40
+ -- comando a medias: recientes primero cuando es "/" pelado
41
+ let items be []
42
+ let seen be {}
43
+ when buf == "/"
44
+ each r in ctx["recent"]
45
+ each c in ctx["commands"]
46
+ when c[0] == r and not contains(seen, r)
47
+ set seen[r] to true
48
+ set items to append(items, {"label": c[0], "fill": c[0] + (when c[1] != "" then " " otherwise ""), "args": c[1], "desc": c[2], "recent": true})
49
+ each c in ctx["commands"]
50
+ when starts(c[0], buf) and not contains(seen, c[0])
51
+ set seen[c[0]] to true
52
+ set items to append(items, {"label": c[0], "fill": c[0] + (when c[1] != "" then " " otherwise ""), "args": c[1], "desc": c[2], "recent": false})
53
+ give {"kind": "cmd", "items": items, "token": buf}
54
+ let cmd be sp[0]
55
+ let rest be sp[1]
56
+ -- último token (lo que se completa); lo anterior queda fijo
57
+ let toks be split(rest, " ")
58
+ let last be toks[length(toks) - 1]
59
+ let head be slice(rest, 0, length(rest) - length(last))
60
+ let items be []
61
+ let cands be ctx["complete"](cmd, head, last)
62
+ each c in cands
63
+ when starts(c, last)
64
+ set items to append(items, {"label": c, "fill": cmd + " " + head + c + (when ends_with(c, "/") then "" otherwise " "), "args": "", "desc": "", "recent": false})
65
+ give {"kind": "arg", "items": items, "token": last}
66
+
67
+ -- ---------- dibujo ----------
68
+
69
+ -- filas físicas que ocupa un texto de ancho w en una terminal de cols columnas
70
+ task rows_of(w, cols)
71
+ when cols <= 0
72
+ give 1
73
+ give floor(w / cols) + 1
74
+
75
+ -- dibuja prompt + buffer (multilínea) + menú; deja el cursor en su lugar. give filas totales dibujadas
76
+ -- (para poder volver al inicio en el próximo redraw)
77
+ task draw(h, st, color, ctx)
78
+ let size be term_size(h)
79
+ let cols be size["cols"]
80
+ let lines be split(st["buf"], "\n")
81
+ -- posición del cursor: línea y columna dentro del buffer
82
+ let cl be 0
83
+ let cc be st["cur"]
84
+ let i be 0
85
+ let found be false
86
+ while i < length(lines) and not found
87
+ when cc <= length(lines[i])
88
+ set cl to i
89
+ set found to true
90
+ otherwise
91
+ set cc to cc - length(lines[i]) - 1
92
+ set i to i + 1
93
+ let out be ""
94
+ -- volver al inicio de lo dibujado la vez anterior y limpiar hacia abajo
95
+ when st["cursor_row"] > 0
96
+ set out to out + ESC + "[" + text(st["cursor_row"]) + "A"
97
+ set out to out + "\r" + ESC + "[J"
98
+ let cont be sgr(color, "2", " … ")
99
+ let rows_before_cursor be 0
100
+ let total be 0
101
+ let cursor_col be 0
102
+ set i to 0
103
+ each l in lines
104
+ let pre be when i == 0 then st["prompt"] otherwise cont
105
+ set out to out + (when i > 0 then "\r\n" otherwise "") + pre + l
106
+ let r be rows_of(vis(pre) + length(l), cols)
107
+ when i < cl
108
+ set rows_before_cursor to rows_before_cursor + r
109
+ otherwise when i == cl
110
+ set rows_before_cursor to rows_before_cursor + floor((vis(pre) + cc) / cols)
111
+ set cursor_col to (vis(pre) + cc) % cols
112
+ set total to total + r
113
+ set i to i + 1
114
+ -- menú
115
+ let menu_rows be 0
116
+ when st["menu"] and length(st["items"]) > 0
117
+ let items be st["items"]
118
+ let from be 0
119
+ when st["sel"] >= MENU_ROWS
120
+ set from to st["sel"] - MENU_ROWS + 1
121
+ let k be from
122
+ while k < length(items) and k < from + MENU_ROWS
123
+ let it be items[k]
124
+ let row be ""
125
+ when st["kind"] == "cmd"
126
+ let full be it["label"] + (when it["args"] != "" then " " + it["args"] otherwise "")
127
+ let name be when length(full) > 34 then slice(full, 0, 33) + "…" otherwise full
128
+ let padn be name + rep(" ", 34 - length(name))
129
+ let desc be when length(it["desc"]) > cols - 40 then slice(it["desc"], 0, cols - 43) + "…" otherwise it["desc"]
130
+ set row to (when k == st["sel"] then sgr(color, "7", " " + padn + " ") otherwise " " + sgr(color, "1", it["label"]) + sgr(color, "2", slice(padn, length(it["label"]), length(padn)) + " ")) + (when it["recent"] then sgr(color, "36", "↺ ") otherwise " ") + sgr(color, "2", desc)
131
+ otherwise
132
+ set row to when k == st["sel"] then sgr(color, "7", " " + it["label"] + " ") otherwise " " + it["label"]
133
+ set out to out + "\r\n" + " " + row
134
+ set menu_rows to menu_rows + 1
135
+ set k to k + 1
136
+ when length(items) > MENU_ROWS
137
+ set out to out + "\r\n" + sgr(color, "2", " (" + text(length(items)) + " opciones · ↑↓ elegir · Tab completa · Esc cierra)")
138
+ set menu_rows to menu_rows + 1
139
+ -- recolocar el cursor: subir (filas debajo de la línea del cursor + menú), ir a la columna
140
+ let below be total - rows_before_cursor - 1 + menu_rows
141
+ when below > 0
142
+ set out to out + ESC + "[" + text(below) + "A"
143
+ set out to out + "\r"
144
+ when cursor_col > 0
145
+ set out to out + ESC + "[" + text(cursor_col) + "C"
146
+ term_write(h, out)
147
+ set st["cursor_row"] to rows_before_cursor
148
+ give st
149
+
150
+ -- ---------- edición ----------
151
+
152
+ task insert(st, s)
153
+ set st["buf"] to slice(st["buf"], 0, st["cur"]) + s + slice(st["buf"], st["cur"], length(st["buf"]))
154
+ set st["cur"] to st["cur"] + length(s)
155
+ give st
156
+
157
+ task refresh_menu(st, ctx)
158
+ let c be candidates(st["buf"], ctx)
159
+ set st["kind"] to c["kind"]
160
+ set st["items"] to c["items"]
161
+ set st["sel"] to 0
162
+ set st["nav"] to false
163
+ set st["menu"] to c["kind"] != "" and length(c["items"]) > 0 and not st["menu_off"]
164
+ give st
165
+
166
+ task word_start(buf, cur)
167
+ let i be cur
168
+ while i > 0 and slice(buf, i - 1, i) == " "
169
+ set i to i - 1
170
+ while i > 0 and slice(buf, i - 1, i) != " " and slice(buf, i - 1, i) != "\n"
171
+ set i to i - 1
172
+ give i
173
+
174
+ task accept(st)
175
+ let it be st["items"][st["sel"]]
176
+ set st["buf"] to it["fill"]
177
+ set st["cur"] to length(it["fill"])
178
+ set st["menu"] to false
179
+ give st
180
+
181
+ export task read(prompt, color, ctx)
182
+ let h be term_open({"ctrl_c": "exit"})
183
+ when h == nothing
184
+ give ctx["fallback"](prompt)
185
+ let st be {"buf": "", "cur": 0, "prompt": prompt, "cursor_row": 0, "menu": false, "menu_off": false, "kind": "", "items": [], "sel": 0, "nav": false}
186
+ let hist be ctx["history"]
187
+ let hi be length(hist)
188
+ let draft be ""
189
+ let result be nothing
190
+ let done be false
191
+ term_write(h, "\r\n")
192
+ set st to draw(h, st, color, ctx)
193
+ while not done
194
+ let ev be term_recv(h, 1)
195
+ let dirty be true
196
+ when ev == nothing
197
+ set dirty to false
198
+ when ctx["idle"]()
199
+ set result to INTERRUPT
200
+ set done to true
201
+ otherwise when ev["type"] == "focus"
202
+ set dirty to false
203
+ otherwise when ev["type"] == "eof"
204
+ set result to nothing
205
+ set done to true
206
+ otherwise when ev["type"] == "paste"
207
+ set st to insert(st, replace_text(ev["text"], "\r", ""))
208
+ set st to refresh_menu(st, ctx)
209
+ otherwise when ev["type"] == "resize"
210
+ set st to st
211
+ otherwise when ev["type"] == "key"
212
+ let k be ev["key"]
213
+ let changed be true
214
+ when k == "enter" and ev["alt"]
215
+ set st to insert(st, "\n")
216
+ otherwise when k == "enter"
217
+ when st["menu"] and st["kind"] == "cmd" and st["buf"] != st["items"][st["sel"]]["label"] and st["buf"] != st["items"][st["sel"]]["fill"]
218
+ set st to accept(st)
219
+ when st["items"][st["sel"]]["args"] == ""
220
+ set result to trim(st["buf"])
221
+ set done to true
222
+ otherwise when st["menu"] and st["kind"] == "arg" and st["nav"]
223
+ -- Enter solo completa el argumento si el humano navegó el menú; si no, envía tal cual
224
+ set st to accept(st)
225
+ otherwise
226
+ set result to st["buf"]
227
+ set done to true
228
+ otherwise when k == "tab" and st["menu"]
229
+ set st to accept(st)
230
+ otherwise when k == "escape"
231
+ when st["menu"]
232
+ set st["menu"] to false
233
+ set st["menu_off"] to true
234
+ set changed to false
235
+ otherwise when k == "up" and st["menu"]
236
+ set st["sel"] to (when st["sel"] > 0 then st["sel"] - 1 otherwise length(st["items"]) - 1)
237
+ set st["nav"] to true
238
+ set changed to false
239
+ otherwise when k == "down" and st["menu"]
240
+ set st["sel"] to (when st["sel"] < length(st["items"]) - 1 then st["sel"] + 1 otherwise 0)
241
+ set st["nav"] to true
242
+ set changed to false
243
+ otherwise when k == "up"
244
+ when hi > 0
245
+ when hi == length(hist)
246
+ set draft to st["buf"]
247
+ set hi to hi - 1
248
+ set st["buf"] to hist[hi]
249
+ set st["cur"] to length(st["buf"])
250
+ set changed to false
251
+ otherwise when k == "down"
252
+ when hi < length(hist)
253
+ set hi to hi + 1
254
+ set st["buf"] to (when hi == length(hist) then draft otherwise hist[hi])
255
+ set st["cur"] to length(st["buf"])
256
+ set changed to false
257
+ otherwise when k == "left"
258
+ when st["cur"] > 0
259
+ set st["cur"] to st["cur"] - 1
260
+ set changed to false
261
+ otherwise when k == "right"
262
+ when st["cur"] < length(st["buf"])
263
+ set st["cur"] to st["cur"] + 1
264
+ set changed to false
265
+ otherwise when k == "home" or (k == "char" and ev["ctrl"] and ev["text"] == "a")
266
+ set st["cur"] to 0
267
+ set changed to false
268
+ otherwise when k == "end" or (k == "char" and ev["ctrl"] and ev["text"] == "e")
269
+ set st["cur"] to length(st["buf"])
270
+ set changed to false
271
+ otherwise when k == "backspace"
272
+ when st["cur"] > 0
273
+ set st["buf"] to slice(st["buf"], 0, st["cur"] - 1) + slice(st["buf"], st["cur"], length(st["buf"]))
274
+ set st["cur"] to st["cur"] - 1
275
+ otherwise when k == "delete"
276
+ when st["cur"] < length(st["buf"])
277
+ set st["buf"] to slice(st["buf"], 0, st["cur"]) + slice(st["buf"], st["cur"] + 1, length(st["buf"]))
278
+ otherwise when k == "char" and ev["ctrl"] and ev["text"] == "u"
279
+ set st["buf"] to ""
280
+ set st["cur"] to 0
281
+ otherwise when k == "char" and ev["ctrl"] and ev["text"] == "w"
282
+ let ws be word_start(st["buf"], st["cur"])
283
+ set st["buf"] to slice(st["buf"], 0, ws) + slice(st["buf"], st["cur"], length(st["buf"]))
284
+ set st["cur"] to ws
285
+ otherwise when k == "char" and ev["ctrl"] and ev["text"] == "o"
286
+ set result to "/out"
287
+ set done to true
288
+ otherwise when k == "char" and ev["ctrl"] and ev["text"] == "l"
289
+ term_write(h, ESC + "[2J" + ESC + "[H")
290
+ set st["cursor_row"] to 0
291
+ set changed to false
292
+ otherwise when k == "char" and not ev["ctrl"]
293
+ set st to insert(st, ev["text"])
294
+ otherwise
295
+ set changed to false
296
+ when changed
297
+ set st["menu_off"] to false
298
+ set st to refresh_menu(st, ctx)
299
+ when not done and dirty
300
+ set st to draw(h, st, color, ctx)
301
+ -- dejar el prompt limpio (sin menú) en el scrollback y bajar a una línea nueva
302
+ set st["menu"] to false
303
+ set st to draw(h, st, color, ctx)
304
+ let lines be split(st["buf"], "\n")
305
+ let size be term_size(h)
306
+ let tail be 0
307
+ let i be 0
308
+ each l in lines
309
+ let pre be when i == 0 then vis(st["prompt"]) otherwise 4
310
+ when i > 0 or true
311
+ set tail to tail + rows_of(pre + length(l), size["cols"])
312
+ set i to i + 1
313
+ let down be tail - st["cursor_row"] - 1
314
+ term_write(h, (when down > 0 then ESC + "[" + text(down) + "B" otherwise "") + "\r\n")
315
+ term_close(h)
316
+ give result
317
+
318
+ -- menú vertical de opciones (aprobaciones): ↑↓ + Enter, o la letra inicial / número; Esc = nothing
319
+ export task choose(question, options, color)
320
+ let h be term_open({"ctrl_c": "exit"})
321
+ when h == nothing
322
+ give -1
323
+ let sel be 0
324
+ let done be false
325
+ let result be nothing
326
+ let drawn be 0
327
+ while not done
328
+ let out be ""
329
+ when drawn > 0
330
+ set out to out + ESC + "[" + text(drawn) + "A"
331
+ set out to out + "\r" + ESC + "[J" + question
332
+ let i be 0
333
+ each o in options
334
+ set out to out + "\r\n" + " " + (when i == sel then sgr(color, "7", " " + o + " ") otherwise " " + sgr(color, "2", o))
335
+ set i to i + 1
336
+ term_write(h, out)
337
+ set drawn to length(options)
338
+ let ev be term_recv(h, 600)
339
+ when ev == nothing
340
+ set result to nothing
341
+ set done to true
342
+ otherwise when ev["type"] == "eof"
343
+ set done to true
344
+ otherwise when ev["type"] == "key"
345
+ let k be ev["key"]
346
+ when k == "up"
347
+ set sel to (when sel > 0 then sel - 1 otherwise length(options) - 1)
348
+ otherwise when k == "down" or k == "tab"
349
+ set sel to (when sel < length(options) - 1 then sel + 1 otherwise 0)
350
+ otherwise when k == "enter"
351
+ set result to sel
352
+ set done to true
353
+ otherwise when k == "escape"
354
+ set done to true
355
+ otherwise when k == "char" and not ev["ctrl"]
356
+ let t be lower(ev["text"])
357
+ let j be 0
358
+ each o in options
359
+ when t == text(j + 1) or t == lower(slice(o, 0, 1))
360
+ set result to j
361
+ set done to true
362
+ set j to j + 1
363
+ term_write(h, "\r\n")
364
+ term_close(h)
365
+ give result
package/lib/md.syn ADDED
@@ -0,0 +1,171 @@
1
+ -- lib/md.syn — markdown → ANSI para la terminal (sin dependencias)
2
+ -- render(md, color) → texto listo para print. color=false → texto plano legible (sin escapes).
3
+ -- inline(s, color) → solo formato inline (negrita, cursiva, `código`, links, ~~tachado~~).
4
+ -- MIGA: `matches` es full-match; para buscar/reemplazar son `capture`/`find_all`/`replace_re`
5
+ -- (backrefs \1). En strings "..." la barra va simple ("\*", "\d"): "\\d" NO es \d.
6
+ -- Los bloques ``` se copian tal cual (sin parseo inline). La web ya renderiza markdown por su cuenta.
7
+
8
+ let ESC be decode(bytes("1b", "hex"))
9
+ let WIDTH be 72
10
+
11
+ task sgr(color, code, s)
12
+ when not color or s == ""
13
+ give s
14
+ give ESC + "[" + code + "m" + s + ESC + "[0m"
15
+
16
+ task rep(ch, n)
17
+ let out be ""
18
+ while length(out) < n
19
+ set out to out + ch
20
+ give out
21
+
22
+ -- ---------- inline ----------
23
+
24
+ task emphasis(s, color)
25
+ let out be s
26
+ -- links [texto](url) → texto url (antes que la cursiva: la url puede traer _ o *)
27
+ set out to replace_re(out, "\[([^\]]+)\]\(([^)]+)\)", sgr(color, "4", "\1") + sgr(color, "2", " \2"))
28
+ set out to replace_re(out, "\*\*([^*]+)\*\*", sgr(color, "1", "\1"))
29
+ set out to replace_re(out, "__([^_]+)__", sgr(color, "1", "\1"))
30
+ set out to replace_re(out, "~~([^~]+)~~", sgr(color, "9", "\1"))
31
+ -- cursiva: *x* solo si no está pegada a texto/número (2*3 no es cursiva). Dos pasadas:
32
+ -- el grupo \3 consume el separador, así que "*a* *b*" necesita la segunda.
33
+ let pass be 0
34
+ while pass < 2
35
+ set out to replace_re(out, "(^|[^\w*])\*([^*\s][^*]*?)\*($|[^\w*])", "\1" + sgr(color, "3", "\2") + "\3")
36
+ set out to replace_re(out, "(^|[^\w_])_([^_\s][^_]*?)_($|[^\w_])", "\1" + sgr(color, "3", "\2") + "\3")
37
+ set pass to pass + 1
38
+ give out
39
+
40
+ export task inline(s, color)
41
+ -- `código` primero: lo de adentro no se toca
42
+ let parts be split(s, "`")
43
+ when length(parts) < 3
44
+ give emphasis(s, color)
45
+ let out be ""
46
+ let idx be 0
47
+ each p in parts
48
+ when idx == length(parts) - 1 and idx % 2 == 1
49
+ set out to out + "`" + emphasis(p, color)
50
+ otherwise when idx % 2 == 1
51
+ set out to out + sgr(color, "33", p)
52
+ otherwise
53
+ set out to out + emphasis(p, color)
54
+ set idx to idx + 1
55
+ give out
56
+
57
+ -- ---------- bloques ----------
58
+
59
+ task indent_of(line)
60
+ let n be 0
61
+ while n < length(line) and slice(line, n, n + 1) == " "
62
+ set n to n + 1
63
+ give n
64
+
65
+ task table_cells(line)
66
+ let t be trim(line)
67
+ when starts_with(t, "|")
68
+ set t to slice(t, 1, length(t))
69
+ when ends_with(t, "|")
70
+ set t to slice(t, 0, length(t) - 1)
71
+ give apply(trim, split(t, "|"))
72
+
73
+ -- rows: lista de listas de celdas (crudas); la primera es el header. Columnas alineadas al ancho
74
+ -- máximo de cada una (ancho visible = length de inline(c, false), sin escapes ni marcas).
75
+ task table_lines(rows, pre, color)
76
+ let widths be []
77
+ each r in rows
78
+ let i be 0
79
+ each c in r
80
+ when i >= length(widths)
81
+ set widths to append(widths, 0)
82
+ when length(inline(c, false)) > widths[i]
83
+ set widths[i] to length(inline(c, false))
84
+ set i to i + 1
85
+ let out be []
86
+ let ri be 0
87
+ each r in rows
88
+ let cells be []
89
+ let i be 0
90
+ each c in r
91
+ let fill be rep(" ", widths[i] - length(inline(c, false)))
92
+ set cells to append(cells, (when ri == 0 then sgr(color, "1", inline(c, color)) otherwise inline(c, color)) + fill)
93
+ set i to i + 1
94
+ set out to append(out, pre + join(cells, sgr(color, "2", " │ ")))
95
+ when ri == 0
96
+ let segs be []
97
+ each w in widths
98
+ set segs to append(segs, rep("─", w))
99
+ set out to append(out, pre + sgr(color, "2", join(segs, "─┼─")))
100
+ set ri to ri + 1
101
+ give out
102
+
103
+ task fence_top(lang, color)
104
+ let label be when lang != "" then " " + lang + " " otherwise ""
105
+ give sgr(color, "2", "┌──" + label + rep("─", WIDTH - 3 - length(label)))
106
+
107
+ export task render(md, color)
108
+ let pre be " "
109
+ let out be []
110
+ let in_code be false
111
+ let fence be ""
112
+ let table be []
113
+ each line in split(md, "\n")
114
+ let t be trim(line)
115
+ let heading be capture(t, "^(#{1,6}) (.+)$")
116
+ let item be capture(t, "^(\d{1,3})[.)] (.*)$")
117
+ let is_row be starts_with(t, "|") and ends_with(t, "|") and not in_code
118
+ when length(table) > 0 and not is_row
119
+ each l in table_lines(table, pre, color)
120
+ set out to append(out, l)
121
+ set table to []
122
+ when in_code
123
+ when starts_with(t, fence)
124
+ set in_code to false
125
+ set out to append(out, pre + sgr(color, "2", "└" + rep("─", WIDTH - 1)))
126
+ otherwise
127
+ set out to append(out, pre + sgr(color, "2", "│ ") + sgr(color, "36", line))
128
+ otherwise when starts_with(t, "```") or starts_with(t, "~~~")
129
+ set in_code to true
130
+ set fence to slice(t, 0, 3)
131
+ set out to append(out, pre + fence_top(trim(slice(t, 3, length(t))), color))
132
+ otherwise when heading != nothing
133
+ let lvl be length(heading[0])
134
+ let raw be trim(heading[1])
135
+ let title be inline(raw, color)
136
+ set out to append(out, "")
137
+ when lvl == 1
138
+ set out to append(out, pre + sgr(color, "1;36", upper(title)))
139
+ set out to append(out, pre + sgr(color, "36", rep("═", length(raw))))
140
+ otherwise when lvl == 2
141
+ set out to append(out, pre + sgr(color, "1;36", title))
142
+ set out to append(out, pre + sgr(color, "2;36", rep("─", length(raw))))
143
+ otherwise
144
+ set out to append(out, pre + sgr(color, "1", title))
145
+ otherwise when matches(t, "(-{3,}|\*{3,}|_{3,})")
146
+ set out to append(out, pre + sgr(color, "2", rep("─", WIDTH)))
147
+ otherwise when starts_with(t, ">")
148
+ set out to append(out, pre + sgr(color, "2", "▎ ") + sgr(color, "3", inline(trim(slice(t, 1, length(t))), color)))
149
+ otherwise when matches(t, "\|[\s:|-]+\|")
150
+ set table to table
151
+ otherwise when is_row
152
+ set table to append(table, table_cells(t))
153
+ otherwise when starts_with(t, "- ") or starts_with(t, "* ") or starts_with(t, "+ ") or item != nothing
154
+ let ind be rep(" ", indent_of(line))
155
+ let mark be when item != nothing then sgr(color, "36", item[0] + ".") otherwise sgr(color, "36", "•")
156
+ let rest be when item != nothing then item[1] otherwise slice(t, 2, length(t))
157
+ when starts_with(rest, "[ ] ")
158
+ set mark to sgr(color, "2", "☐")
159
+ set rest to slice(rest, 4, length(rest))
160
+ otherwise when starts_with(rest, "[x] ") or starts_with(rest, "[X] ")
161
+ set mark to sgr(color, "32", "☑")
162
+ set rest to slice(rest, 4, length(rest))
163
+ set out to append(out, pre + ind + mark + " " + inline(rest, color))
164
+ otherwise
165
+ set out to append(out, when t == "" then "" otherwise pre + inline(line, color))
166
+ when length(table) > 0
167
+ each l in table_lines(table, pre, color)
168
+ set out to append(out, l)
169
+ when in_code
170
+ set out to append(out, pre + sgr(color, "2", "└" + rep("─", WIDTH - 1)))
171
+ give join(out, "\n")
@@ -149,6 +149,41 @@ export task describe_call(name, args)
149
149
  give "skill install " + text(args["source"]) + " --skill " + text(args["name"]) + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
150
150
  when name == "bash"
151
151
  give "$ " + one_line(when contains(args, "command") then text(args["command"]) otherwise "", 160)
152
+ -- lectura/búsqueda: como lo escribiría un humano en la shell
153
+ when name == "read"
154
+ let rng be when contains(args, "offset") or contains(args, "limit") then " (" + (when contains(args, "offset") then "desde " + text(floor(number(text(args["offset"])))) otherwise "") + (when contains(args, "limit") then " " + text(floor(number(text(args["limit"])))) + " líneas" otherwise "") + ")" otherwise ""
155
+ give "read " + arg(args, "path", ".") + rng
156
+ when name == "ls"
157
+ give "ls " + arg(args, "path", ".")
158
+ when name == "find"
159
+ give "find " + arg(args, "pattern", "*") + (when contains(args, "path") then " en " + text(args["path"]) otherwise "")
160
+ when name == "grep"
161
+ let flags be (when contains(args, "regex") and args["regex"] == true then " -E" otherwise "") + (when contains(args, "glob") then " --glob " + text(args["glob"]) otherwise "")
162
+ give "grep" + flags + " \"" + one_line(arg(args, "pattern", ""), 80) + "\"" + (when contains(args, "path") then " " + text(args["path"]) otherwise "")
163
+ when name == "edit"
164
+ give "edit " + arg(args, "path", "?")
165
+ when name == "write"
166
+ give "write " + arg(args, "path", "?") + (when contains(args, "content") then dim_len(text(args["content"])) otherwise "")
167
+ when name == "memory"
168
+ let mact be arg(args, "action", "list")
169
+ give "memory " + mact + (when contains(args, "name") then " " + text(args["name"]) otherwise "") + (when (mact == "write" or mact == "append") and contains(args, "content") then " — " + one_line(text(args["content"]), 70) otherwise "")
170
+ when name == "process"
171
+ let pact be arg(args, "action", "list")
172
+ give "process " + pact + (when contains(args, "name") then " " + text(args["name"]) otherwise "") + (when contains(args, "command") then " $ " + one_line(text(args["command"]), 100) otherwise "")
173
+ when name == "skill"
174
+ give "skill " + arg(args, "action", "load") + " " + arg(args, "name", "?")
175
+ when name == "todo"
176
+ let n be when contains(args, "items") then length(args["items"]) otherwise 0
177
+ let done be when contains(args, "items") then length(where(args["items"], (i) => contains(i, "status") and text(i["status"]) == "done")) otherwise 0
178
+ give "todo " + text(done) + "/" + text(n) + " hechas"
152
179
  when contains(args, "path")
153
180
  give name + " " + text(args["path"])
154
- give name + " " + json_encode(args)
181
+ give name + " " + one_line(json_encode(args), 160)
182
+
183
+ task arg(args, key, default)
184
+ when contains(args, key)
185
+ give text(args[key])
186
+ give default
187
+
188
+ task dim_len(s)
189
+ give " (" + text(length(split(s, "\n"))) + " líneas)"
@@ -16,8 +16,11 @@ export task tool(path, old_string, new_string, replace_all)
16
16
  when n > 1
17
17
  when not all
18
18
  raise(`old_string appears {text(n)} times in {shown}; include more surrounding context to make it unique, or pass replace_all=true`)
19
- write_file(real, join(parts, new_string))
19
+ let after be join(parts, new_string)
20
+ write_file(real, after)
20
21
  c.mark_observed(real)
22
+ -- antes/después para que la UI muestre el diff (chat.syn / web); el modelo solo recibe la línea de abajo
23
+ share {"path": shown, "old": content, "new": after} as "lampson:ui:diff"
21
24
  give `edited {shown}: {text(n)} replacement(s)`
22
25
 
23
26
  export let SPEC be {
@@ -6,10 +6,14 @@ export task tool(path, content)
6
6
  require file("workspace/*")
7
7
  let real be c.ws(path)
8
8
  -- sobrescribir un archivo existente exige haberlo leído (y que no haya cambiado): igual que edit
9
+ let before be ""
9
10
  when file_exists(real)
10
11
  c.check_observed(real, "write")
12
+ set before to read_file(real)
11
13
  write_file(real, content)
12
14
  c.mark_observed(real)
15
+ -- antes/después para que la UI muestre el diff (archivo nuevo: before = "")
16
+ share {"path": c.unws(real), "old": before, "new": content} as "lampson:ui:diff"
13
17
  give `wrote {text(length(content))} chars to {c.unws(real)}`
14
18
 
15
19
  export let SPEC be {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, lamps (your own tool plugins), LSP, MCP, sub-agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "node": ">=18"
33
33
  },
34
34
  "dependencies": {
35
- "synsema": "^0.6.10"
35
+ "synsema": "^0.6.11"
36
36
  },
37
37
  "scripts": {
38
38
  "test": "pwsh -NoProfile -File tests/run.ps1"
@@ -3,7 +3,7 @@ name: synsema
3
3
  description: Writing, checking, running and testing Synsema (.syn) code — syntax reflexes, capabilities, live processes / pseudo-terminals, and the runtime traps that cost hours. Load before touching any .syn file.
4
4
  ---
5
5
 
6
- # Synsema quick reference (v0.6.10)
6
+ # Synsema quick reference (v0.6.11)
7
7
 
8
8
  > Curated 10 KB summary for the agent (the full reference is ~450 KB and lives in the user's editor
9
9
  > skill). Kept in sync by hand with each `synsema update`; if `synsema --version` is newer than the
@@ -22,6 +22,11 @@ description: Writing, checking, running and testing Synsema (.syn) code — synt
22
22
  - `try` … `recover err` (recover SWALLOWS; `raise(err)` to re-throw) · `raise("msg")`
23
23
  - `contains(xs, x)` (on maps checks KEYS) · `append(xs, x)` returns a NEW list → `set xs to append(xs, x)`
24
24
  - `apply(f, xs)`, `where(xs, p)`, `sort_by(xs, f)`, `slice(xs, a, b)`, `split/join/trim/lower/upper`
25
+ - Text/regex: `replace_text(t, old, new)` (literal), `replace_re(t, re, rep)` (`\1` backrefs), `capture(t, re)`
26
+ (first match; with groups → list), `find_all`, `matches` (**full match** only — not a search).
27
+ There is NO `replace`, `regex_replace`, `chars`, `repeat`. Rust regex: no lookahead/lookbehind.
28
+ In `"..."` strings backslashes are literal: write `"\d"`, `"\*"` (`"\\d"` is a literal `\d`).
29
+ Anonymous functions: `(x) => expr` only (no inline `task(x)`).
25
30
  - `json_encode / json_decode` · `length` · `text(x)` · `number(s)` (ALWAYS float → `floor()` for ints)
26
31
  - Modules: `use "./m.syn" as m` (local only, never `../`), `export task/let`. A module cannot have
27
32
  top-level `require` or `serve` — the ENTRY file grants capabilities.
@@ -50,6 +55,12 @@ description: Writing, checking, running and testing Synsema (.syn) code — synt
50
55
  `proc_spawn` in a handler is gone when the handler returns. A process that must outlive requests lives
51
56
  inside an `agent` spawned from the handler (own lifecycle; blackboard `share/observe` + `bus_*` are shared
52
57
  with handlers). That is how lampson's `process` tool works (`lib/tools/proc.syn`).
58
+ - **Own terminal / raw keys (v0.6.11+)**: `let h be term_open({"ctrl_c": "exit"})` → `nothing` without a
59
+ TTY / under `test`/`serve` (fall back to `read_line`); `term_recv(h, secs)` → `{type: "key", key, text,
60
+ ctrl, alt, shift}` (`key` = `"char"|"enter"|"tab"|"backspace"|"up"|…`), `paste`, `resize`, `eof`;
61
+ draw with `term_write(h, ansi)` (**`print` stays buffered**); `term_size(h)`; `term_close(h)`. Alt+Enter
62
+ always arrives (Shift+Enter needs kitty protocol). `ask/approve` still work while open. Lampson's line
63
+ editor is `lib/line.syn`; drive a terminal UI in tests via `proc_spawn(…, {pty: true})` (`tty_test.syn`).
53
64
  - **File watch (v0.6.9+)**: `let w be watch("src", {"interval": 0.2, "ignore": ["*.tmp"]})` → events
54
65
  `{type: "create"|"modify"|"delete", path, is_dir}` via `watch_recv(w, secs)` or `select`; polling with a
55
66
  snapshot (latency = interval), `watch_close(w)`. Gate: `file("src")` + `file("src/*")`.
@@ -67,7 +78,9 @@ description: Writing, checking, running and testing Synsema (.syn) code — synt
67
78
  - `localhost` resolves to IPv6; use `127.0.0.1`.
68
79
  - `run("bash", ...)` hangs on Windows (WSL bash) → `C:\Program Files\Git\bin\bash.exe` or `cmd /c`.
69
80
  - A task named `run` shadows the builtin `run` (infinite recursion).
70
- - Reserved words that break variable/param names: `reason task ask stop decide analyze generate show approve confirm`.
81
+ - Reserved words that break variable/param names: `reason task ask stop type run decide analyze generate show approve confirm`.
82
+ - Reading a MISSING map key is a runtime error (`Map has no key 'x'`), not `nothing` → `contains(m, "x")` first.
83
+ - Maps are passed by reference: `set m["k"] to v` inside a task IS visible to the caller (lists via `append` are not — it returns a new list).
71
84
  - `and`/`or` do NOT short-circuit → nest `when` before indexing.
72
85
  - No `merge`: add a key with `set m["k"] to v`. No `append_file`: read + write (atomic; parents created).
73
86
  - Runtime error messages are Capitalized (`Not a directory: …`) and `contains` is case-sensitive → compare `lower(text(err))`.