ScuttleBot

feat: structured message metadata for rich rendering (#57) Add optional Meta field to bridge messages so the web UI can render structured content (tool calls, diffs, errors, status cards) while IRC sees only plain text. - bridge: add Meta struct {Type, Data} to Message, add SendWithMeta - api: accept optional meta in POST /messages, flows through SSE - sessionrelay: add PostWithMeta/PostToWithMeta to Connector interface - claude-relay: emit tool_result metadata alongside plain text summaries - codex-relay: emit tool_result metadata for function_call/custom_tool - ui: meta renderers for tool_result/diff/error/status/artifact/image, expandable blocks on click, rich/text mode toggle in chat topbar

lmata 2026-04-04 04:53 trunk
Commit 3d42643e671f2cdf42ca7808e595637190430cd9ac2ba8544cc567cb12792db5
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -86,10 +86,16 @@
8686
TargetCWD string
8787
Args []string
8888
}
8989
9090
type message = sessionrelay.Message
91
+
92
+// mirrorLine is a single line of relay output with optional structured metadata.
93
+type mirrorLine struct {
94
+ Text string
95
+ Meta json.RawMessage // nil for plain text lines
96
+}
9197
9298
type relayState struct {
9399
mu sync.RWMutex
94100
lastBusy time.Time
95101
}
@@ -279,16 +285,20 @@
279285
}
280286
// Session not found yet — wait and retry instead of giving up.
281287
time.Sleep(10 * time.Second)
282288
continue
283289
}
284
- if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
285
- for _, line := range splitMirrorText(text) {
290
+ if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
291
+ for _, line := range splitMirrorText(ml.Text) {
286292
if line == "" {
287293
continue
288294
}
289
- _ = relay.Post(ctx, line)
295
+ if len(ml.Meta) > 0 {
296
+ _ = relay.PostWithMeta(ctx, line, ml.Meta)
297
+ } else {
298
+ _ = relay.Post(ctx, line)
299
+ }
290300
}
291301
}); err != nil && ctx.Err() == nil {
292302
// Tail lost — retry discovery.
293303
time.Sleep(5 * time.Second)
294304
continue
@@ -400,11 +410,11 @@
400410
return entry.CWD == targetCWD
401411
}
402412
return false
403413
}
404414
405
-func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
415
+func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
406416
file, err := os.Open(path)
407417
if err != nil {
408418
return err
409419
}
410420
defer file.Close()
@@ -415,13 +425,13 @@
415425
416426
reader := bufio.NewReader(file)
417427
for {
418428
line, err := reader.ReadBytes('\n')
419429
if len(line) > 0 {
420
- for _, text := range sessionMessages(line, mirrorReasoning) {
421
- if text != "" {
422
- emit(text)
430
+ for _, ml := range sessionMessages(line, mirrorReasoning) {
431
+ if ml.Text != "" {
432
+ emit(ml)
423433
}
424434
}
425435
}
426436
if err == nil {
427437
continue
@@ -436,46 +446,104 @@
436446
}
437447
return err
438448
}
439449
}
440450
441
-// sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
451
+// sessionMessages parses a Claude Code JSONL line and returns mirror lines
452
+// with optional structured metadata for rich rendering in the web UI.
442453
// If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
443
-func sessionMessages(line []byte, mirrorReasoning bool) []string {
454
+func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
444455
var entry claudeSessionEntry
445456
if err := json.Unmarshal(line, &entry); err != nil {
446457
return nil
447458
}
448459
if entry.Type != "assistant" || entry.Message.Role != "assistant" {
449460
return nil
450461
}
451462
452
- var out []string
463
+ var out []mirrorLine
453464
for _, block := range entry.Message.Content {
454465
switch block.Type {
455466
case "text":
456467
for _, l := range splitMirrorText(block.Text) {
457468
if l != "" {
458
- out = append(out, sanitizeSecrets(l))
469
+ out = append(out, mirrorLine{Text: sanitizeSecrets(l)})
459470
}
460471
}
461472
case "tool_use":
462473
if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
463
- out = append(out, msg)
474
+ out = append(out, mirrorLine{
475
+ Text: msg,
476
+ Meta: toolMeta(block.Name, block.Input),
477
+ })
464478
}
465479
case "thinking":
466480
if mirrorReasoning {
467481
for _, l := range splitMirrorText(block.Text) {
468482
if l != "" {
469
- out = append(out, "💭 "+sanitizeSecrets(l))
483
+ out = append(out, mirrorLine{Text: "💭 " + sanitizeSecrets(l)})
470484
}
471485
}
472486
}
473487
}
474488
}
475489
return out
476490
}
491
+
492
+// toolMeta builds a JSON metadata envelope for a tool_use block.
493
+func toolMeta(name string, inputRaw json.RawMessage) json.RawMessage {
494
+ var input map[string]json.RawMessage
495
+ _ = json.Unmarshal(inputRaw, &input)
496
+
497
+ data := map[string]string{"tool": name}
498
+
499
+ str := func(key string) string {
500
+ v, ok := input[key]
501
+ if !ok {
502
+ return ""
503
+ }
504
+ var s string
505
+ if err := json.Unmarshal(v, &s); err != nil {
506
+ return strings.Trim(string(v), `"`)
507
+ }
508
+ return s
509
+ }
510
+
511
+ switch name {
512
+ case "Bash":
513
+ if cmd := str("command"); cmd != "" {
514
+ data["command"] = sanitizeSecrets(cmd)
515
+ }
516
+ case "Edit", "Write", "Read":
517
+ if p := str("file_path"); p != "" {
518
+ data["file"] = p
519
+ }
520
+ case "Glob":
521
+ if p := str("pattern"); p != "" {
522
+ data["pattern"] = p
523
+ }
524
+ case "Grep":
525
+ if p := str("pattern"); p != "" {
526
+ data["pattern"] = p
527
+ }
528
+ case "WebFetch":
529
+ if u := str("url"); u != "" {
530
+ data["url"] = sanitizeSecrets(u)
531
+ }
532
+ case "WebSearch":
533
+ if q := str("query"); q != "" {
534
+ data["query"] = q
535
+ }
536
+ }
537
+
538
+ meta := map[string]any{
539
+ "type": "tool_result",
540
+ "data": data,
541
+ }
542
+ b, _ := json.Marshal(meta)
543
+ return b
544
+}
477545
478546
func summarizeToolUse(name string, inputRaw json.RawMessage) string {
479547
var input map[string]json.RawMessage
480548
_ = json.Unmarshal(inputRaw, &input)
481549
482550
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -86,10 +86,16 @@
86 TargetCWD string
87 Args []string
88 }
89
90 type message = sessionrelay.Message
 
 
 
 
 
 
91
92 type relayState struct {
93 mu sync.RWMutex
94 lastBusy time.Time
95 }
@@ -279,16 +285,20 @@
279 }
280 // Session not found yet — wait and retry instead of giving up.
281 time.Sleep(10 * time.Second)
282 continue
283 }
284 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
285 for _, line := range splitMirrorText(text) {
286 if line == "" {
287 continue
288 }
289 _ = relay.Post(ctx, line)
 
 
 
 
290 }
291 }); err != nil && ctx.Err() == nil {
292 // Tail lost — retry discovery.
293 time.Sleep(5 * time.Second)
294 continue
@@ -400,11 +410,11 @@
400 return entry.CWD == targetCWD
401 }
402 return false
403 }
404
405 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
406 file, err := os.Open(path)
407 if err != nil {
408 return err
409 }
410 defer file.Close()
@@ -415,13 +425,13 @@
415
416 reader := bufio.NewReader(file)
417 for {
418 line, err := reader.ReadBytes('\n')
419 if len(line) > 0 {
420 for _, text := range sessionMessages(line, mirrorReasoning) {
421 if text != "" {
422 emit(text)
423 }
424 }
425 }
426 if err == nil {
427 continue
@@ -436,46 +446,104 @@
436 }
437 return err
438 }
439 }
440
441 // sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
 
442 // If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
443 func sessionMessages(line []byte, mirrorReasoning bool) []string {
444 var entry claudeSessionEntry
445 if err := json.Unmarshal(line, &entry); err != nil {
446 return nil
447 }
448 if entry.Type != "assistant" || entry.Message.Role != "assistant" {
449 return nil
450 }
451
452 var out []string
453 for _, block := range entry.Message.Content {
454 switch block.Type {
455 case "text":
456 for _, l := range splitMirrorText(block.Text) {
457 if l != "" {
458 out = append(out, sanitizeSecrets(l))
459 }
460 }
461 case "tool_use":
462 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
463 out = append(out, msg)
 
 
 
464 }
465 case "thinking":
466 if mirrorReasoning {
467 for _, l := range splitMirrorText(block.Text) {
468 if l != "" {
469 out = append(out, "💭 "+sanitizeSecrets(l))
470 }
471 }
472 }
473 }
474 }
475 return out
476 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
478 func summarizeToolUse(name string, inputRaw json.RawMessage) string {
479 var input map[string]json.RawMessage
480 _ = json.Unmarshal(inputRaw, &input)
481
482
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -86,10 +86,16 @@
86 TargetCWD string
87 Args []string
88 }
89
90 type message = sessionrelay.Message
91
92 // mirrorLine is a single line of relay output with optional structured metadata.
93 type mirrorLine struct {
94 Text string
95 Meta json.RawMessage // nil for plain text lines
96 }
97
98 type relayState struct {
99 mu sync.RWMutex
100 lastBusy time.Time
101 }
@@ -279,16 +285,20 @@
285 }
286 // Session not found yet — wait and retry instead of giving up.
287 time.Sleep(10 * time.Second)
288 continue
289 }
290 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
291 for _, line := range splitMirrorText(ml.Text) {
292 if line == "" {
293 continue
294 }
295 if len(ml.Meta) > 0 {
296 _ = relay.PostWithMeta(ctx, line, ml.Meta)
297 } else {
298 _ = relay.Post(ctx, line)
299 }
300 }
301 }); err != nil && ctx.Err() == nil {
302 // Tail lost — retry discovery.
303 time.Sleep(5 * time.Second)
304 continue
@@ -400,11 +410,11 @@
410 return entry.CWD == targetCWD
411 }
412 return false
413 }
414
415 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
416 file, err := os.Open(path)
417 if err != nil {
418 return err
419 }
420 defer file.Close()
@@ -415,13 +425,13 @@
425
426 reader := bufio.NewReader(file)
427 for {
428 line, err := reader.ReadBytes('\n')
429 if len(line) > 0 {
430 for _, ml := range sessionMessages(line, mirrorReasoning) {
431 if ml.Text != "" {
432 emit(ml)
433 }
434 }
435 }
436 if err == nil {
437 continue
@@ -436,46 +446,104 @@
446 }
447 return err
448 }
449 }
450
451 // sessionMessages parses a Claude Code JSONL line and returns mirror lines
452 // with optional structured metadata for rich rendering in the web UI.
453 // If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
454 func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
455 var entry claudeSessionEntry
456 if err := json.Unmarshal(line, &entry); err != nil {
457 return nil
458 }
459 if entry.Type != "assistant" || entry.Message.Role != "assistant" {
460 return nil
461 }
462
463 var out []mirrorLine
464 for _, block := range entry.Message.Content {
465 switch block.Type {
466 case "text":
467 for _, l := range splitMirrorText(block.Text) {
468 if l != "" {
469 out = append(out, mirrorLine{Text: sanitizeSecrets(l)})
470 }
471 }
472 case "tool_use":
473 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
474 out = append(out, mirrorLine{
475 Text: msg,
476 Meta: toolMeta(block.Name, block.Input),
477 })
478 }
479 case "thinking":
480 if mirrorReasoning {
481 for _, l := range splitMirrorText(block.Text) {
482 if l != "" {
483 out = append(out, mirrorLine{Text: "💭 " + sanitizeSecrets(l)})
484 }
485 }
486 }
487 }
488 }
489 return out
490 }
491
492 // toolMeta builds a JSON metadata envelope for a tool_use block.
493 func toolMeta(name string, inputRaw json.RawMessage) json.RawMessage {
494 var input map[string]json.RawMessage
495 _ = json.Unmarshal(inputRaw, &input)
496
497 data := map[string]string{"tool": name}
498
499 str := func(key string) string {
500 v, ok := input[key]
501 if !ok {
502 return ""
503 }
504 var s string
505 if err := json.Unmarshal(v, &s); err != nil {
506 return strings.Trim(string(v), `"`)
507 }
508 return s
509 }
510
511 switch name {
512 case "Bash":
513 if cmd := str("command"); cmd != "" {
514 data["command"] = sanitizeSecrets(cmd)
515 }
516 case "Edit", "Write", "Read":
517 if p := str("file_path"); p != "" {
518 data["file"] = p
519 }
520 case "Glob":
521 if p := str("pattern"); p != "" {
522 data["pattern"] = p
523 }
524 case "Grep":
525 if p := str("pattern"); p != "" {
526 data["pattern"] = p
527 }
528 case "WebFetch":
529 if u := str("url"); u != "" {
530 data["url"] = sanitizeSecrets(u)
531 }
532 case "WebSearch":
533 if q := str("query"); q != "" {
534 data["query"] = q
535 }
536 }
537
538 meta := map[string]any{
539 "type": "tool_result",
540 "data": data,
541 }
542 b, _ := json.Marshal(meta)
543 return b
544 }
545
546 func summarizeToolUse(name string, inputRaw json.RawMessage) string {
547 var input map[string]json.RawMessage
548 _ = json.Unmarshal(inputRaw, &input)
549
550
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -54,15 +54,15 @@
5454
func TestSessionMessagesThinking(t *testing.T) {
5555
line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
5656
5757
// thinking off — only text
5858
got := sessionMessages(line, false)
59
- if len(got) != 1 || got[0] != "final answer" {
59
+ if len(got) != 1 || got[0].Text != "final answer" {
6060
t.Fatalf("mirrorReasoning=false: got %#v", got)
6161
}
6262
6363
// thinking on — both, thinking prefixed
6464
got = sessionMessages(line, true)
65
- if len(got) != 2 || got[0] != "💭 reasoning here" || got[1] != "final answer" {
65
+ if len(got) != 2 || got[0].Text != "💭 reasoning here" || got[1].Text != "final answer" {
6666
t.Fatalf("mirrorReasoning=true: got %#v", got)
6767
}
6868
}
6969
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -54,15 +54,15 @@
54 func TestSessionMessagesThinking(t *testing.T) {
55 line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
56
57 // thinking off — only text
58 got := sessionMessages(line, false)
59 if len(got) != 1 || got[0] != "final answer" {
60 t.Fatalf("mirrorReasoning=false: got %#v", got)
61 }
62
63 // thinking on — both, thinking prefixed
64 got = sessionMessages(line, true)
65 if len(got) != 2 || got[0] != "💭 reasoning here" || got[1] != "final answer" {
66 t.Fatalf("mirrorReasoning=true: got %#v", got)
67 }
68 }
69
--- cmd/claude-relay/main_test.go
+++ cmd/claude-relay/main_test.go
@@ -54,15 +54,15 @@
54 func TestSessionMessagesThinking(t *testing.T) {
55 line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","text":"reasoning here"},{"type":"text","text":"final answer"}]}}`)
56
57 // thinking off — only text
58 got := sessionMessages(line, false)
59 if len(got) != 1 || got[0].Text != "final answer" {
60 t.Fatalf("mirrorReasoning=false: got %#v", got)
61 }
62
63 // thinking on — both, thinking prefixed
64 got = sessionMessages(line, true)
65 if len(got) != 2 || got[0].Text != "💭 reasoning here" || got[1].Text != "final answer" {
66 t.Fatalf("mirrorReasoning=true: got %#v", got)
67 }
68 }
69
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -86,10 +86,16 @@
8686
TargetCWD string
8787
Args []string
8888
}
8989
9090
type message = sessionrelay.Message
91
+
92
+// mirrorLine is a single line of relay output with optional structured metadata.
93
+type mirrorLine struct {
94
+ Text string
95
+ Meta json.RawMessage
96
+}
9197
9298
type relayState struct {
9399
mu sync.RWMutex
94100
lastBusy time.Time
95101
}
@@ -809,16 +815,20 @@
809815
return
810816
}
811817
time.Sleep(10 * time.Second)
812818
continue
813819
}
814
- if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
815
- for _, line := range splitMirrorText(text) {
820
+ if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
821
+ for _, line := range splitMirrorText(ml.Text) {
816822
if line == "" {
817823
continue
818824
}
819
- _ = relay.Post(ctx, line)
825
+ if len(ml.Meta) > 0 {
826
+ _ = relay.PostWithMeta(ctx, line, ml.Meta)
827
+ } else {
828
+ _ = relay.Post(ctx, line)
829
+ }
820830
}
821831
}); err != nil && ctx.Err() == nil {
822832
time.Sleep(5 * time.Second)
823833
continue
824834
}
@@ -862,11 +872,11 @@
862872
case <-ticker.C:
863873
}
864874
}
865875
}
866876
867
-func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
877
+func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
868878
file, err := os.Open(path)
869879
if err != nil {
870880
return err
871881
}
872882
defer file.Close()
@@ -877,13 +887,13 @@
877887
878888
reader := bufio.NewReader(file)
879889
for {
880890
line, err := reader.ReadBytes('\n')
881891
if len(line) > 0 {
882
- for _, text := range sessionMessages(line, mirrorReasoning) {
883
- if text != "" {
884
- emit(text)
892
+ for _, ml := range sessionMessages(line, mirrorReasoning) {
893
+ if ml.Text != "" {
894
+ emit(ml)
885895
}
886896
}
887897
}
888898
if err == nil {
889899
continue
@@ -898,11 +908,11 @@
898908
}
899909
return err
900910
}
901911
}
902912
903
-func sessionMessages(line []byte, mirrorReasoning bool) []string {
913
+func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
904914
var env sessionEnvelope
905915
if err := json.Unmarshal(line, &env); err != nil {
906916
return nil
907917
}
908918
if env.Type != "response_item" {
@@ -915,24 +925,46 @@
915925
}
916926
917927
switch payload.Type {
918928
case "function_call":
919929
if msg := summarizeFunctionCall(payload.Name, payload.Arguments); msg != "" {
920
- return []string{msg}
930
+ meta := codexToolMeta(payload.Name, payload.Arguments)
931
+ return []mirrorLine{{Text: msg, Meta: meta}}
921932
}
922933
case "custom_tool_call":
923934
if msg := summarizeCustomToolCall(payload.Name, payload.Input); msg != "" {
924
- return []string{msg}
935
+ meta := codexToolMeta(payload.Name, payload.Input)
936
+ return []mirrorLine{{Text: msg, Meta: meta}}
925937
}
926938
case "message":
927939
if payload.Role != "assistant" {
928940
return nil
929941
}
930942
return flattenAssistantContent(payload.Content, mirrorReasoning)
931943
}
932944
return nil
933945
}
946
+
947
+// codexToolMeta builds a JSON metadata envelope for a Codex tool call.
948
+func codexToolMeta(name, argsJSON string) json.RawMessage {
949
+ data := map[string]string{"tool": name}
950
+ switch name {
951
+ case "exec_command":
952
+ var args execCommandArgs
953
+ if err := json.Unmarshal([]byte(argsJSON), &args); err == nil && args.Cmd != "" {
954
+ data["command"] = sanitizeSecrets(args.Cmd)
955
+ }
956
+ case "apply_patch":
957
+ files := patchTargets(argsJSON)
958
+ if len(files) > 0 {
959
+ data["file"] = files[0]
960
+ }
961
+ }
962
+ meta := map[string]any{"type": "tool_result", "data": data}
963
+ b, _ := json.Marshal(meta)
964
+ return b
965
+}
934966
935967
func summarizeFunctionCall(name, argsJSON string) string {
936968
switch name {
937969
case "exec_command":
938970
var args execCommandArgs
@@ -975,25 +1007,25 @@
9751007
}
9761008
return name
9771009
}
9781010
}
9791011
980
-func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []string {
981
- var lines []string
1012
+func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []mirrorLine {
1013
+ var lines []mirrorLine
9821014
for _, item := range content {
9831015
switch item.Type {
9841016
case "output_text":
9851017
for _, line := range splitMirrorText(item.Text) {
9861018
if line != "" {
987
- lines = append(lines, line)
1019
+ lines = append(lines, mirrorLine{Text: line})
9881020
}
9891021
}
9901022
case "reasoning":
9911023
if mirrorReasoning {
9921024
for _, line := range splitMirrorText(item.Text) {
9931025
if line != "" {
994
- lines = append(lines, "💭 "+line)
1026
+ lines = append(lines, mirrorLine{Text: "💭 " + line})
9951027
}
9961028
}
9971029
}
9981030
}
9991031
}
10001032
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -86,10 +86,16 @@
86 TargetCWD string
87 Args []string
88 }
89
90 type message = sessionrelay.Message
 
 
 
 
 
 
91
92 type relayState struct {
93 mu sync.RWMutex
94 lastBusy time.Time
95 }
@@ -809,16 +815,20 @@
809 return
810 }
811 time.Sleep(10 * time.Second)
812 continue
813 }
814 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(text string) {
815 for _, line := range splitMirrorText(text) {
816 if line == "" {
817 continue
818 }
819 _ = relay.Post(ctx, line)
 
 
 
 
820 }
821 }); err != nil && ctx.Err() == nil {
822 time.Sleep(5 * time.Second)
823 continue
824 }
@@ -862,11 +872,11 @@
862 case <-ticker.C:
863 }
864 }
865 }
866
867 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(string)) error {
868 file, err := os.Open(path)
869 if err != nil {
870 return err
871 }
872 defer file.Close()
@@ -877,13 +887,13 @@
877
878 reader := bufio.NewReader(file)
879 for {
880 line, err := reader.ReadBytes('\n')
881 if len(line) > 0 {
882 for _, text := range sessionMessages(line, mirrorReasoning) {
883 if text != "" {
884 emit(text)
885 }
886 }
887 }
888 if err == nil {
889 continue
@@ -898,11 +908,11 @@
898 }
899 return err
900 }
901 }
902
903 func sessionMessages(line []byte, mirrorReasoning bool) []string {
904 var env sessionEnvelope
905 if err := json.Unmarshal(line, &env); err != nil {
906 return nil
907 }
908 if env.Type != "response_item" {
@@ -915,24 +925,46 @@
915 }
916
917 switch payload.Type {
918 case "function_call":
919 if msg := summarizeFunctionCall(payload.Name, payload.Arguments); msg != "" {
920 return []string{msg}
 
921 }
922 case "custom_tool_call":
923 if msg := summarizeCustomToolCall(payload.Name, payload.Input); msg != "" {
924 return []string{msg}
 
925 }
926 case "message":
927 if payload.Role != "assistant" {
928 return nil
929 }
930 return flattenAssistantContent(payload.Content, mirrorReasoning)
931 }
932 return nil
933 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
935 func summarizeFunctionCall(name, argsJSON string) string {
936 switch name {
937 case "exec_command":
938 var args execCommandArgs
@@ -975,25 +1007,25 @@
975 }
976 return name
977 }
978 }
979
980 func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []string {
981 var lines []string
982 for _, item := range content {
983 switch item.Type {
984 case "output_text":
985 for _, line := range splitMirrorText(item.Text) {
986 if line != "" {
987 lines = append(lines, line)
988 }
989 }
990 case "reasoning":
991 if mirrorReasoning {
992 for _, line := range splitMirrorText(item.Text) {
993 if line != "" {
994 lines = append(lines, "💭 "+line)
995 }
996 }
997 }
998 }
999 }
1000
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -86,10 +86,16 @@
86 TargetCWD string
87 Args []string
88 }
89
90 type message = sessionrelay.Message
91
92 // mirrorLine is a single line of relay output with optional structured metadata.
93 type mirrorLine struct {
94 Text string
95 Meta json.RawMessage
96 }
97
98 type relayState struct {
99 mu sync.RWMutex
100 lastBusy time.Time
101 }
@@ -809,16 +815,20 @@
815 return
816 }
817 time.Sleep(10 * time.Second)
818 continue
819 }
820 if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
821 for _, line := range splitMirrorText(ml.Text) {
822 if line == "" {
823 continue
824 }
825 if len(ml.Meta) > 0 {
826 _ = relay.PostWithMeta(ctx, line, ml.Meta)
827 } else {
828 _ = relay.Post(ctx, line)
829 }
830 }
831 }); err != nil && ctx.Err() == nil {
832 time.Sleep(5 * time.Second)
833 continue
834 }
@@ -862,11 +872,11 @@
872 case <-ticker.C:
873 }
874 }
875 }
876
877 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
878 file, err := os.Open(path)
879 if err != nil {
880 return err
881 }
882 defer file.Close()
@@ -877,13 +887,13 @@
887
888 reader := bufio.NewReader(file)
889 for {
890 line, err := reader.ReadBytes('\n')
891 if len(line) > 0 {
892 for _, ml := range sessionMessages(line, mirrorReasoning) {
893 if ml.Text != "" {
894 emit(ml)
895 }
896 }
897 }
898 if err == nil {
899 continue
@@ -898,11 +908,11 @@
908 }
909 return err
910 }
911 }
912
913 func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
914 var env sessionEnvelope
915 if err := json.Unmarshal(line, &env); err != nil {
916 return nil
917 }
918 if env.Type != "response_item" {
@@ -915,24 +925,46 @@
925 }
926
927 switch payload.Type {
928 case "function_call":
929 if msg := summarizeFunctionCall(payload.Name, payload.Arguments); msg != "" {
930 meta := codexToolMeta(payload.Name, payload.Arguments)
931 return []mirrorLine{{Text: msg, Meta: meta}}
932 }
933 case "custom_tool_call":
934 if msg := summarizeCustomToolCall(payload.Name, payload.Input); msg != "" {
935 meta := codexToolMeta(payload.Name, payload.Input)
936 return []mirrorLine{{Text: msg, Meta: meta}}
937 }
938 case "message":
939 if payload.Role != "assistant" {
940 return nil
941 }
942 return flattenAssistantContent(payload.Content, mirrorReasoning)
943 }
944 return nil
945 }
946
947 // codexToolMeta builds a JSON metadata envelope for a Codex tool call.
948 func codexToolMeta(name, argsJSON string) json.RawMessage {
949 data := map[string]string{"tool": name}
950 switch name {
951 case "exec_command":
952 var args execCommandArgs
953 if err := json.Unmarshal([]byte(argsJSON), &args); err == nil && args.Cmd != "" {
954 data["command"] = sanitizeSecrets(args.Cmd)
955 }
956 case "apply_patch":
957 files := patchTargets(argsJSON)
958 if len(files) > 0 {
959 data["file"] = files[0]
960 }
961 }
962 meta := map[string]any{"type": "tool_result", "data": data}
963 b, _ := json.Marshal(meta)
964 return b
965 }
966
967 func summarizeFunctionCall(name, argsJSON string) string {
968 switch name {
969 case "exec_command":
970 var args execCommandArgs
@@ -975,25 +1007,25 @@
1007 }
1008 return name
1009 }
1010 }
1011
1012 func flattenAssistantContent(content []sessionContent, mirrorReasoning bool) []mirrorLine {
1013 var lines []mirrorLine
1014 for _, item := range content {
1015 switch item.Type {
1016 case "output_text":
1017 for _, line := range splitMirrorText(item.Text) {
1018 if line != "" {
1019 lines = append(lines, mirrorLine{Text: line})
1020 }
1021 }
1022 case "reasoning":
1023 if mirrorReasoning {
1024 for _, line := range splitMirrorText(item.Text) {
1025 if line != "" {
1026 lines = append(lines, mirrorLine{Text: "💭 " + line})
1027 }
1028 }
1029 }
1030 }
1031 }
1032
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -155,33 +155,33 @@
155155
func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
156156
t.Helper()
157157
158158
fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
159159
got := sessionMessages(fnLine, false)
160
- if len(got) != 1 || got[0] != "› pwd" {
160
+ if len(got) != 1 || got[0].Text != "› pwd" {
161161
t.Fatalf("sessionMessages function_call = %#v", got)
162162
}
163163
164164
msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
165165
got = sessionMessages(msgLine, false)
166
- if len(got) != 2 || got[0] != "one line" || got[1] != "second line" {
166
+ if len(got) != 2 || got[0].Text != "one line" || got[1].Text != "second line" {
167167
t.Fatalf("sessionMessages assistant = %#v", got)
168168
}
169169
}
170170
171171
func TestSessionMessagesReasoning(t *testing.T) {
172172
line := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"reasoning","text":"thinking hard"},{"type":"output_text","text":"done"}]}}`)
173173
174174
// reasoning off — only output_text
175175
got := sessionMessages(line, false)
176
- if len(got) != 1 || got[0] != "done" {
176
+ if len(got) != 1 || got[0].Text != "done" {
177177
t.Fatalf("mirrorReasoning=false: got %#v", got)
178178
}
179179
180180
// reasoning on — both, reasoning prefixed
181181
got = sessionMessages(line, true)
182
- if len(got) != 2 || got[0] != "💭 thinking hard" || got[1] != "done" {
182
+ if len(got) != 2 || got[0].Text != "💭 thinking hard" || got[1].Text != "done" {
183183
t.Fatalf("mirrorReasoning=true: got %#v", got)
184184
}
185185
}
186186
187187
func TestExplicitThreadID(t *testing.T) {
188188
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -155,33 +155,33 @@
155 func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
156 t.Helper()
157
158 fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
159 got := sessionMessages(fnLine, false)
160 if len(got) != 1 || got[0] != "› pwd" {
161 t.Fatalf("sessionMessages function_call = %#v", got)
162 }
163
164 msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
165 got = sessionMessages(msgLine, false)
166 if len(got) != 2 || got[0] != "one line" || got[1] != "second line" {
167 t.Fatalf("sessionMessages assistant = %#v", got)
168 }
169 }
170
171 func TestSessionMessagesReasoning(t *testing.T) {
172 line := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"reasoning","text":"thinking hard"},{"type":"output_text","text":"done"}]}}`)
173
174 // reasoning off — only output_text
175 got := sessionMessages(line, false)
176 if len(got) != 1 || got[0] != "done" {
177 t.Fatalf("mirrorReasoning=false: got %#v", got)
178 }
179
180 // reasoning on — both, reasoning prefixed
181 got = sessionMessages(line, true)
182 if len(got) != 2 || got[0] != "💭 thinking hard" || got[1] != "done" {
183 t.Fatalf("mirrorReasoning=true: got %#v", got)
184 }
185 }
186
187 func TestExplicitThreadID(t *testing.T) {
188
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -155,33 +155,33 @@
155 func TestSessionMessagesFunctionCallAndAssistant(t *testing.T) {
156 t.Helper()
157
158 fnLine := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}`)
159 got := sessionMessages(fnLine, false)
160 if len(got) != 1 || got[0].Text != "› pwd" {
161 t.Fatalf("sessionMessages function_call = %#v", got)
162 }
163
164 msgLine := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"one line\nsecond line"}]}}`)
165 got = sessionMessages(msgLine, false)
166 if len(got) != 2 || got[0].Text != "one line" || got[1].Text != "second line" {
167 t.Fatalf("sessionMessages assistant = %#v", got)
168 }
169 }
170
171 func TestSessionMessagesReasoning(t *testing.T) {
172 line := []byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"reasoning","text":"thinking hard"},{"type":"output_text","text":"done"}]}}`)
173
174 // reasoning off — only output_text
175 got := sessionMessages(line, false)
176 if len(got) != 1 || got[0].Text != "done" {
177 t.Fatalf("mirrorReasoning=false: got %#v", got)
178 }
179
180 // reasoning on — both, reasoning prefixed
181 got = sessionMessages(line, true)
182 if len(got) != 2 || got[0].Text != "💭 thinking hard" || got[1].Text != "done" {
183 t.Fatalf("mirrorReasoning=true: got %#v", got)
184 }
185 }
186
187 func TestExplicitThreadID(t *testing.T) {
188
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -478,11 +478,10 @@
478478
if err != nil {
479479
return
480480
}
481481
}
482482
}
483
-
484483
485484
func (s *relayState) observeOutput(data []byte, now time.Time) {
486485
if s == nil {
487486
return
488487
}
489488
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -478,11 +478,10 @@
478 if err != nil {
479 return
480 }
481 }
482 }
483
484
485 func (s *relayState) observeOutput(data []byte, now time.Time) {
486 if s == nil {
487 return
488 }
489
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -478,11 +478,10 @@
478 if err != nil {
479 return
480 }
481 }
482 }
 
483
484 func (s *relayState) observeOutput(data []byte, now time.Time) {
485 if s == nil {
486 return
487 }
488
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -16,10 +16,11 @@
1616
JoinChannel(channel string)
1717
LeaveChannel(channel string)
1818
Messages(channel string) []bridge.Message
1919
Subscribe(channel string) (<-chan bridge.Message, func())
2020
Send(ctx context.Context, channel, text, senderNick string) error
21
+ SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
2122
Stats() bridge.Stats
2223
TouchUser(channel, nick string)
2324
Users(channel string) []string
2425
}
2526
@@ -64,22 +65,23 @@
6465
}
6566
6667
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
6768
channel := "#" + r.PathValue("channel")
6869
var req struct {
69
- Text string `json:"text"`
70
- Nick string `json:"nick"`
70
+ Text string `json:"text"`
71
+ Nick string `json:"nick"`
72
+ Meta *bridge.Meta `json:"meta,omitempty"`
7173
}
7274
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7375
writeError(w, http.StatusBadRequest, "invalid request body")
7476
return
7577
}
7678
if req.Text == "" {
7779
writeError(w, http.StatusBadRequest, "text is required")
7880
return
7981
}
80
- if err := s.bridge.Send(r.Context(), channel, req.Text, req.Nick); err != nil {
82
+ if err := s.bridge.SendWithMeta(r.Context(), channel, req.Text, req.Nick, req.Meta); err != nil {
8183
s.log.Error("bridge send", "channel", channel, "err", err)
8284
writeError(w, http.StatusInternalServerError, "send failed")
8385
return
8486
}
8587
w.WriteHeader(http.StatusNoContent)
8688
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -16,10 +16,11 @@
16 JoinChannel(channel string)
17 LeaveChannel(channel string)
18 Messages(channel string) []bridge.Message
19 Subscribe(channel string) (<-chan bridge.Message, func())
20 Send(ctx context.Context, channel, text, senderNick string) error
 
21 Stats() bridge.Stats
22 TouchUser(channel, nick string)
23 Users(channel string) []string
24 }
25
@@ -64,22 +65,23 @@
64 }
65
66 func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
67 channel := "#" + r.PathValue("channel")
68 var req struct {
69 Text string `json:"text"`
70 Nick string `json:"nick"`
 
71 }
72 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
73 writeError(w, http.StatusBadRequest, "invalid request body")
74 return
75 }
76 if req.Text == "" {
77 writeError(w, http.StatusBadRequest, "text is required")
78 return
79 }
80 if err := s.bridge.Send(r.Context(), channel, req.Text, req.Nick); err != nil {
81 s.log.Error("bridge send", "channel", channel, "err", err)
82 writeError(w, http.StatusInternalServerError, "send failed")
83 return
84 }
85 w.WriteHeader(http.StatusNoContent)
86
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -16,10 +16,11 @@
16 JoinChannel(channel string)
17 LeaveChannel(channel string)
18 Messages(channel string) []bridge.Message
19 Subscribe(channel string) (<-chan bridge.Message, func())
20 Send(ctx context.Context, channel, text, senderNick string) error
21 SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
22 Stats() bridge.Stats
23 TouchUser(channel, nick string)
24 Users(channel string) []string
25 }
26
@@ -64,22 +65,23 @@
65 }
66
67 func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
68 channel := "#" + r.PathValue("channel")
69 var req struct {
70 Text string `json:"text"`
71 Nick string `json:"nick"`
72 Meta *bridge.Meta `json:"meta,omitempty"`
73 }
74 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
75 writeError(w, http.StatusBadRequest, "invalid request body")
76 return
77 }
78 if req.Text == "" {
79 writeError(w, http.StatusBadRequest, "text is required")
80 return
81 }
82 if err := s.bridge.SendWithMeta(r.Context(), channel, req.Text, req.Nick, req.Meta); err != nil {
83 s.log.Error("bridge send", "channel", channel, "err", err)
84 writeError(w, http.StatusInternalServerError, "send failed")
85 return
86 }
87 w.WriteHeader(http.StatusNoContent)
88
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -27,12 +27,15 @@
2727
func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
2828
func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
2929
return make(chan bridge.Message), func() {}
3030
}
3131
func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32
-func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
33
-func (b *stubChatBridge) Users(string) []string { return nil }
32
+func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
33
+ return nil
34
+}
35
+func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36
+func (b *stubChatBridge) Users(string) []string { return nil }
3437
func (b *stubChatBridge) TouchUser(channel, nick string) {
3538
b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
3639
}
3740
3841
func TestHandleChannelPresence(t *testing.T) {
3942
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -27,12 +27,15 @@
27 func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
28 func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
29 return make(chan bridge.Message), func() {}
30 }
31 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
33 func (b *stubChatBridge) Users(string) []string { return nil }
 
 
 
34 func (b *stubChatBridge) TouchUser(channel, nick string) {
35 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
36 }
37
38 func TestHandleChannelPresence(t *testing.T) {
39
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -27,12 +27,15 @@
27 func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
28 func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
29 return make(chan bridge.Message), func() {}
30 }
31 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32 func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
33 return nil
34 }
35 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36 func (b *stubChatBridge) Users(string) []string { return nil }
37 func (b *stubChatBridge) TouchUser(channel, nick string) {
38 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
39 }
40
41 func TestHandleChannelPresence(t *testing.T) {
42
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -175,10 +175,29 @@
175175
.msg-text { color:#e6edf3; word-break:break-word; }
176176
.msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; }
177177
.msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; }
178178
.msg-row.hl-system { opacity:0.6; font-style:italic; }
179179
.msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; }
180
+/* meta blocks */
181
+.msg-meta { display:none; margin:2px 0 4px 0; padding:8px 10px; background:#0d1117; border:1px solid #21262d; border-radius:6px; font-size:12px; line-height:1.5; cursor:default; }
182
+.msg-meta.open { display:block; }
183
+.msg-meta-toggle { display:inline-block; margin-left:6px; font-size:10px; color:#8b949e; cursor:pointer; padding:0 4px; border:1px solid #30363d; border-radius:3px; vertical-align:middle; }
184
+.msg-meta-toggle:hover { color:#e6edf3; border-color:#58a6ff; }
185
+.msg-meta .meta-type { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; margin-bottom:4px; }
186
+.msg-meta .meta-tool { color:#d2a8ff; font-weight:600; }
187
+.msg-meta .meta-file { color:#79c0ff; }
188
+.msg-meta .meta-cmd { color:#a5d6ff; font-family:inherit; }
189
+.msg-meta .meta-error { color:#ff7b72; }
190
+.msg-meta .meta-status { display:inline-block; padding:1px 6px; border-radius:3px; font-size:11px; }
191
+.msg-meta .meta-status.ok { background:#3fb95022; color:#3fb950; border:1px solid #3fb95044; }
192
+.msg-meta .meta-status.error { background:#f8514922; color:#f85149; border:1px solid #f8514944; }
193
+.msg-meta .meta-status.running { background:#1f6feb22; color:#58a6ff; border:1px solid #1f6feb44; }
194
+.msg-meta .meta-kv { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; }
195
+.msg-meta .meta-kv dt { color:#8b949e; }
196
+.msg-meta .meta-kv dd { color:#e6edf3; word-break:break-all; }
197
+.msg-meta pre { margin:4px 0 0; padding:6px 8px; background:#161b22; border:1px solid #21262d; border-radius:4px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; color:#e6edf3; font-size:12px; }
198
+.msg-meta img { max-width:100%; max-height:300px; border-radius:4px; margin-top:4px; }
180199
.chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
181200
182201
/* channels tab */
183202
.chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
184203
.chan-card:last-child { border-bottom:none; }
@@ -491,10 +510,11 @@
491510
<span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
492511
<select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
493512
<option value="">— pick a user —</option>
494513
</select>
495514
<button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button>
515
+ <button class="sm" id="chat-rich-toggle" onclick="toggleRichMode()" title="toggle rich/text mode" style="font-size:11px;padding:2px 6px">{ }</button>
496516
<button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button>
497517
<span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
498518
</div>
499519
<div class="chat-msgs" id="chat-msgs">
500520
<div class="empty" id="chat-placeholder">join a channel to start chatting</div>
@@ -1888,14 +1908,26 @@
18881908
const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
18891909
const color = nickColor(displayNick);
18901910
18911911
const row = document.createElement('div');
18921912
row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
1913
+ // Build meta toggle if metadata present and rich mode is on.
1914
+ let metaToggle = '';
1915
+ let metaBlock = '';
1916
+ if (msg.meta && msg.meta.type) {
1917
+ const html = renderMeta(msg.meta);
1918
+ if (html) {
1919
+ const show = isRichMode();
1920
+ metaToggle = `<span class="msg-meta-toggle" style="${show ? '' : 'display:none'}" onclick="this.parentElement.nextElementSibling.classList.toggle('open');event.stopPropagation()">{ }</span>`;
1921
+ metaBlock = `<div class="msg-meta">${html}</div>`;
1922
+ }
1923
+ }
1924
+
18931925
row.innerHTML =
18941926
`<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
18951927
`<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` +
1896
- `<span class="msg-text">${highlightText(esc(displayText))}</span>`;
1928
+ `<span class="msg-text">${highlightText(esc(displayText))}${metaToggle}</span>`;
18971929
18981930
// Apply row-level highlights.
18991931
const myNick = localStorage.getItem('sb_username') || '';
19001932
const lower = displayText.toLowerCase();
19011933
if (myNick && lower.includes(myNick.toLowerCase())) {
@@ -1907,10 +1939,16 @@
19071939
if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) {
19081940
row.classList.add('hl-system');
19091941
}
19101942
19111943
area.appendChild(row);
1944
+ // Append meta block after the row so toggle can find it via nextElementSibling.
1945
+ if (metaBlock) {
1946
+ const metaEl = document.createElement('div');
1947
+ metaEl.innerHTML = metaBlock;
1948
+ area.appendChild(metaEl.firstChild);
1949
+ }
19121950
19131951
// Unread badge when chat tab not active
19141952
if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
19151953
_chatUnread++;
19161954
document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
@@ -1964,10 +2002,89 @@
19642002
}
19652003
// Restore layout preference on load.
19662004
if (localStorage.getItem('sb_chat_columnar') === '1') {
19672005
document.getElementById('chat-msgs').classList.add('columnar');
19682006
}
2007
+
2008
+// --- rich mode toggle ---
2009
+function isRichMode() { return localStorage.getItem('sb_rich_mode') !== '0'; }
2010
+function toggleRichMode() {
2011
+ const on = !isRichMode();
2012
+ localStorage.setItem('sb_rich_mode', on ? '1' : '0');
2013
+ const btn = document.getElementById('chat-rich-toggle');
2014
+ btn.style.color = on ? '' : '#8b949e';
2015
+ btn.title = on ? 'rich mode on — click for text only' : 'text only — click for rich mode';
2016
+ // Toggle all existing meta blocks visibility.
2017
+ document.querySelectorAll('.msg-meta-toggle').forEach(el => { el.style.display = on ? '' : 'none'; });
2018
+ if (!on) document.querySelectorAll('.msg-meta.open').forEach(el => el.classList.remove('open'));
2019
+}
2020
+// Initialize toggle button state on load.
2021
+(function() {
2022
+ const btn = document.getElementById('chat-rich-toggle');
2023
+ if (!isRichMode()) { btn.style.color = '#8b949e'; btn.title = 'text only — click for rich mode'; }
2024
+ else { btn.title = 'rich mode on — click for text only'; }
2025
+})();
2026
+
2027
+// --- meta renderers ---
2028
+function renderMeta(meta) {
2029
+ if (!meta || !meta.type || !meta.data) return null;
2030
+ switch (meta.type) {
2031
+ case 'tool_result': return renderToolResult(meta.data);
2032
+ case 'diff': return renderDiff(meta.data);
2033
+ case 'error': return renderError(meta.data);
2034
+ case 'status': return renderStatus(meta.data);
2035
+ case 'artifact': return renderArtifact(meta.data);
2036
+ case 'image': return renderImage(meta.data);
2037
+ default: return renderGeneric(meta);
2038
+ }
2039
+}
2040
+function renderToolResult(d) {
2041
+ let html = '<div class="meta-type">tool call</div><dl class="meta-kv">';
2042
+ html += '<dt>tool</dt><dd class="meta-tool">' + esc(d.tool || '?') + '</dd>';
2043
+ if (d.file) html += '<dt>file</dt><dd class="meta-file">' + esc(d.file) + '</dd>';
2044
+ if (d.command) html += '<dt>command</dt><dd class="meta-cmd">' + esc(d.command) + '</dd>';
2045
+ if (d.pattern) html += '<dt>pattern</dt><dd>' + esc(d.pattern) + '</dd>';
2046
+ if (d.query) html += '<dt>query</dt><dd>' + esc(d.query) + '</dd>';
2047
+ if (d.url) html += '<dt>url</dt><dd>' + esc(d.url) + '</dd>';
2048
+ if (d.result) html += '<dt>result</dt><dd>' + esc(d.result) + '</dd>';
2049
+ html += '</dl>';
2050
+ return html;
2051
+}
2052
+function renderDiff(d) {
2053
+ let html = '<div class="meta-type">diff</div>';
2054
+ if (d.file) html += '<div class="meta-file">' + esc(d.file) + '</div>';
2055
+ if (d.hunks) html += '<pre>' + esc(typeof d.hunks === 'string' ? d.hunks : JSON.stringify(d.hunks, null, 2)) + '</pre>';
2056
+ return html;
2057
+}
2058
+function renderError(d) {
2059
+ let html = '<div class="meta-type meta-error">error</div>';
2060
+ html += '<div class="meta-error">' + esc(d.message || '') + '</div>';
2061
+ if (d.stack) html += '<pre>' + esc(d.stack) + '</pre>';
2062
+ return html;
2063
+}
2064
+function renderStatus(d) {
2065
+ const state = (d.state || 'running').toLowerCase();
2066
+ const cls = state === 'ok' || state === 'success' || state === 'done' ? 'ok' : state === 'error' || state === 'failed' ? 'error' : 'running';
2067
+ let html = '<div class="meta-type">status</div>';
2068
+ html += '<span class="meta-status ' + cls + '">' + esc(d.state || '') + '</span>';
2069
+ if (d.message) html += ' <span>' + esc(d.message) + '</span>';
2070
+ return html;
2071
+}
2072
+function renderArtifact(d) {
2073
+ let html = '<div class="meta-type">artifact</div>';
2074
+ html += '<div class="meta-file">' + esc(d.name || d.path || '?') + '</div>';
2075
+ if (d.language) html += '<span class="tag perm">' + esc(d.language) + '</span>';
2076
+ return html;
2077
+}
2078
+function renderImage(d) {
2079
+ let html = '<div class="meta-type">image</div>';
2080
+ if (d.url) html += '<img src="' + esc(d.url) + '" alt="' + esc(d.alt || '') + '" loading="lazy">';
2081
+ return html;
2082
+}
2083
+function renderGeneric(meta) {
2084
+ return '<div class="meta-type">' + esc(meta.type) + '</div><pre>' + esc(JSON.stringify(meta.data, null, 2)) + '</pre>';
2085
+}
19692086
19702087
async function sendMsg() {
19712088
if (!chatChannel) return;
19722089
const input = document.getElementById('chat-text-input');
19732090
const nick = document.getElementById('chat-identity').value.trim() || 'web';
19742091
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -175,10 +175,29 @@
175 .msg-text { color:#e6edf3; word-break:break-word; }
176 .msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; }
177 .msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; }
178 .msg-row.hl-system { opacity:0.6; font-style:italic; }
179 .msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180 .chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
181
182 /* channels tab */
183 .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
184 .chan-card:last-child { border-bottom:none; }
@@ -491,10 +510,11 @@
491 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
492 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
493 <option value="">— pick a user —</option>
494 </select>
495 <button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button>
 
496 <button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button>
497 <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
498 </div>
499 <div class="chat-msgs" id="chat-msgs">
500 <div class="empty" id="chat-placeholder">join a channel to start chatting</div>
@@ -1888,14 +1908,26 @@
1888 const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1889 const color = nickColor(displayNick);
1890
1891 const row = document.createElement('div');
1892 row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
 
 
 
 
 
 
 
 
 
 
 
 
1893 row.innerHTML =
1894 `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
1895 `<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` +
1896 `<span class="msg-text">${highlightText(esc(displayText))}</span>`;
1897
1898 // Apply row-level highlights.
1899 const myNick = localStorage.getItem('sb_username') || '';
1900 const lower = displayText.toLowerCase();
1901 if (myNick && lower.includes(myNick.toLowerCase())) {
@@ -1907,10 +1939,16 @@
1907 if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) {
1908 row.classList.add('hl-system');
1909 }
1910
1911 area.appendChild(row);
 
 
 
 
 
 
1912
1913 // Unread badge when chat tab not active
1914 if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
1915 _chatUnread++;
1916 document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
@@ -1964,10 +2002,89 @@
1964 }
1965 // Restore layout preference on load.
1966 if (localStorage.getItem('sb_chat_columnar') === '1') {
1967 document.getElementById('chat-msgs').classList.add('columnar');
1968 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1969
1970 async function sendMsg() {
1971 if (!chatChannel) return;
1972 const input = document.getElementById('chat-text-input');
1973 const nick = document.getElementById('chat-identity').value.trim() || 'web';
1974
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -175,10 +175,29 @@
175 .msg-text { color:#e6edf3; word-break:break-word; }
176 .msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; }
177 .msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; }
178 .msg-row.hl-system { opacity:0.6; font-style:italic; }
179 .msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; }
180 /* meta blocks */
181 .msg-meta { display:none; margin:2px 0 4px 0; padding:8px 10px; background:#0d1117; border:1px solid #21262d; border-radius:6px; font-size:12px; line-height:1.5; cursor:default; }
182 .msg-meta.open { display:block; }
183 .msg-meta-toggle { display:inline-block; margin-left:6px; font-size:10px; color:#8b949e; cursor:pointer; padding:0 4px; border:1px solid #30363d; border-radius:3px; vertical-align:middle; }
184 .msg-meta-toggle:hover { color:#e6edf3; border-color:#58a6ff; }
185 .msg-meta .meta-type { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; margin-bottom:4px; }
186 .msg-meta .meta-tool { color:#d2a8ff; font-weight:600; }
187 .msg-meta .meta-file { color:#79c0ff; }
188 .msg-meta .meta-cmd { color:#a5d6ff; font-family:inherit; }
189 .msg-meta .meta-error { color:#ff7b72; }
190 .msg-meta .meta-status { display:inline-block; padding:1px 6px; border-radius:3px; font-size:11px; }
191 .msg-meta .meta-status.ok { background:#3fb95022; color:#3fb950; border:1px solid #3fb95044; }
192 .msg-meta .meta-status.error { background:#f8514922; color:#f85149; border:1px solid #f8514944; }
193 .msg-meta .meta-status.running { background:#1f6feb22; color:#58a6ff; border:1px solid #1f6feb44; }
194 .msg-meta .meta-kv { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; }
195 .msg-meta .meta-kv dt { color:#8b949e; }
196 .msg-meta .meta-kv dd { color:#e6edf3; word-break:break-all; }
197 .msg-meta pre { margin:4px 0 0; padding:6px 8px; background:#161b22; border:1px solid #21262d; border-radius:4px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; color:#e6edf3; font-size:12px; }
198 .msg-meta img { max-width:100%; max-height:300px; border-radius:4px; margin-top:4px; }
199 .chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
200
201 /* channels tab */
202 .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
203 .chan-card:last-child { border-bottom:none; }
@@ -491,10 +510,11 @@
510 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
511 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
512 <option value="">— pick a user —</option>
513 </select>
514 <button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button>
515 <button class="sm" id="chat-rich-toggle" onclick="toggleRichMode()" title="toggle rich/text mode" style="font-size:11px;padding:2px 6px">{ }</button>
516 <button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button>
517 <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
518 </div>
519 <div class="chat-msgs" id="chat-msgs">
520 <div class="empty" id="chat-placeholder">join a channel to start chatting</div>
@@ -1888,14 +1908,26 @@
1908 const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1909 const color = nickColor(displayNick);
1910
1911 const row = document.createElement('div');
1912 row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
1913 // Build meta toggle if metadata present and rich mode is on.
1914 let metaToggle = '';
1915 let metaBlock = '';
1916 if (msg.meta && msg.meta.type) {
1917 const html = renderMeta(msg.meta);
1918 if (html) {
1919 const show = isRichMode();
1920 metaToggle = `<span class="msg-meta-toggle" style="${show ? '' : 'display:none'}" onclick="this.parentElement.nextElementSibling.classList.toggle('open');event.stopPropagation()">{ }</span>`;
1921 metaBlock = `<div class="msg-meta">${html}</div>`;
1922 }
1923 }
1924
1925 row.innerHTML =
1926 `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
1927 `<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` +
1928 `<span class="msg-text">${highlightText(esc(displayText))}${metaToggle}</span>`;
1929
1930 // Apply row-level highlights.
1931 const myNick = localStorage.getItem('sb_username') || '';
1932 const lower = displayText.toLowerCase();
1933 if (myNick && lower.includes(myNick.toLowerCase())) {
@@ -1907,10 +1939,16 @@
1939 if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) {
1940 row.classList.add('hl-system');
1941 }
1942
1943 area.appendChild(row);
1944 // Append meta block after the row so toggle can find it via nextElementSibling.
1945 if (metaBlock) {
1946 const metaEl = document.createElement('div');
1947 metaEl.innerHTML = metaBlock;
1948 area.appendChild(metaEl.firstChild);
1949 }
1950
1951 // Unread badge when chat tab not active
1952 if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
1953 _chatUnread++;
1954 document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
@@ -1964,10 +2002,89 @@
2002 }
2003 // Restore layout preference on load.
2004 if (localStorage.getItem('sb_chat_columnar') === '1') {
2005 document.getElementById('chat-msgs').classList.add('columnar');
2006 }
2007
2008 // --- rich mode toggle ---
2009 function isRichMode() { return localStorage.getItem('sb_rich_mode') !== '0'; }
2010 function toggleRichMode() {
2011 const on = !isRichMode();
2012 localStorage.setItem('sb_rich_mode', on ? '1' : '0');
2013 const btn = document.getElementById('chat-rich-toggle');
2014 btn.style.color = on ? '' : '#8b949e';
2015 btn.title = on ? 'rich mode on — click for text only' : 'text only — click for rich mode';
2016 // Toggle all existing meta blocks visibility.
2017 document.querySelectorAll('.msg-meta-toggle').forEach(el => { el.style.display = on ? '' : 'none'; });
2018 if (!on) document.querySelectorAll('.msg-meta.open').forEach(el => el.classList.remove('open'));
2019 }
2020 // Initialize toggle button state on load.
2021 (function() {
2022 const btn = document.getElementById('chat-rich-toggle');
2023 if (!isRichMode()) { btn.style.color = '#8b949e'; btn.title = 'text only — click for rich mode'; }
2024 else { btn.title = 'rich mode on — click for text only'; }
2025 })();
2026
2027 // --- meta renderers ---
2028 function renderMeta(meta) {
2029 if (!meta || !meta.type || !meta.data) return null;
2030 switch (meta.type) {
2031 case 'tool_result': return renderToolResult(meta.data);
2032 case 'diff': return renderDiff(meta.data);
2033 case 'error': return renderError(meta.data);
2034 case 'status': return renderStatus(meta.data);
2035 case 'artifact': return renderArtifact(meta.data);
2036 case 'image': return renderImage(meta.data);
2037 default: return renderGeneric(meta);
2038 }
2039 }
2040 function renderToolResult(d) {
2041 let html = '<div class="meta-type">tool call</div><dl class="meta-kv">';
2042 html += '<dt>tool</dt><dd class="meta-tool">' + esc(d.tool || '?') + '</dd>';
2043 if (d.file) html += '<dt>file</dt><dd class="meta-file">' + esc(d.file) + '</dd>';
2044 if (d.command) html += '<dt>command</dt><dd class="meta-cmd">' + esc(d.command) + '</dd>';
2045 if (d.pattern) html += '<dt>pattern</dt><dd>' + esc(d.pattern) + '</dd>';
2046 if (d.query) html += '<dt>query</dt><dd>' + esc(d.query) + '</dd>';
2047 if (d.url) html += '<dt>url</dt><dd>' + esc(d.url) + '</dd>';
2048 if (d.result) html += '<dt>result</dt><dd>' + esc(d.result) + '</dd>';
2049 html += '</dl>';
2050 return html;
2051 }
2052 function renderDiff(d) {
2053 let html = '<div class="meta-type">diff</div>';
2054 if (d.file) html += '<div class="meta-file">' + esc(d.file) + '</div>';
2055 if (d.hunks) html += '<pre>' + esc(typeof d.hunks === 'string' ? d.hunks : JSON.stringify(d.hunks, null, 2)) + '</pre>';
2056 return html;
2057 }
2058 function renderError(d) {
2059 let html = '<div class="meta-type meta-error">error</div>';
2060 html += '<div class="meta-error">' + esc(d.message || '') + '</div>';
2061 if (d.stack) html += '<pre>' + esc(d.stack) + '</pre>';
2062 return html;
2063 }
2064 function renderStatus(d) {
2065 const state = (d.state || 'running').toLowerCase();
2066 const cls = state === 'ok' || state === 'success' || state === 'done' ? 'ok' : state === 'error' || state === 'failed' ? 'error' : 'running';
2067 let html = '<div class="meta-type">status</div>';
2068 html += '<span class="meta-status ' + cls + '">' + esc(d.state || '') + '</span>';
2069 if (d.message) html += ' <span>' + esc(d.message) + '</span>';
2070 return html;
2071 }
2072 function renderArtifact(d) {
2073 let html = '<div class="meta-type">artifact</div>';
2074 html += '<div class="meta-file">' + esc(d.name || d.path || '?') + '</div>';
2075 if (d.language) html += '<span class="tag perm">' + esc(d.language) + '</span>';
2076 return html;
2077 }
2078 function renderImage(d) {
2079 let html = '<div class="meta-type">image</div>';
2080 if (d.url) html += '<img src="' + esc(d.url) + '" alt="' + esc(d.alt || '') + '" loading="lazy">';
2081 return html;
2082 }
2083 function renderGeneric(meta) {
2084 return '<div class="meta-type">' + esc(meta.type) + '</div><pre>' + esc(JSON.stringify(meta.data, null, 2)) + '</pre>';
2085 }
2086
2087 async function sendMsg() {
2088 if (!chatChannel) return;
2089 const input = document.getElementById('chat-text-input');
2090 const nick = document.getElementById('chat-identity').value.trim() || 'web';
2091
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
55
// to post messages back into IRC.
66
package bridge
77
88
import (
99
"context"
10
+ "encoding/json"
1011
"fmt"
1112
"log/slog"
1213
"net"
1314
"strconv"
1415
"strings"
@@ -19,17 +20,25 @@
1920
"github.com/lrstanley/girc"
2021
)
2122
2223
const botNick = "bridge"
2324
const defaultWebUserTTL = 5 * time.Minute
25
+
26
+// Meta is optional structured metadata attached to a bridge message.
27
+// IRC sees only the plain text; the web UI uses Meta for rich rendering.
28
+type Meta struct {
29
+ Type string `json:"type"`
30
+ Data json.RawMessage `json:"data"`
31
+}
2432
2533
// Message is a single IRC message captured by the bridge.
2634
type Message struct {
2735
At time.Time `json:"at"`
2836
Channel string `json:"channel"`
2937
Nick string `json:"nick"`
3038
Text string `json:"text"`
39
+ Meta *Meta `json:"meta,omitempty"`
3140
}
3241
3342
// ringBuf is a fixed-capacity circular buffer of Messages.
3443
type ringBuf struct {
3544
msgs []Message
@@ -317,35 +326,40 @@
317326
318327
// Send sends a message to channel. The message is attributed to senderNick
319328
// via a visible prefix: "[senderNick] text". The sent message is also pushed
320329
// directly into the buffer since IRC servers don't echo messages back to sender.
321330
func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
331
+ return b.SendWithMeta(ctx, channel, text, senderNick, nil)
332
+}
333
+
334
+// SendWithMeta sends a message to channel with optional structured metadata.
335
+// IRC receives only the plain text; SSE subscribers receive the full message
336
+// including meta for rich rendering in the web UI.
337
+func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
322338
if b.client == nil {
323339
return fmt.Errorf("bridge: not connected")
324340
}
325341
ircText := text
326342
if senderNick != "" {
327343
ircText = "[" + senderNick + "] " + text
328344
}
329345
b.client.Cmd.Message(channel, ircText)
330346
331
- // Track web sender as active in this channel.
332347
if senderNick != "" {
333348
b.TouchUser(channel, senderNick)
334349
}
335350
336
- // Buffer the outgoing message immediately (server won't echo it back).
337
- // Use senderNick so the web UI shows who actually sent it.
338351
displayNick := b.nick
339352
if senderNick != "" {
340353
displayNick = senderNick
341354
}
342355
b.dispatch(Message{
343356
At: time.Now(),
344357
Channel: channel,
345358
Nick: displayNick,
346359
Text: text,
360
+ Meta: meta,
347361
})
348362
return nil
349363
}
350364
351365
// TouchUser marks a bridge/web nick as active in the given channel without
352366
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
5 // to post messages back into IRC.
6 package bridge
7
8 import (
9 "context"
 
10 "fmt"
11 "log/slog"
12 "net"
13 "strconv"
14 "strings"
@@ -19,17 +20,25 @@
19 "github.com/lrstanley/girc"
20 )
21
22 const botNick = "bridge"
23 const defaultWebUserTTL = 5 * time.Minute
 
 
 
 
 
 
 
24
25 // Message is a single IRC message captured by the bridge.
26 type Message struct {
27 At time.Time `json:"at"`
28 Channel string `json:"channel"`
29 Nick string `json:"nick"`
30 Text string `json:"text"`
 
31 }
32
33 // ringBuf is a fixed-capacity circular buffer of Messages.
34 type ringBuf struct {
35 msgs []Message
@@ -317,35 +326,40 @@
317
318 // Send sends a message to channel. The message is attributed to senderNick
319 // via a visible prefix: "[senderNick] text". The sent message is also pushed
320 // directly into the buffer since IRC servers don't echo messages back to sender.
321 func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
 
 
 
 
 
 
 
322 if b.client == nil {
323 return fmt.Errorf("bridge: not connected")
324 }
325 ircText := text
326 if senderNick != "" {
327 ircText = "[" + senderNick + "] " + text
328 }
329 b.client.Cmd.Message(channel, ircText)
330
331 // Track web sender as active in this channel.
332 if senderNick != "" {
333 b.TouchUser(channel, senderNick)
334 }
335
336 // Buffer the outgoing message immediately (server won't echo it back).
337 // Use senderNick so the web UI shows who actually sent it.
338 displayNick := b.nick
339 if senderNick != "" {
340 displayNick = senderNick
341 }
342 b.dispatch(Message{
343 At: time.Now(),
344 Channel: channel,
345 Nick: displayNick,
346 Text: text,
 
347 })
348 return nil
349 }
350
351 // TouchUser marks a bridge/web nick as active in the given channel without
352
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -5,10 +5,11 @@
5 // to post messages back into IRC.
6 package bridge
7
8 import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "log/slog"
13 "net"
14 "strconv"
15 "strings"
@@ -19,17 +20,25 @@
20 "github.com/lrstanley/girc"
21 )
22
23 const botNick = "bridge"
24 const defaultWebUserTTL = 5 * time.Minute
25
26 // Meta is optional structured metadata attached to a bridge message.
27 // IRC sees only the plain text; the web UI uses Meta for rich rendering.
28 type Meta struct {
29 Type string `json:"type"`
30 Data json.RawMessage `json:"data"`
31 }
32
33 // Message is a single IRC message captured by the bridge.
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
39 Meta *Meta `json:"meta,omitempty"`
40 }
41
42 // ringBuf is a fixed-capacity circular buffer of Messages.
43 type ringBuf struct {
44 msgs []Message
@@ -317,35 +326,40 @@
326
327 // Send sends a message to channel. The message is attributed to senderNick
328 // via a visible prefix: "[senderNick] text". The sent message is also pushed
329 // directly into the buffer since IRC servers don't echo messages back to sender.
330 func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
331 return b.SendWithMeta(ctx, channel, text, senderNick, nil)
332 }
333
334 // SendWithMeta sends a message to channel with optional structured metadata.
335 // IRC receives only the plain text; SSE subscribers receive the full message
336 // including meta for rich rendering in the web UI.
337 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
338 if b.client == nil {
339 return fmt.Errorf("bridge: not connected")
340 }
341 ircText := text
342 if senderNick != "" {
343 ircText = "[" + senderNick + "] " + text
344 }
345 b.client.Cmd.Message(channel, ircText)
346
 
347 if senderNick != "" {
348 b.TouchUser(channel, senderNick)
349 }
350
 
 
351 displayNick := b.nick
352 if senderNick != "" {
353 displayNick = senderNick
354 }
355 b.dispatch(Message{
356 At: time.Now(),
357 Channel: channel,
358 Nick: displayNick,
359 Text: text,
360 Meta: meta,
361 })
362 return nil
363 }
364
365 // TouchUser marks a bridge/web nick as active in the given channel without
366
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -91,27 +91,39 @@
9191
}
9292
return nil
9393
}
9494
9595
func (c *httpConnector) Post(ctx context.Context, text string) error {
96
+ return c.PostWithMeta(ctx, text, nil)
97
+}
98
+
99
+func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
100
+ return c.PostToWithMeta(ctx, channel, text, nil)
101
+}
102
+
103
+func (c *httpConnector) PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error {
96104
for _, channel := range c.Channels() {
97
- if err := c.PostTo(ctx, channel, text); err != nil {
105
+ if err := c.PostToWithMeta(ctx, channel, text, meta); err != nil {
98106
return err
99107
}
100108
}
101109
return nil
102110
}
103111
104
-func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
112
+func (c *httpConnector) PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error {
105113
channel = channelSlug(channel)
106114
if channel == "" {
107115
return fmt.Errorf("sessionrelay: post channel is required")
108116
}
109
- return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", map[string]string{
117
+ body := map[string]any{
110118
"nick": c.nick,
111119
"text": text,
112
- })
120
+ }
121
+ if len(meta) > 0 {
122
+ body["meta"] = json.RawMessage(meta)
123
+ }
124
+ return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", body)
113125
}
114126
115127
func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
116128
out := make([]Message, 0, 32)
117129
for _, channel := range c.Channels() {
118130
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -91,27 +91,39 @@
91 }
92 return nil
93 }
94
95 func (c *httpConnector) Post(ctx context.Context, text string) error {
 
 
 
 
 
 
 
 
96 for _, channel := range c.Channels() {
97 if err := c.PostTo(ctx, channel, text); err != nil {
98 return err
99 }
100 }
101 return nil
102 }
103
104 func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
105 channel = channelSlug(channel)
106 if channel == "" {
107 return fmt.Errorf("sessionrelay: post channel is required")
108 }
109 return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", map[string]string{
110 "nick": c.nick,
111 "text": text,
112 })
 
 
 
 
113 }
114
115 func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
116 out := make([]Message, 0, 32)
117 for _, channel := range c.Channels() {
118
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -91,27 +91,39 @@
91 }
92 return nil
93 }
94
95 func (c *httpConnector) Post(ctx context.Context, text string) error {
96 return c.PostWithMeta(ctx, text, nil)
97 }
98
99 func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
100 return c.PostToWithMeta(ctx, channel, text, nil)
101 }
102
103 func (c *httpConnector) PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error {
104 for _, channel := range c.Channels() {
105 if err := c.PostToWithMeta(ctx, channel, text, meta); err != nil {
106 return err
107 }
108 }
109 return nil
110 }
111
112 func (c *httpConnector) PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error {
113 channel = channelSlug(channel)
114 if channel == "" {
115 return fmt.Errorf("sessionrelay: post channel is required")
116 }
117 body := map[string]any{
118 "nick": c.nick,
119 "text": text,
120 }
121 if len(meta) > 0 {
122 body["meta"] = json.RawMessage(meta)
123 }
124 return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", body)
125 }
126
127 func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
128 out := make([]Message, 0, 32)
129 for _, channel := range c.Channels() {
130
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -214,10 +214,19 @@
214214
})
215215
}
216216
}
217217
218218
func (c *ircConnector) Post(_ context.Context, text string) error {
219
+ return c.PostWithMeta(context.Background(), text, nil)
220
+}
221
+
222
+func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223
+ return c.PostToWithMeta(context.Background(), channel, text, nil)
224
+}
225
+
226
+// PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227
+func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
219228
c.mu.RLock()
220229
client := c.client
221230
c.mu.RUnlock()
222231
if client == nil {
223232
return fmt.Errorf("sessionrelay: irc client not connected")
@@ -226,11 +235,12 @@
226235
client.Cmd.Message(channel, text)
227236
}
228237
return nil
229238
}
230239
231
-func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
240
+// PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241
+func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
232242
c.mu.RLock()
233243
client := c.client
234244
c.mu.RUnlock()
235245
if client == nil {
236246
return fmt.Errorf("sessionrelay: irc client not connected")
237247
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -214,10 +214,19 @@
214 })
215 }
216 }
217
218 func (c *ircConnector) Post(_ context.Context, text string) error {
 
 
 
 
 
 
 
 
 
219 c.mu.RLock()
220 client := c.client
221 c.mu.RUnlock()
222 if client == nil {
223 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -226,11 +235,12 @@
226 client.Cmd.Message(channel, text)
227 }
228 return nil
229 }
230
231 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
 
232 c.mu.RLock()
233 client := c.client
234 c.mu.RUnlock()
235 if client == nil {
236 return fmt.Errorf("sessionrelay: irc client not connected")
237
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -214,10 +214,19 @@
214 })
215 }
216 }
217
218 func (c *ircConnector) Post(_ context.Context, text string) error {
219 return c.PostWithMeta(context.Background(), text, nil)
220 }
221
222 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223 return c.PostToWithMeta(context.Background(), channel, text, nil)
224 }
225
226 // PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227 func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
228 c.mu.RLock()
229 client := c.client
230 c.mu.RUnlock()
231 if client == nil {
232 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -226,11 +235,12 @@
235 client.Cmd.Message(channel, text)
236 }
237 return nil
238 }
239
240 // PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
242 c.mu.RLock()
243 client := c.client
244 c.mu.RUnlock()
245 if client == nil {
246 return fmt.Errorf("sessionrelay: irc client not connected")
247
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -1,9 +1,10 @@
11
package sessionrelay
22
33
import (
44
"context"
5
+ "encoding/json"
56
"fmt"
67
"net/http"
78
"strings"
89
"time"
910
)
@@ -47,10 +48,12 @@
4748
4849
type Connector interface {
4950
Connect(ctx context.Context) error
5051
Post(ctx context.Context, text string) error
5152
PostTo(ctx context.Context, channel, text string) error
53
+ PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error
54
+ PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error
5255
MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
5356
Touch(ctx context.Context) error
5457
JoinChannel(ctx context.Context, channel string) error
5558
PartChannel(ctx context.Context, channel string) error
5659
Channels() []string
5760
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -1,9 +1,10 @@
1 package sessionrelay
2
3 import (
4 "context"
 
5 "fmt"
6 "net/http"
7 "strings"
8 "time"
9 )
@@ -47,10 +48,12 @@
47
48 type Connector interface {
49 Connect(ctx context.Context) error
50 Post(ctx context.Context, text string) error
51 PostTo(ctx context.Context, channel, text string) error
 
 
52 MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
53 Touch(ctx context.Context) error
54 JoinChannel(ctx context.Context, channel string) error
55 PartChannel(ctx context.Context, channel string) error
56 Channels() []string
57
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -1,9 +1,10 @@
1 package sessionrelay
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 "time"
10 )
@@ -47,10 +48,12 @@
48
49 type Connector interface {
50 Connect(ctx context.Context) error
51 Post(ctx context.Context, text string) error
52 PostTo(ctx context.Context, channel, text string) error
53 PostWithMeta(ctx context.Context, text string, meta json.RawMessage) error
54 PostToWithMeta(ctx context.Context, channel, text string, meta json.RawMessage) error
55 MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
56 Touch(ctx context.Context) error
57 JoinChannel(ctx context.Context, channel string) error
58 PartChannel(ctx context.Context, channel string) error
59 Channels() []string
60

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button