lampson 0.1.2 → 0.1.3

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
@@ -139,6 +139,10 @@ servers, flags), `↑↓` browse history or the menu, `Alt+Enter` inserts a newl
139
139
  last tool result in full, `Ctrl+U`/`Ctrl+W` clear the line/word, `Esc` closes the menu. Approvals are
140
140
  an arrow-key menu (`permitir`/`denegar`, or `p`/`d`).
141
141
 
142
+ While the model thinks or a slow tool runs (a long `bash`, a sub-agent), a status line shows what is
143
+ running, the elapsed time and a bar that fills as you wait (`⠋ pensando ▰▰▱▱▱▱▱▱▱▱ 12s`); it appears
144
+ after 0.4 s so instant tools do not flicker, and anything you type meanwhile lands in the prompt.
145
+
142
146
  The terminal renders the model's markdown (headings, lists, tables, code fences) and shows every tool
143
147
  result: `edit`/`write` print a line diff (`- red / + green`, line numbers, 2 lines of context); other
144
148
  tools are collapsed to 15 lines. `/out [n]` prints the n-th last result of the turn in full and
@@ -299,7 +303,7 @@ Borrowed from the harness that does each part best (see `notes/*.md`):
299
303
  | Budget runs out silently | 80 %: a notice appended to the latest tool result (no new user message, cache stays warm); 95 %: last step without tools, summary required | hermes / opencode |
300
304
  | Edits a file it never read, or one that changed | **Observation gate in code**: `read` records the file hash; `edit`/`write` on an existing file are rejected without it, or if the file changed since | deepseek |
301
305
  | Loses the plan | `todo` tool (whole-list replacement, one `in_progress` at a time, scoped to the session like the three references); re-injected only after context compaction, active items only | hermes, opencode |
302
- | Reads the whole project before touching anything | **Exploration cap** (ours): after 8 read-only calls in a row (read/ls/find/grep) without an edit/write/command the result carries a warning; after 16 they are refused until it acts (`LAMPSON_EXPLORE_CAP`) | — |
306
+ | Reads the whole project before touching anything | **Exploration cap** (ours): after 12 read-only calls in a row (read/ls/find/grep) without an edit/write/command the result carries a warning; after 24 they are refused until it acts (`LAMPSON_EXPLORE_CAP`) | — |
303
307
 
304
308
  ### Sub-agents
305
309
 
package/chat.syn CHANGED
@@ -46,6 +46,7 @@ use "./lib/trace.syn" as trace
46
46
  use "./lib/md.syn" as md
47
47
  use "./lib/diff.syn" as diff
48
48
  use "./lib/line.syn" as ed
49
+ use "./lib/ui.syn" as ui
49
50
  use "./lib/mcp.syn" as mcp
50
51
  use "./lib/lamps.syn" as lamps
51
52
  use "./lib/lsp.syn" as lsp
@@ -107,6 +108,15 @@ task summarize(name, out)
107
108
  set used to used + length(l) + 2
108
109
  let rest be length(lines) - length(shown)
109
110
  give text(length(lines)) + (when name == "ls" then " entradas: " otherwise " archivos: ") + join(shown, " ") + (when rest > 0 then dim(" +" + text(rest)) otherwise "")
111
+ when name == "delegate" and not VERBOSE
112
+ -- una línea por subagente: el encabezado [id · agente · estado · pasos · tokens · log]
113
+ let heads be []
114
+ each l in lines
115
+ when starts_with(trim(l), "[") and contains(l, " steps") or (starts_with(trim(l), "[") and contains(l, " · log:"))
116
+ set heads to append(heads, trim(l))
117
+ when length(heads) == 0
118
+ give first_line(out, 120)
119
+ give join(heads, "\n ")
110
120
  when name == "grep" and not VERBOSE
111
121
  let files be {}
112
122
  each l in lines
@@ -136,11 +146,21 @@ task diff_of(name, out)
136
146
  give diff.diff(d["old"], d["new"], 2, COLOR)
137
147
 
138
148
  task print_diff(path, d)
139
- let head be path + " · " + green("+" + text(d["added"])) + " " + red("−" + text(d["removed"]))
140
- print(" " + green("✓") + " " + head)
149
+ print_diff_with(" ", path, d)
150
+
151
+ -- lead = lo que va antes del ✓ (la llamada, si se fusionó en la misma línea)
152
+ task print_diff_with(lead, path, d)
153
+ let head be green("✓") + " " + c("34", path) + " " + green("+" + text(d["added"])) + " " + red("−" + text(d["removed"]))
154
+ print(lead + head)
141
155
  each l in d["lines"]
142
156
  print(" " + l)
143
157
 
158
+ -- tools "instantáneas": la llamada se imprime junto con su resultado, en una sola línea
159
+ task is_instant(name)
160
+ give name == "read" or name == "ls" or name == "find" or name == "grep" or name == "memory" or name == "edit" or name == "write" or name == "todo" or name == "lsp"
161
+
162
+ let pending_call be ""
163
+
144
164
  -- /out: resultado completo de una tool del turno
145
165
  task print_full(o)
146
166
  when o["diff"] != nothing
@@ -149,17 +169,110 @@ task print_full(o)
149
169
  each l in split(o["output"], "\n")
150
170
  print(" " + l)
151
171
 
172
+ -- ---------- indicador de espera ----------
173
+ -- El intérprete principal se bloquea en la llamada al modelo y en cada tool; el que dibuja es un agente
174
+ -- (hilo real) que mira el blackboard: `lampson:busy` = {label, kind, since} mientras hay algo en curso.
175
+ -- Arranca a dibujar recién a los 0,4 s (las tools instantáneas no parpadean) y suelta la terminal
176
+ -- (`lampson:spinner:holding` = false) antes de que el principal vuelva a imprimir.
177
+ agent Spinner
178
+ require time
179
+ let frames be ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
180
+ let i be 0
181
+ let drawn be false
182
+ let run be true
183
+ -- MIGA: un agente NO puede abrir la terminal (term_open → nothing) pero sí print+flush. Cada print de un
184
+ -- agente sale con el prefijo "[Spinner_0] " en la línea de abajo: cada frame limpia esa línea (\r ESC[K),
185
+ -- sube una (ESC[1A) y redibuja la suya. Al terminar deja el cursor en la línea del spinner, vacía.
186
+ let clear_here be "\r" + esc + "[K"
187
+ let up be esc + "[1A"
188
+ while run
189
+ observe "lampson:spinner:stop" as st
190
+ observe "lampson:busy" as b
191
+ when st == true
192
+ set run to false
193
+ otherwise when b == nothing
194
+ when drawn
195
+ print(clear_here + up + clear_here + up)
196
+ flush()
197
+ set drawn to false
198
+ share false as "lampson:spinner:holding"
199
+ sleep(0.05)
200
+ otherwise
201
+ let el be now() - b["since"]
202
+ when el >= 0.4
203
+ -- barra de "costo hundido": se llena rápido al principio y cada vez más despacio
204
+ let fill be floor(10 * (1 - exp(0 - el / 40)))
205
+ let bar be ""
206
+ let k be 0
207
+ while k < 10
208
+ set bar to bar + (when k < fill then "▰" otherwise "▱")
209
+ set k to k + 1
210
+ let hint be when el > 90 then " esto está tardando (Ctrl+C corta)" otherwise (when el > 30 then " ya casi" otherwise (when el > 8 then " sigue trabajando" otherwise ""))
211
+ let secs be text(floor(el)) + "s"
212
+ let label be b["label"]
213
+ when length(label) > 60
214
+ set label to slice(label, 0, 59) + "…"
215
+ let frame be frames[i % 10]
216
+ let body be when color then esc + "[36m" + frame + esc + "[0m " + label + " " + esc + "[2m" + bar + " " + secs + hint + esc + "[0m" otherwise frame + " " + label + " " + bar + " " + secs + hint
217
+ when drawn
218
+ print(clear_here + up + clear_here + " " + body)
219
+ otherwise
220
+ share true as "lampson:spinner:holding"
221
+ print(clear_here + " " + body)
222
+ set drawn to true
223
+ flush()
224
+ set i to i + 1
225
+ sleep(0.08)
226
+ when drawn
227
+ print(clear_here + up + clear_here + up)
228
+ flush()
229
+ share false as "lampson:spinner:holding"
230
+
231
+ task busy_on(label, kind)
232
+ share {"label": label, "kind": kind, "since": now()} as "lampson:busy"
233
+
234
+ -- apagar y esperar a que el agente suelte la terminal (máx. 1 s) antes de volver a imprimir
235
+ task busy_off()
236
+ share nothing as "lampson:busy"
237
+ let waited be 0
238
+ let holding be true
239
+ while holding and waited < 100
240
+ observe "lampson:spinner:holding" as hd
241
+ set holding to hd == true
242
+ when holding
243
+ sleep(0.01)
244
+ set waited to waited + 1
245
+
152
246
  -- tag = "" para el agente principal; los subagentes en background NO pasan por acá (escriben su log)
153
247
  task on_event(kind, data, tag)
154
248
  trace.event(sid, kind, data, tag)
155
- when kind == "assistant"
249
+ when kind == "busy"
250
+ busy_on(data["label"], data["kind"])
251
+ otherwise when kind == "idle"
252
+ busy_off()
253
+ otherwise when kind == "assistant"
254
+ -- ● marca el turno del asistente (primera línea con contenido)
255
+ let lines be split(md.render(data, COLOR), "\n")
256
+ let marked be false
257
+ let out_lines be []
258
+ each l in lines
259
+ when not marked and trim(l) != "" and starts_with(l, " ")
260
+ set out_lines to append(out_lines, c("36;1", "● ") + slice(l, 2, length(l)))
261
+ set marked to true
262
+ otherwise
263
+ set out_lines to append(out_lines, l)
156
264
  print("")
157
- print(md.render(data, COLOR))
265
+ print(join(out_lines, "\n"))
158
266
  print("")
159
267
  otherwise when kind == "inbox"
160
268
  print(" " + cyan("✉ " + first_line(data, 140)))
161
269
  otherwise when kind == "tool_call"
162
- print(" " + yellow("▸ " + permission.describe_call(data["name"], data["args"])))
270
+ let desc be permission.describe_call(data["name"], data["args"])
271
+ when is_instant(data["name"])
272
+ set pending_call to desc
273
+ otherwise
274
+ set pending_call to ""
275
+ print(" " + yellow("▸ " + desc))
163
276
  otherwise when kind == "tool_result"
164
277
  let out be data["output"]
165
278
  let name be data["call"]["name"]
@@ -167,11 +280,17 @@ task on_event(kind, data, tag)
167
280
  let d be diff_of(name, out)
168
281
  let path be when d != nothing then text(data["call"]["args"]["path"]) otherwise ""
169
282
  set turn_outputs to append(turn_outputs, {"name": name, "args": data["call"]["args"], "output": out, "diff": d, "path": path})
283
+ let lead be when pending_call != "" then " " + yellow("▸ " + pending_call) + " " otherwise " "
284
+ set pending_call to ""
170
285
  when d != nothing
171
- print_diff(path, d)
286
+ print_diff_with(lead, path, d)
172
287
  otherwise
173
288
  let mark be when bad then red("✗") otherwise green("✓")
174
- print(" " + mark + " " + (when bad then red(summarize(name, out)) otherwise dim(summarize(name, out))))
289
+ let s be summarize(name, out)
290
+ -- una sola línea → cortar al ancho; multilínea (bash) se deja
291
+ when not contains(s, "\n")
292
+ set s to ui.cut(s, ui.cols() - ui.width(lead) - 4)
293
+ print(lead + mark + " " + (when bad then red(s) otherwise dim(s)))
175
294
  otherwise when kind == "tool_denied"
176
295
  set data to data
177
296
  otherwise when kind == "error"
@@ -180,11 +299,13 @@ task on_event(kind, data, tag)
180
299
  print(" " + yellow("el proveedor rechazó el nombre del modelo (" + cfg["model"] + "): /model sin argumentos lista los válidos, /model <nombre> lo cambia"))
181
300
  otherwise when kind == "compact"
182
301
  print(" " + dim("⧗ compactando contexto (~" + text(data["before"]) + " tokens)"))
302
+ busy_on("compactando contexto", "llm")
183
303
  flush()
184
304
 
185
305
  -- Human in the loop con el `approve` nativo de Synsema: prompt [approve] … (y/n) en TTY; sin TTY deniega
186
306
  -- (fail-closed, un agente no puede auto-aprobarse); `within` acota la espera.
187
307
  task ask_user(name, args, why)
308
+ busy_off()
188
309
  print("")
189
310
  print(" " + yellow("⚠ requiere tu aprobación · " + why))
190
311
  print(" " + permission.describe_call(name, args))
@@ -641,6 +762,9 @@ when sid == ""
641
762
  share {"id": sid} as "lampson:session"
642
763
 
643
764
  banner(env_info["cwd"], cfg, profile, opts["permission_mode"], sid)
765
+ share nothing as "lampson:busy"
766
+ share false as "lampson:spinner:stop"
767
+ spawn Spinner with esc = ESC, color = COLOR
644
768
  flush()
645
769
 
646
770
  let total_usage be {"input": 0, "output": 0}
@@ -1005,23 +1129,31 @@ while running
1005
1129
  -- catálogo fresco por turno: si el turno anterior conectó/quitó un server MCP, sus tools
1006
1130
  -- entran/salen acá (con los mismos servers el catálogo es idéntico → el prompt cache no se corta)
1007
1131
  set opts to opts_for(profile, mode)
1132
+ let t0 be now()
1008
1133
  let result be loop.run_turn(cfg, messages, opts, on_event)
1009
1134
  trace.turn_end(sid, result)
1010
1135
  set messages to result["messages"]
1011
1136
  set total_usage to {"input": total_usage["input"] + result["usage"]["input"], "output": total_usage["output"] + result["usage"]["output"]}
1012
- let summary be text(result["steps"]) + (when result["steps"] == 1 then " paso" otherwise " pasos") + " · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens"
1137
+ let summary be text(result["steps"]) + (when result["steps"] == 1 then " paso" otherwise " pasos") + " · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens · " + ui.fmt_duration(now() - t0)
1013
1138
  when result["stopped"] == "max_steps"
1014
1139
  set summary to summary + " · " + red("límite de " + text(opts["max_steps"]) + " pasos por turno")
1015
1140
  print(dim(" ─── " + summary))
1016
1141
  print(" " + yellow("El agente paró por el límite de pasos, no porque terminó. Escribí «seguí» para que continúe donde quedó, o subí LAMPSON_MAX_STEPS."))
1017
1142
  otherwise when result["stopped"] != "done"
1018
- set summary to summary + " · " + red("detenido: " + result["stopped"])
1143
+ -- el detalle del error ya salió en la línea ‼; acá solo la causa corta
1144
+ let why be result["stopped"]
1145
+ when starts_with(why, "error: network")
1146
+ set why to "error de red (ver ‼ arriba)"
1147
+ otherwise when starts_with(why, "error:")
1148
+ set why to first_line(why, 60)
1149
+ set summary to summary + " · " + red("detenido: " + why)
1019
1150
  print(dim(" ─── " + summary))
1020
1151
  otherwise
1021
1152
  print(dim(" ─── " + summary))
1022
1153
  session.save(sid, messages, {"title": session.title_of(messages)})
1023
1154
  flush()
1024
1155
  -- los procesos gestionados (servidores) y los subagentes en background mueren con lampson
1156
+ share true as "lampson:spinner:stop"
1025
1157
  agents.stop_all()
1026
1158
  mcp.stop_all()
1027
1159
  lsp.stop_all()
package/lib/diff.syn CHANGED
@@ -1,10 +1,33 @@
1
1
  -- lib/diff.syn — diff de líneas para mostrar en la terminal qué cambió edit/write
2
2
  -- diff(old, new, ctx, color) → {"added": n, "removed": m, "lines": [texto ya formateado…]}
3
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.
4
+ -- medio. Si el medio supera LIMIT² celdas (80×80 líneas cambiadas ≈ un rewrite) se muestra como bloque
5
+ -- quitado + bloque agregado: exacto y en milisegundos (el LCS interpretado a 300×300 tardaba 2 minutos).
6
+
7
+ use "./ui.syn" as ui
5
8
 
6
9
  let ESC be decode(bytes("1b", "hex"))
7
- let LIMIT be 600
10
+ let LIMIT be 80
11
+
12
+ -- línea de diff con fondo (verde/rojo) rellenada hasta el ancho útil, cortada si se pasa
13
+ task row(color, kind, num, body, width)
14
+ let n be sgr(color, "2", lpad(num, width) + " │ ")
15
+ let w be ui.cols() - 8 - width - 3
16
+ when w < 30
17
+ set w to 30
18
+ let txt be ui.cut(kind + " " + body, w)
19
+ let pad be rep(" ", w - ui.width(txt))
20
+ when kind == "+"
21
+ give n + ui.bg_add(color, sgr(color, "32", txt + pad))
22
+ when kind == "-"
23
+ give n + ui.bg_del(color, sgr(color, "31", txt + pad))
24
+ give n + sgr(color, "2", ui.cut(" " + body, w))
25
+
26
+ task rep(ch, n)
27
+ let out be ""
28
+ while length(out) < n
29
+ set out to out + ch
30
+ give out
8
31
 
9
32
  task sgr(color, code, s)
10
33
  when not color
@@ -22,10 +45,13 @@ task lcs_ops(a, b)
22
45
  let n be length(a)
23
46
  let m be length(b)
24
47
  -- 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)
48
+ -- MIGA: append copia la lista entera (O(n) cada vez) → construirla con append era O(n²·m²).
49
+ -- range() la crea de una y set por índice es O(1).
50
+ let table be range(0, (n + 1) * (m + 1))
51
+ let z be 0
52
+ while z < (n + 1) * (m + 1)
53
+ set table[z] to 0
54
+ set z to z + 1
29
55
  let i be n - 1
30
56
  while i >= 0
31
57
  let j be m - 1
@@ -98,7 +124,7 @@ export task diff(old, new, ctx, color)
98
124
  set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
99
125
  let k be start
100
126
  while k < pre
101
- set lines to append(lines, sgr(color, "2", lpad(text(k + 1), width) + " │ " + a[k]))
127
+ set lines to append(lines, row(color, " ", text(k + 1), a[k], width))
102
128
  set k to k + 1
103
129
  -- medio: contexto interno acotado (ctx a cada lado de un cambio)
104
130
  let last_change be -1
@@ -118,16 +144,16 @@ export task diff(old, new, ctx, color)
118
144
  set w to w + 1
119
145
  when o["op"] == " "
120
146
  when near
121
- set lines to append(lines, sgr(color, "2", lpad(text(pre + o["b"] + 1), width) + " │ " + mid_b[o["b"]]))
147
+ set lines to append(lines, row(color, " ", text(pre + o["b"] + 1), mid_b[o["b"]], width))
122
148
  set skipping to false
123
149
  otherwise when not skipping
124
150
  set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
125
151
  set skipping to true
126
152
  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"]]))
153
+ set lines to append(lines, row(color, "-", text(pre + o["a"] + 1), mid_a[o["a"]], width))
128
154
  set skipping to false
129
155
  otherwise
130
- set lines to append(lines, sgr(color, "2", lpad(text(pre + o["b"] + 1), width) + " │ ") + sgr(color, "32", "+ " + mid_b[o["b"]]))
156
+ set lines to append(lines, row(color, "+", text(pre + o["b"] + 1), mid_b[o["b"]], width))
131
157
  set skipping to false
132
158
  set idx to idx + 1
133
159
  -- contexto posterior (del sufijo común)
@@ -135,7 +161,7 @@ export task diff(old, new, ctx, color)
135
161
  let upto be when after_start + ctx < length(b) then after_start + ctx otherwise length(b)
136
162
  set k to after_start
137
163
  while k < upto
138
- set lines to append(lines, sgr(color, "2", lpad(text(k + 1), width) + " │ " + b[k]))
164
+ set lines to append(lines, row(color, " ", text(k + 1), b[k], width))
139
165
  set k to k + 1
140
166
  when upto < length(b)
141
167
  set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
package/lib/line.syn CHANGED
@@ -8,6 +8,8 @@
8
8
  -- devuelva ctx["complete"](cmd, prefijo) (archivos, servers, flags…). Máx MENU_ROWS filas.
9
9
  -- MIGA: dibujar SIEMPRE con term_write (print queda buffereado). El runtime restaura la terminal al cerrar.
10
10
 
11
+ use "./ui.syn" as ui
12
+
11
13
  let ESC be decode(bytes("1b", "hex"))
12
14
  let MENU_ROWS be 8
13
15
  export let INTERRUPT be ESC + "interrupt"
@@ -18,7 +20,7 @@ task sgr(color, code, s)
18
20
  give ESC + "[" + code + "m" + s + ESC + "[0m"
19
21
 
20
22
  task vis(s)
21
- give length(strip_ansi(s))
23
+ give ui.width(s)
22
24
 
23
25
  task rep(ch, n)
24
26
  let out be ""
@@ -102,13 +104,14 @@ task draw(h, st, color, ctx)
102
104
  set i to 0
103
105
  each l in lines
104
106
  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
+ set out to out + (when i > 0 then "\r\n" otherwise "") + pre + (when st["final"] and l != "" then sgr(color, "7", " " + l + " ") otherwise l)
108
+ let r be rows_of(vis(pre) + ui.width(l), cols)
107
109
  when i < cl
108
110
  set rows_before_cursor to rows_before_cursor + r
109
111
  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
+ let ccw be ui.width(slice(l, 0, cc))
113
+ set rows_before_cursor to rows_before_cursor + floor((vis(pre) + ccw) / cols)
114
+ set cursor_col to (vis(pre) + ccw) % cols
112
115
  set total to total + r
113
116
  set i to i + 1
114
117
  -- menú
@@ -182,7 +185,8 @@ export task read(prompt, color, ctx)
182
185
  let h be term_open({"ctrl_c": "exit"})
183
186
  when h == nothing
184
187
  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}
188
+ ui.set_cols(term_size(h)["cols"])
189
+ let st be {"buf": "", "cur": 0, "prompt": prompt, "cursor_row": 0, "menu": false, "menu_off": false, "kind": "", "items": [], "sel": 0, "nav": false, "final": false}
186
190
  let hist be ctx["history"]
187
191
  let hi be length(hist)
188
192
  let draft be ""
@@ -207,7 +211,7 @@ export task read(prompt, color, ctx)
207
211
  set st to insert(st, replace_text(ev["text"], "\r", ""))
208
212
  set st to refresh_menu(st, ctx)
209
213
  otherwise when ev["type"] == "resize"
210
- set st to st
214
+ ui.set_cols(ev["cols"])
211
215
  otherwise when ev["type"] == "key"
212
216
  let k be ev["key"]
213
217
  let changed be true
@@ -300,13 +304,14 @@ export task read(prompt, color, ctx)
300
304
  set st to draw(h, st, color, ctx)
301
305
  -- dejar el prompt limpio (sin menú) en el scrollback y bajar a una línea nueva
302
306
  set st["menu"] to false
307
+ set st["final"] to result != nothing and result != INTERRUPT and trim(st["buf"]) != ""
303
308
  set st to draw(h, st, color, ctx)
304
309
  let lines be split(st["buf"], "\n")
305
310
  let size be term_size(h)
306
311
  let tail be 0
307
312
  let i be 0
308
313
  each l in lines
309
- let pre be when i == 0 then vis(st["prompt"]) otherwise 4
314
+ let pre be (when i == 0 then vis(st["prompt"]) otherwise 4) + (when st["final"] then 2 otherwise 0)
310
315
  when i > 0 or true
311
316
  set tail to tail + rows_of(pre + length(l), size["cols"])
312
317
  set i to i + 1
package/lib/loop.syn CHANGED
@@ -55,7 +55,7 @@ export task explore_cap()
55
55
  let e be env("LAMPSON_EXPLORE_CAP", "")
56
56
  when e != ""
57
57
  give floor(number(e))
58
- give 16
58
+ give 24
59
59
 
60
60
  -- {streak, note, refuse}: nuevo streak tras esta llamada y qué hacer con ella
61
61
  export task explore_verdict(streak, name, cap)
@@ -100,6 +100,9 @@ export task spill(name, id, out)
100
100
  require file(".lampson/*")
101
101
  when name == "read" or length(out) <= SPILL_CAP
102
102
  give out
103
+ -- el informe de los subagentes es el entregable: no se manda a disco (hasta 60k)
104
+ when name == "delegate" and length(out) <= SPILL_CAP * 6
105
+ give out
103
106
  let path be SPILL_DIR + "/" + id + ".txt"
104
107
  write_file(path, out)
105
108
  let half be floor(SPILL_CAP / 2)
@@ -276,17 +279,21 @@ task execute(tc, opts, on_event)
276
279
  when not approved
277
280
  emit(on_event, "tool_denied", {"call": tc, "reason": "user declined (" + verdict["reason"] + ")"}, tag)
278
281
  give "DENIED by the user (" + verdict["reason"] + "). Do not retry this exact action; ask the user or propose an alternative."
282
+ emit(on_event, "busy", {"label": permission.describe_call(name, args), "kind": "tool", "name": name}, tag)
283
+ let out be ""
279
284
  try
280
285
  -- tools MCP: no son tasks Synsema (args libres); el registry las marca con "mcp"
281
286
  when registry[name] == "mcp"
282
- give text(mcp.call(name, args))
287
+ set out to text(mcp.call(name, args))
283
288
  -- tools de lámparas: un proceso hijo por llamada (lib/lamps.syn)
284
- when registry[name] == "lamp"
285
- give text(lamps.call(name, args))
286
- let result be call_tool(registry[name], args)
287
- give text(result)
289
+ otherwise when registry[name] == "lamp"
290
+ set out to text(lamps.call(name, args))
291
+ otherwise
292
+ set out to text(call_tool(registry[name], args))
288
293
  recover err
289
- give "ERROR: " + err
294
+ set out to "ERROR: " + err
295
+ emit(on_event, "idle", nothing, tag)
296
+ give out
290
297
 
291
298
  export task run_turn(cfg, messages, opts, on_event)
292
299
  require net
@@ -353,7 +360,9 @@ export task run_turn(cfg, messages, opts, on_event)
353
360
  when last_step
354
361
  let why be when over_budget then "Token budget for this turn is exhausted (" + text(spent) + " of " + text(floor(budget)) + " tokens)" otherwise "Step limit reached for this turn (" + text(max_steps) + " tool calls)"
355
362
  set msgs to append(msgs, {"role": "user", "content": "[harness] " + why + ". Do NOT call tools now. Reply with a short status: what you completed, what is verified, and exactly what remains to do next. The user can say 'continue' to resume."})
363
+ emit(on_event, "busy", {"label": "pensando", "kind": "llm"}, tag)
356
364
  let r be provider.chat_retry(cfg, msgs, cat, 3)
365
+ emit(on_event, "idle", nothing, tag)
357
366
  when r["error"] != nothing
358
367
  emit(on_event, "error", r["error"], tag)
359
368
  set stopped to "error: " + r["error"]
@@ -406,16 +415,21 @@ export task run_turn(cfg, messages, opts, on_event)
406
415
  when ex["note"] != ""
407
416
  set out to out + ex["note"]
408
417
  emit(on_event, "inbox", "tope de exploración a la mitad (" + text(explore_streak) + "/" + text(cap) + " lecturas seguidas sin actuar): aviso al modelo", tag)
409
- when starts_with(out, "ERROR") or starts_with(out, "DENIED")
418
+ -- solo cuentan los errores REALES de la tool; los rechazos del harness (tope de lecturas,
419
+ -- repeticiones) ya castigan con el rechazo y no deben además cortar el turno
420
+ when (starts_with(out, "ERROR") or starts_with(out, "DENIED")) and not starts_with(out, "ERROR: exploration cap") and not starts_with(out, "ERROR: this turn already made") and not starts_with(out, "ERROR: you already called")
410
421
  set errors to errors + 1
411
422
  emit(on_event, "tool_result", {"call": tc, "output": out}, tag)
412
423
  set msgs to append(msgs, tool_result_msg(tc, out))
413
424
  when errors >= MAX_ERRORS_PER_TURN
414
425
  set msgs to append(msgs, {"role": "user", "content": "[harness] Too many tool errors this turn. Stop calling tools and report what happened."})
426
+ emit(on_event, "busy", {"label": "pensando", "kind": "llm"}, tag)
415
427
  let fin be provider.chat_retry(cfg, msgs, [], 2)
416
- set msgs to append(msgs, {"role": "assistant", "content": fin["text"], "tool_calls": []})
417
- emit(on_event, "assistant", fin["text"], tag)
418
- give {"messages": msgs, "text": fin["text"], "steps": steps, "usage": usage, "stopped": "too_many_errors"}
428
+ emit(on_event, "idle", nothing, tag)
429
+ let fin_text be strip_raw_tool_calls(fin["text"])
430
+ set msgs to append(msgs, {"role": "assistant", "content": fin_text, "tool_calls": []})
431
+ emit(on_event, "assistant", fin_text, tag)
432
+ give {"messages": msgs, "text": fin_text, "steps": steps, "usage": usage, "stopped": "too_many_errors"}
419
433
  set stopped to when spent > budget then "budget" otherwise "max_steps"
420
434
  give {"messages": msgs, "text": final_text, "steps": steps, "usage": usage, "stopped": stopped}
421
435
 
package/lib/md.syn CHANGED
@@ -5,8 +5,26 @@
5
5
  -- (backrefs \1). En strings "..." la barra va simple ("\*", "\d"): "\\d" NO es \d.
6
6
  -- Los bloques ``` se copian tal cual (sin parseo inline). La web ya renderiza markdown por su cuenta.
7
7
 
8
+ use "./ui.syn" as ui
9
+
8
10
  let ESC be decode(bytes("1b", "hex"))
9
- let WIDTH be 72
11
+ -- ancho útil: columnas de la terminal menos la sangría (mín. 40, máx. 100 para que la prosa se lea)
12
+ task width_now()
13
+ let c be ui.cols() - 4
14
+ when c < 40
15
+ give 40
16
+ when c > 100
17
+ give 100
18
+ give c
19
+
20
+ -- rutas y símbolos de código dentro de la prosa → azul (lib/x.syn, src/a/b.ts, describe_call(), foo.bar)
21
+ task paths(s, color)
22
+ when not color
23
+ give s
24
+ let out be replace_re(s, "(^|[\s(])([\w.-]*/[\w./-]{2,})", "\1" + ESC + "[34m\2" + ESC + "[0m")
25
+ set out to replace_re(out, "(^|[\s(])(\w+\.(syn|js|ts|tsx|jsx|json|md|py|rs|go|ps1|sh|html|css|toml|yml|yaml|env))(\b)", "\1" + ESC + "[34m\2" + ESC + "[0m")
26
+ set out to replace_re(out, "(^|[\s(])(\w+\(\))", "\1" + ESC + "[34m\2" + ESC + "[0m")
27
+ give out
10
28
 
11
29
  task sgr(color, code, s)
12
30
  when not color or s == ""
@@ -41,16 +59,16 @@ export task inline(s, color)
41
59
  -- `código` primero: lo de adentro no se toca
42
60
  let parts be split(s, "`")
43
61
  when length(parts) < 3
44
- give emphasis(s, color)
62
+ give paths(emphasis(s, color), color)
45
63
  let out be ""
46
64
  let idx be 0
47
65
  each p in parts
48
66
  when idx == length(parts) - 1 and idx % 2 == 1
49
- set out to out + "`" + emphasis(p, color)
67
+ set out to out + "`" + paths(emphasis(p, color), color)
50
68
  otherwise when idx % 2 == 1
51
- set out to out + sgr(color, "33", p)
69
+ set out to out + sgr(color, "36", p)
52
70
  otherwise
53
- set out to out + emphasis(p, color)
71
+ set out to out + paths(emphasis(p, color), color)
54
72
  set idx to idx + 1
55
73
  give out
56
74
 
@@ -79,19 +97,51 @@ task table_lines(rows, pre, color)
79
97
  each c in r
80
98
  when i >= length(widths)
81
99
  set widths to append(widths, 0)
82
- when length(inline(c, false)) > widths[i]
83
- set widths[i] to length(inline(c, false))
100
+ when ui.width(inline(c, false)) > widths[i]
101
+ set widths[i] to ui.width(inline(c, false))
84
102
  set i to i + 1
103
+ -- si no entra en la terminal, achicar la columna más ancha hasta que entre (mínimo 10) y envolver
104
+ let avail be width_now() - 3 * (length(widths) - 1)
105
+ let total be 0
106
+ each w in widths
107
+ set total to total + w
108
+ while total > avail
109
+ let widest be 0
110
+ let wi be 0
111
+ each w in widths
112
+ when w > widths[widest]
113
+ set widest to wi
114
+ set wi to wi + 1
115
+ when widths[widest] <= 10
116
+ set total to avail
117
+ otherwise
118
+ set widths[widest] to widths[widest] - 1
119
+ set total to total - 1
85
120
  let out be []
86
121
  let ri be 0
87
122
  each r in rows
88
- let cells be []
123
+ -- cada celda → lista de líneas envueltas a su ancho
124
+ let wrapped be []
125
+ let height be 1
89
126
  let i be 0
90
127
  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)
128
+ let ls be ui.wrap(c, widths[i], "")
129
+ set wrapped to append(wrapped, ls)
130
+ when length(ls) > height
131
+ set height to length(ls)
93
132
  set i to i + 1
94
- set out to append(out, pre + join(cells, sgr(color, "2", " │ ")))
133
+ let li be 0
134
+ while li < height
135
+ let cells be []
136
+ set i to 0
137
+ each ls in wrapped
138
+ let piece be when li < length(ls) then ls[li] otherwise ""
139
+ let vis be ui.width(inline(piece, false))
140
+ let fill be rep(" ", (when widths[i] > vis then widths[i] - vis otherwise 0))
141
+ set cells to append(cells, (when ri == 0 then sgr(color, "1", inline(piece, color)) otherwise inline(piece, color)) + fill)
142
+ set i to i + 1
143
+ set out to append(out, pre + join(cells, sgr(color, "2", " │ ")))
144
+ set li to li + 1
95
145
  when ri == 0
96
146
  let segs be []
97
147
  each w in widths
@@ -102,7 +152,7 @@ task table_lines(rows, pre, color)
102
152
 
103
153
  task fence_top(lang, color)
104
154
  let label be when lang != "" then " " + lang + " " otherwise ""
105
- give sgr(color, "2", "┌──" + label + rep("─", WIDTH - 3 - length(label)))
155
+ give sgr(color, "2", "┌──" + label + rep("─", width_now() - 3 - length(label)))
106
156
 
107
157
  export task render(md, color)
108
158
  let pre be " "
@@ -122,9 +172,9 @@ export task render(md, color)
122
172
  when in_code
123
173
  when starts_with(t, fence)
124
174
  set in_code to false
125
- set out to append(out, pre + sgr(color, "2", "└" + rep("─", WIDTH - 1)))
175
+ set out to append(out, pre + sgr(color, "2", "└" + rep("─", width_now() - 1)))
126
176
  otherwise
127
- set out to append(out, pre + sgr(color, "2", "│ ") + sgr(color, "36", line))
177
+ set out to append(out, pre + sgr(color, "2", "│ ") + ui.cut(sgr(color, "36", line), width_now() - 2))
128
178
  otherwise when starts_with(t, "```") or starts_with(t, "~~~")
129
179
  set in_code to true
130
180
  set fence to slice(t, 0, 3)
@@ -143,7 +193,7 @@ export task render(md, color)
143
193
  otherwise
144
194
  set out to append(out, pre + sgr(color, "1", title))
145
195
  otherwise when matches(t, "(-{3,}|\*{3,}|_{3,})")
146
- set out to append(out, pre + sgr(color, "2", rep("─", WIDTH)))
196
+ set out to append(out, pre + sgr(color, "2", rep("─", width_now())))
147
197
  otherwise when starts_with(t, ">")
148
198
  set out to append(out, pre + sgr(color, "2", "▎ ") + sgr(color, "3", inline(trim(slice(t, 1, length(t))), color)))
149
199
  otherwise when matches(t, "\|[\s:|-]+\|")
@@ -160,12 +210,20 @@ export task render(md, color)
160
210
  otherwise when starts_with(rest, "[x] ") or starts_with(rest, "[X] ")
161
211
  set mark to sgr(color, "32", "☑")
162
212
  set rest to slice(rest, 4, length(rest))
163
- set out to append(out, pre + ind + mark + " " + inline(rest, color))
213
+ let wrapped be ui.wrap(rest, width_now() - length(ind) - 2, "")
214
+ let wi be 0
215
+ each wl in wrapped
216
+ set out to append(out, pre + ind + (when wi == 0 then mark + " " otherwise " ") + inline(wl, color))
217
+ set wi to wi + 1
164
218
  otherwise
165
- set out to append(out, when t == "" then "" otherwise pre + inline(line, color))
219
+ when t == ""
220
+ set out to append(out, "")
221
+ otherwise
222
+ each wl in ui.wrap(trim(line), width_now(), "")
223
+ set out to append(out, pre + inline(wl, color))
166
224
  when length(table) > 0
167
225
  each l in table_lines(table, pre, color)
168
226
  set out to append(out, l)
169
227
  when in_code
170
- set out to append(out, pre + sgr(color, "2", "└" + rep("─", WIDTH - 1)))
228
+ set out to append(out, pre + sgr(color, "2", "└" + rep("─", width_now() - 1)))
171
229
  give join(out, "\n")
package/lib/prompt.syn CHANGED
@@ -35,7 +35,7 @@ Operate like a careful senior engineer: precise, honest, and economical with wor
35
35
  # Method — follow these five steps on every non-trivial request
36
36
  1. PLAN. If the task needs 3+ distinct steps (not 3 tool calls for one conceptual step), call todo FIRST and keep it current: one item in_progress at a time, marked completed the moment its work is verified — never batched, never on intent. The todo list survives context compaction; your narration does not. Skip it for a single straightforward change; when in doubt, use it.
37
37
  2. LOCATE. Find code with grep and find, never by reading directories at random. For a broad, open-ended question about how the codebase works, delegate ONE focused question to an explore sub-agent instead of reading everything yourself — the search stays out of your context. Batch independent lookups into a single turn.
38
- 3. READ ONLY WHAT YOU NEED. Read the files you will change plus the ones you must understand to change them — not the whole project. Every file you read is re-sent on every later call. Use offset/limit for long files, but avoid tiny repeated slices. Trace a symbol to its definition and usages rather than guessing its shape. Never invent files, symbols, APIs or imports — if you have not seen it in this repo, go look. HARD LIMITS enforced by the harness: after 8 read-only calls in a row without acting you get a warning, after 16 read/ls/find/grep are refused until you act; a file read twice unchanged comes back as a one-line receipt.
38
+ 3. READ ONLY WHAT YOU NEED. Read the files you will change plus the ones you must understand to change them — not the whole project. Every file you read is re-sent on every later call. Use offset/limit for long files, but avoid tiny repeated slices. Trace a symbol to its definition and usages rather than guessing its shape. Never invent files, symbols, APIs or imports — if you have not seen it in this repo, go look. HARD LIMITS enforced by the harness: after 12 read-only calls in a row without acting you get a warning, after 24 read/ls/find/grep are refused until you act; a file read twice unchanged comes back as a one-line receipt.
39
39
  4. CHANGE. Act as soon as you know enough — usually within the first 6-10 reads. Prefer edit (exact-string replacement; old_string must be unique, include surrounding context) over write; write only for new files or full rewrites. edit/write on an existing file are rejected unless you read that file in this session and it has not changed since. Minimal, targeted changes: no drive-by refactors, renames or reformatting; add every import or dependency your code needs. Do NOT print code blocks to the user as a substitute for editing — apply the change, then summarise it. If an edit fails to apply, re-read the file and retry with the exact current text; if the same region fails twice, rewrite the enclosing function with write.
40
40
  5. VERIFY AND REPORT. Run the relevant tests, linter or build with bash and confirm they pass before claiming the work is done — AFTER your edits, not before as reconnaissance. Never assume a test command: check the README, the package manifest or the project instructions. Finish with a short summary: what changed (files, path:line for specific code) and how you verified it. Do not narrate every step.
41
41
 
package/lib/ui.syn ADDED
@@ -0,0 +1,161 @@
1
+ -- lib/ui.syn — una sola paleta y medidas de texto para la terminal (md.syn, diff.syn, line.syn, chat.syn)
2
+ -- Colores: dim/blue/green/red/yellow/cyan/bold/inv (+ fondos bg_add/bg_del para diffs). color=false → texto plano.
3
+ -- width(s) ancho visible (sin escapes; emoji/CJK cuentan 2)
4
+ -- cut(s, max) corta a `max` columnas respetando escapes (y cierra el color)
5
+ -- wrap(s, max, indent) envuelve texto PLANO por palabras; las líneas siguientes llevan `indent`
6
+ -- cols() columnas de la terminal (el editor las publica en el blackboard "lampson:ui:cols"; 100 si no hay)
7
+ -- fmt_duration(secs) → "12s" · "2m 27s" · "1h 03m"
8
+
9
+ let ESC be decode(bytes("1b", "hex"))
10
+
11
+ export task sgr(color, code, s)
12
+ when not color or s == ""
13
+ give s
14
+ give ESC + "[" + code + "m" + s + ESC + "[0m"
15
+
16
+ export task dim(color, s)
17
+ give sgr(color, "2", s)
18
+ export task bold(color, s)
19
+ give sgr(color, "1", s)
20
+ export task inv(color, s)
21
+ give sgr(color, "7", s)
22
+ export task blue(color, s)
23
+ give sgr(color, "34", s)
24
+ export task cyan(color, s)
25
+ give sgr(color, "36", s)
26
+ export task green(color, s)
27
+ give sgr(color, "32", s)
28
+ export task red(color, s)
29
+ give sgr(color, "31", s)
30
+ export task yellow(color, s)
31
+ give sgr(color, "33", s)
32
+ -- fondos para diffs (256 colores: verde y rojo oscuros, legibles en tema claro y oscuro)
33
+ export task bg_add(color, s)
34
+ give sgr(color, "48;5;22", s)
35
+ export task bg_del(color, s)
36
+ give sgr(color, "48;5;52", s)
37
+
38
+ -- ---------- medidas ----------
39
+
40
+ -- punto de código de UN carácter a partir de sus bytes UTF-8
41
+ task codepoint(ch)
42
+ let b be bytes(ch)
43
+ let n be length(b)
44
+ when n == 1
45
+ give b[0]
46
+ when n == 2
47
+ give (b[0] - 192) * 64 + (b[1] - 128)
48
+ when n == 3
49
+ give (b[0] - 224) * 4096 + (b[1] - 128) * 64 + (b[2] - 128)
50
+ give (b[0] - 240) * 262144 + (b[1] - 128) * 4096 + (b[2] - 128) * 64 + (b[3] - 128)
51
+
52
+ -- ancho de un carácter en celdas: emoji, CJK y símbolos "wide" ocupan 2
53
+ task char_width(ch)
54
+ let cp be codepoint(ch)
55
+ when cp < 4352
56
+ give 1
57
+ when cp >= 127744 and cp <= 129791
58
+ give 2
59
+ when cp >= 4352 and cp <= 4447
60
+ give 2
61
+ when cp >= 11904 and cp <= 42191
62
+ give 2
63
+ when cp >= 44032 and cp <= 55203
64
+ give 2
65
+ when cp >= 63744 and cp <= 64255
66
+ give 2
67
+ when cp >= 65040 and cp <= 65135
68
+ give 2
69
+ when cp >= 65280 and cp <= 65376
70
+ give 2
71
+ when cp >= 9800 and cp <= 9811
72
+ give 2
73
+ when cp == 9888 or cp == 9889 or cp == 9989 or cp == 10060 or cp == 11093
74
+ give 2
75
+ give 1
76
+
77
+ export task width(s)
78
+ let t be strip_ansi(s)
79
+ let w be 0
80
+ let i be 0
81
+ let n be length(t)
82
+ while i < n
83
+ set w to w + char_width(slice(t, i, i + 1))
84
+ set i to i + 1
85
+ give w
86
+
87
+ -- corta a `max` celdas visibles, saltando secuencias ANSI enteras; añade "…" si cortó y cierra el color
88
+ export task cut(s, max)
89
+ when width(s) <= max
90
+ give s
91
+ let out be ""
92
+ let w be 0
93
+ let i be 0
94
+ let n be length(s)
95
+ let limit be max - 1
96
+ while i < n and w < limit
97
+ let ch be slice(s, i, i + 1)
98
+ when ch == ESC
99
+ -- copiar la secuencia CSI completa: ESC [ … letra
100
+ let j be i + 1
101
+ while j < n and not matches(slice(s, j, j + 1), "[A-Za-z]")
102
+ set j to j + 1
103
+ set out to out + slice(s, i, j + 1)
104
+ set i to j + 1
105
+ otherwise
106
+ let cw be char_width(ch)
107
+ when w + cw <= limit
108
+ set out to out + ch
109
+ set w to w + cw
110
+ set i to i + 1
111
+ otherwise
112
+ set i to n
113
+ give out + ESC + "[0m" + "…"
114
+
115
+ -- envuelve texto plano (sin escapes) por palabras; give lista de líneas (sin indent en la primera)
116
+ export task wrap(s, max, indent)
117
+ when max <= 10 or length(s) <= max
118
+ give [s]
119
+ let words be split(s, " ")
120
+ let lines be []
121
+ let cur be ""
122
+ let curw be 0
123
+ each w in words
124
+ let ww be width(w)
125
+ let room be when length(lines) == 0 then max otherwise max - length(indent)
126
+ when curw == 0
127
+ set cur to w
128
+ set curw to ww
129
+ otherwise when curw + 1 + ww <= room
130
+ set cur to cur + " " + w
131
+ set curw to curw + 1 + ww
132
+ otherwise
133
+ set lines to append(lines, (when length(lines) == 0 then "" otherwise indent) + cur)
134
+ set cur to w
135
+ set curw to ww
136
+ set lines to append(lines, (when length(lines) == 0 then "" otherwise indent) + cur)
137
+ give lines
138
+
139
+ -- ---------- terminal ----------
140
+
141
+ export task cols()
142
+ observe "lampson:ui:cols" as c
143
+ when c == nothing
144
+ give 100
145
+ give c
146
+
147
+ export task set_cols(n)
148
+ share n as "lampson:ui:cols"
149
+ give n
150
+
151
+ export task fmt_duration(secs)
152
+ let s be floor(secs)
153
+ when s < 60
154
+ give text(s) + "s"
155
+ when s < 3600
156
+ let m be floor(s / 60)
157
+ let r be s - m * 60
158
+ give text(m) + "m " + (when r < 10 then "0" otherwise "") + text(r) + "s"
159
+ let h be floor(s / 3600)
160
+ let m2 be floor((s - h * 3600) / 60)
161
+ give text(h) + "h " + (when m2 < 10 then "0" otherwise "") + text(m2) + "m"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": {
package/public/index.html CHANGED
@@ -145,6 +145,10 @@
145
145
  .sec h2 { font:400 10.5px/1.35 var(--mono); text-transform:uppercase; letter-spacing:.1em; color:var(--ink-3); margin:0; padding:12px 16px 8px; display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none; flex:none; }
146
146
  .sec h2:hover { color:var(--ink); }
147
147
  .sec .caret { width:9px; font-size:9px; color:var(--ink-3); }
148
+ .sec h2 .h2act { margin-left:auto; border:0; background:none; color:var(--ink-3); font:inherit; font-size:12px; cursor:pointer; padding:0 2px; line-height:1; opacity:.6; }
149
+ .sec h2 .h2act:hover { opacity:1; color:var(--accent); }
150
+ .sec h2 .h2act.spin { animation: h2spin .6s linear; }
151
+ @keyframes h2spin { to { transform: rotate(360deg); } }
148
152
  .sec .cnt { margin-left:auto; letter-spacing:0; text-transform:none; }
149
153
  .sec .body { display:none; overflow:auto; min-height:0; padding:0 10px 10px 16px; }
150
154
  .sec.open .body { display:block; }
@@ -370,7 +374,7 @@
370
374
  </header>
371
375
  <aside>
372
376
  <section class="sec grow open" data-sec="tree">
373
- <h2><span class="caret">▾</span>Archivos</h2>
377
+ <h2><span class="caret">▾</span>Archivos<button class="h2act" id="tree-reload" title="recargar el árbol">↻</button></h2>
374
378
  <div class="body" id="tree"></div>
375
379
  </section>
376
380
  <section class="sec" data-sec="procs">
@@ -700,6 +704,11 @@ function autoSec(name, hasContent) {
700
704
  const pref = localStorage.getItem('lampson.sec.' + name);
701
705
  setSec(name, pref === null ? hasContent : pref === '1');
702
706
  }
707
+ // el árbol se refresca solo cuando una tool pudo crear/borrar archivos (write/edit/bash/process/delegate/lamp),
708
+ // con un pequeño debounce para no repintar 20 veces en un turno con 20 writes; ↻ lo fuerza a mano
709
+ let treeTimer = null;
710
+ function treeChanged() { clearTimeout(treeTimer); treeTimer = setTimeout(loadTree, 400); }
711
+ $('#tree-reload').onclick = e => { e.stopPropagation(); const b = e.currentTarget; b.classList.remove('spin'); void b.offsetWidth; b.classList.add('spin'); loadTree(); };
703
712
  document.querySelectorAll('.sec h2').forEach(h => h.onclick = () => {
704
713
  const s = h.closest('.sec'); const open = !s.classList.contains('open');
705
714
  localStorage.setItem('lampson.sec.' + s.dataset.sec, open ? '1' : '0');
@@ -1244,6 +1253,7 @@ function handle(chunk, thinking) {
1244
1253
  else if (k === 'tool_call') { thinking.querySelector('span:last-child').textContent = 'ejecutando ' + esc(d.name) + '…'; pending = add('step', `<span class="ic">⚙</span>${cmdHtml(describe(d))}`); wireMore(pending); if (d.name === 'process') setTimeout(loadProcs, 2500); if (d.name === 'delegate') { setTimeout(loadAgents, 800); setTimeout(loadAgents, 4000); } if (d.name === 'todo') setTimeout(loadTodo, 300); if (d.name === 'lamp') setTimeout(loadLamps, 300); if (d.name === 'lsp') setTimeout(loadLsp, 1500); }
1245
1254
  else if (k === 'inbox') { add('meta', '✉ ' + esc(String(d).split('\n')[0].slice(0, 160))); loadAgents(); }
1246
1255
  else if (k === 'tool_result') { const out = String(d.output); const bad = /^(ERROR|DENIED)/.test(out);
1256
+ if (!bad && d.call && /^(write|edit|bash|process|delegate|lamp|skill)$/.test(d.call.name)) treeChanged();
1247
1257
  // lámpara recién creada: avisar en el chat con un acceso directo al switch (el modelo no sabe cómo se enciende en esta UI)
1248
1258
  if (d.call && d.call.name === 'lamp' && /^lamp '([^']+)' created/.test(out)) { const nm = out.match(/^lamp '([^']+)' created/)[1]; const m = add('meta', `☼ lámpara <b>${esc(nm)}</b> creada — está apagada: <a href="#" class="lampgo">encenderla en «lámparas»</a>`); m.querySelector('.lampgo').onclick = (ev) => { ev.preventDefault(); $('#lamps').click(); }; $('#lamps').classList.add('new'); setTimeout(() => $('#lamps').classList.remove('new'), 4000); } const el = pending || add('step', '<span class="ic">→</span>'); pending = null; thinking.querySelector('span:last-child').textContent = 'pensando…'; const ic = el.querySelector('.ic'); if (ic) { ic.textContent = bad ? '✗' : '✓'; ic.className = 'ic ' + (bad ? 'bad' : 'ok'); } el.insertAdjacentHTML('beforeend', `<details><summary class="${bad ? 'bad' : ''}">${esc(out.split('\n')[0].slice(0, 140))} <span style="color:var(--ink-3)">(${out.length} chars)</span></summary><pre>${esc(out)}</pre></details>`); }
1249
1259
  else if (k === 'approval_request') { const el = add('approval', `<div class="card"><div class="why">⚠ ${esc(d.why)}</div><code>${esc(describe({name: d.name, args: d.args}))}</code><div class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></div></div>`); el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await fetch('/api/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: d.id, decision: b.dataset.ok === '1' }) }); }); el.dataset.id = d.id; }