kward 0.76.0 → 0.77.0

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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +75 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/rpc.md +0 -22
  17. data/doc/security.md +180 -0
  18. data/doc/usage.md +15 -32
  19. data/lib/kward/ansi.rb +2 -2
  20. data/lib/kward/cancellation.rb +2 -2
  21. data/lib/kward/cli/commands.rb +1 -3
  22. data/lib/kward/cli/interactive_turn.rb +0 -17
  23. data/lib/kward/cli/plugins.rb +2 -7
  24. data/lib/kward/cli/prompt_interface.rb +1 -0
  25. data/lib/kward/cli/runtime_helpers.rb +0 -38
  26. data/lib/kward/cli/sessions.rb +14 -5
  27. data/lib/kward/cli/settings.rb +22 -0
  28. data/lib/kward/cli/slash_commands.rb +1 -561
  29. data/lib/kward/cli/tabs.rb +14 -2
  30. data/lib/kward/cli.rb +1 -17
  31. data/lib/kward/compactor.rb +3 -3
  32. data/lib/kward/config_files.rb +18 -0
  33. data/lib/kward/ekwsh.rb +0 -4
  34. data/lib/kward/hooks/catalog.rb +0 -5
  35. data/lib/kward/image_attachments.rb +1 -1
  36. data/lib/kward/pan/index.html.erb +459 -64
  37. data/lib/kward/pan/kward_logo.png +0 -0
  38. data/lib/kward/pan/server.rb +248 -23
  39. data/lib/kward/plugin_registry.rb +18 -0
  40. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  41. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  42. data/lib/kward/prompt_interface/layout.rb +0 -4
  43. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  44. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  46. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  47. data/lib/kward/prompt_interface.rb +18 -1
  48. data/lib/kward/prompts/commands.rb +1 -3
  49. data/lib/kward/prompts.rb +20 -0
  50. data/lib/kward/rpc/server.rb +13 -41
  51. data/lib/kward/rpc/session_manager.rb +75 -25
  52. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  53. data/lib/kward/rpc/tool_metadata.rb +23 -0
  54. data/lib/kward/rpc/transcript_normalizer.rb +4 -0
  55. data/lib/kward/session_store.rb +55 -16
  56. data/lib/kward/skills/registry.rb +21 -1
  57. data/lib/kward/tools/base.rb +14 -0
  58. data/lib/kward/tools/registry.rb +11 -16
  59. data/lib/kward/tools/search/code.rb +1 -1
  60. data/lib/kward/tools/search/web.rb +1 -1
  61. data/lib/kward/tools/tool_call.rb +0 -4
  62. data/lib/kward/transcript_export.rb +1 -1
  63. data/lib/kward/version.rb +1 -1
  64. data/templates/default/kward_navigation.rb +5 -1
  65. data/templates/default/layout/html/layout.erb +0 -2
  66. metadata +5 -12
  67. data/lib/kward/workers/git_guard.rb +0 -93
  68. data/lib/kward/workers/job.rb +0 -99
  69. data/lib/kward/workers/live_view.rb +0 -49
  70. data/lib/kward/workers/manager.rb +0 -328
  71. data/lib/kward/workers/queue_runner.rb +0 -166
  72. data/lib/kward/workers/queue_store.rb +0 -112
  73. data/lib/kward/workers/store.rb +0 -72
  74. data/lib/kward/workers/tool_policy.rb +0 -23
  75. data/lib/kward/workers/worker.rb +0 -82
  76. data/lib/kward/workers/write_lock.rb +0 -38
  77. data/lib/kward/workers.rb +0 -10
@@ -2,108 +2,500 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#070b07">
6
7
  <title>Kward Pan Mode</title>
7
8
  <style>
8
- :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
9
- body { margin: 0; padding: 1rem; max-width: 56rem; margin-inline: auto; }
10
- header { margin-bottom: 1rem; }
11
- h1 { font-size: 1.35rem; margin: 0 0 .25rem; }
12
- .meta { color: #666; font-size: .85rem; overflow-wrap: anywhere; }
13
- #transcript { border: 1px solid #9995; border-radius: .5rem; min-height: 45vh; max-height: 65vh; overflow-y: auto; padding: .75rem; background: #7771; }
14
- .entry { margin: 0 0 .75rem; white-space: pre-wrap; overflow-wrap: anywhere; }
15
- .label { font-weight: 700; margin-bottom: .15rem; }
16
- .user .label { color: #2f6fca; }
17
- .assistant .label { color: #14833b; }
18
- .tool .label { color: #9b4ab8; }
19
- .reasoning .label { color: #9a6b00; }
20
- form { position: sticky; bottom: 0; background: Canvas; padding-top: .75rem; margin-top: .75rem; }
21
- textarea { box-sizing: border-box; width: 100%; min-height: 7rem; font: inherit; padding: .65rem; border-radius: .5rem; border: 1px solid #999; }
22
- .controls { display: flex; gap: .5rem; align-items: center; margin-top: .5rem; }
23
- button { font: inherit; padding: .6rem .9rem; border-radius: .45rem; border: 1px solid #777; }
24
- #status { color: #666; font-size: .9rem; }
25
- @media (max-width: 600px) {
26
- body { padding: .65rem; }
27
- #transcript { max-height: 58vh; }
28
- .controls { align-items: stretch; flex-direction: column; }
29
- button { width: 100%; }
9
+ :root {
10
+ color-scheme: dark;
11
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
12
+ --bg: #070b07;
13
+ --panel: #0d120d;
14
+ --panel-raised: #141a13;
15
+ --panel-soft: #101610;
16
+ --ink: #efe8d0;
17
+ --muted: #afa88f;
18
+ --subtle: #777c68;
19
+ --line: #293024;
20
+ --line-bright: #465035;
21
+ --accent: #9caf35;
22
+ --accent-bright: #d2df62;
23
+ --danger: #ff7f6f;
24
+ --radius: 16px;
25
+ --shadow: 0 18px 60px #0008;
30
26
  }
27
+
28
+ * { box-sizing: border-box; }
29
+ html, body { height: 100%; }
30
+ body { margin: 0; overflow: hidden; background: var(--bg); color: var(--ink); }
31
+ button, textarea { font: inherit; }
32
+ button { color: inherit; }
33
+ button:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 2px; }
34
+
35
+ .app { display: grid; grid-template-columns: 19rem minmax(0, 1fr); height: 100dvh; }
36
+ .sidebar { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--line); background: #090e09; }
37
+ .brand { display: flex; align-items: center; gap: .7rem; min-height: 4.25rem; padding: 1rem; border-bottom: 1px solid var(--line); }
38
+ .brand-logo { width: 2.15rem; height: 2.15rem; flex: 0 0 auto; object-fit: contain; }
39
+ .brand-copy strong { display: block; letter-spacing: .02em; }
40
+ .brand-copy span { color: var(--muted); font-size: .75rem; }
41
+ .icon-button { display: grid; width: 2.65rem; height: 2.65rem; flex: 0 0 auto; place-items: center; border: 1px solid var(--line); border-radius: 11px; background: var(--panel-raised); cursor: pointer; }
42
+ .icon-button:hover { border-color: var(--line-bright); background: #1a2118; }
43
+ #close-sessions { display: none; margin-left: auto; }
44
+ .new-session { display: flex; align-items: center; justify-content: center; gap: .5rem; margin: .85rem; padding: .72rem 1rem; border: 1px solid var(--accent); border-radius: 11px; background: #18200e; color: var(--accent-bright); font-weight: 700; cursor: pointer; }
45
+ .new-session:hover { background: #222d12; }
46
+ .session-heading { padding: .1rem 1rem .55rem; color: var(--subtle); font-size: .7rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
47
+ .session-list { min-height: 0; overflow-y: auto; padding: 0 .55rem 1rem; }
48
+ .session-item { display: flex; width: 100%; gap: .65rem; align-items: flex-start; padding: .72rem; border: 1px solid transparent; border-radius: 11px; background: transparent; text-align: left; cursor: pointer; }
49
+ .session-item:hover { background: var(--panel-raised); }
50
+ .session-item.active { border-color: #3e4926; background: #171e10; }
51
+ .session-dot { width: .48rem; height: .48rem; flex: 0 0 auto; margin-top: .42rem; border-radius: 99px; background: var(--line-bright); }
52
+ .session-item.active .session-dot { background: var(--accent-bright); box-shadow: 0 0 10px #d2df6277; }
53
+ .session-copy { min-width: 0; }
54
+ .session-title { overflow: hidden; color: var(--ink); font-size: .84rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
55
+ .session-meta { margin-top: .2rem; color: var(--subtle); font-size: .7rem; }
56
+ .sidebar-footer { padding: .8rem 1rem calc(.8rem + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); color: var(--subtle); font-size: .72rem; overflow-wrap: anywhere; }
57
+
58
+ .main { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr) auto; background: radial-gradient(circle at 50% -10%, #18210f 0, transparent 28rem), var(--bg); }
59
+ .topbar { display: flex; min-height: 4.25rem; align-items: center; gap: .8rem; padding: .65rem clamp(.8rem, 3vw, 2rem); border-bottom: 1px solid var(--line); background: #070b07d9; backdrop-filter: blur(18px); }
60
+ #open-sessions { display: none; }
61
+ .title-block { min-width: 0; flex: 1; }
62
+ .title-row { display: flex; align-items: center; gap: .55rem; }
63
+ #conversation-title { overflow: hidden; margin: 0; font-size: .96rem; text-overflow: ellipsis; white-space: nowrap; }
64
+ .live-dot { width: .42rem; height: .42rem; border-radius: 99px; background: var(--accent); }
65
+ .top-meta { margin-top: .17rem; overflow: hidden; color: var(--muted); font-size: .7rem; text-overflow: ellipsis; white-space: nowrap; }
66
+ .text-button { padding: .45rem .65rem; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font-size: .76rem; cursor: pointer; }
67
+ .text-button:hover { border-color: var(--line); color: var(--ink); }
68
+ .text-button.danger:hover { border-color: #6b332d; color: var(--danger); }
69
+
70
+ #transcript { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scroll-behavior: smooth; }
71
+ .transcript-inner { width: min(100%, 52rem); min-height: 100%; margin-inline: auto; padding: 1.5rem clamp(1rem, 4vw, 2rem) 2rem; }
72
+ .empty { display: grid; min-height: 100%; place-content: center; padding: 2rem; }
73
+ .startup { width: min(29rem, 100%); }
74
+ .startup-status { display: flex; align-items: center; gap: .55rem; margin-bottom: 1.4rem; font-size: .92rem; }
75
+ .startup-status .live-dot { box-shadow: 0 0 11px #9caf3577; }
76
+ .startup-info { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr); gap: .55rem .9rem; margin: 0; padding: 0; }
77
+ .startup-info dt { color: var(--subtle); }
78
+ .startup-info dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--accent-bright); }
79
+ .startup-business { margin: 1.55rem 0 0; font-size: 1.02rem; font-weight: 750; letter-spacing: .01em; }
80
+ .startup-logo { display: block; width: 7.5rem; height: 7.5rem; margin: 0 auto 1.4rem; object-fit: contain; filter: drop-shadow(0 12px 22px #0008); }
81
+ .entry { margin: 0 0 1.15rem; animation: appear .18s ease-out; }
82
+ @keyframes appear { from { opacity: 0; transform: translateY(3px); } }
83
+ .entry.user { display: flex; justify-content: flex-end; }
84
+ .message { max-width: min(88%, 42rem); }
85
+ .label { margin: 0 0 .35rem; color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
86
+ .user .label { display: none; }
87
+ .text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.58; }
88
+ .text > :first-child { margin-top: 0; }
89
+ .text > :last-child { margin-bottom: 0; }
90
+ .markdown-heading { margin: 1rem 0 .45rem; line-height: 1.25; }
91
+ .markdown-heading.level-1 { font-size: 1.35rem; }
92
+ .markdown-heading.level-2 { font-size: 1.18rem; }
93
+ .markdown-heading.level-3 { font-size: 1.05rem; }
94
+ .markdown-paragraph { margin: 0 0 .8rem; }
95
+ .markdown-code { margin: .7rem 0; padding: .7rem .8rem; overflow-x: auto; border: 1px solid var(--line); border-radius: 9px; background: #090e09; color: var(--ink); font: .82rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; }
96
+ .markdown-code code { font: inherit; }
97
+ .markdown-quote { margin: .65rem 0; padding-left: .8rem; border-left: 2px solid var(--line-bright); color: var(--muted); }
98
+ .markdown-list { margin: .45rem 0 .8rem; padding-left: 1.4rem; }
99
+ .markdown-list li + li { margin-top: .2rem; }
100
+ .markdown-task { display: inline-block; width: 1.3rem; color: var(--accent-bright); }
101
+ .markdown-link { color: var(--accent-bright); }
102
+ .markdown-strike { color: var(--muted); }
103
+ .user .message { padding: .72rem .9rem; border: 1px solid #3a4328; border-radius: 15px 15px 4px 15px; background: #1b2312; }
104
+ .assistant .message { max-width: 100%; }
105
+ .assistant .label { color: var(--accent-bright); }
106
+ .reasoning, .tool, .system { margin-left: .75rem; padding-left: .8rem; border-left: 2px solid var(--line); color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .76rem; }
107
+ .reasoning .label { color: #f0bf6a; }
108
+ .tool .label { color: #a7c884; }
109
+ .tool .text, .reasoning .text { max-height: 11rem; overflow: auto; }
110
+
111
+ .composer-shell { padding: .7rem clamp(.7rem, 3vw, 2rem) calc(.75rem + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: linear-gradient(180deg, #070b07e8, #070b07); }
112
+ #prompt-form { width: min(100%, 50rem); margin-inline: auto; }
113
+ .composer { display: flex; align-items: flex-end; gap: .55rem; padding: .5rem; border: 1px solid var(--line-bright); border-radius: 15px; background: var(--panel-raised); box-shadow: 0 8px 30px #0005; }
114
+ .composer:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px #9caf3512, 0 8px 30px #0005; }
115
+ textarea { width: 100%; min-height: 2.5rem; max-height: 10rem; resize: none; padding: .55rem .45rem; border: 0; outline: 0 !important; background: transparent; color: var(--ink); line-height: 1.45; }
116
+ textarea::placeholder { color: var(--subtle); }
117
+ .send { width: 2.65rem; height: 2.65rem; flex: 0 0 auto; border: 0; border-radius: 10px; background: var(--accent-bright); color: #101408; font-size: 1.15rem; font-weight: 900; cursor: pointer; }
118
+ .send:hover { background: #e3ee77; }
119
+ .send:disabled { cursor: default; opacity: .4; }
120
+ .composer-meta { display: flex; justify-content: space-between; gap: 1rem; padding: .4rem .2rem 0; color: var(--subtle); font-size: .68rem; }
121
+ #status[data-state="working"] { color: var(--accent-bright); }
122
+ #status[data-state="error"] { color: var(--danger); }
123
+ .backdrop { display: none; }
124
+
125
+ @media (max-width: 760px) {
126
+ .app { display: block; }
127
+ .sidebar { position: fixed; z-index: 30; inset: 0 auto 0 0; width: min(88vw, 21rem); transform: translateX(-102%); transition: transform .2s ease; box-shadow: var(--shadow); }
128
+ body.sessions-open .sidebar { transform: translateX(0); }
129
+ #close-sessions, #open-sessions { display: grid; }
130
+ .backdrop { position: fixed; z-index: 20; inset: 0; display: block; visibility: hidden; background: #0009; opacity: 0; transition: opacity .2s, visibility .2s; }
131
+ body.sessions-open .backdrop { visibility: visible; opacity: 1; }
132
+ .main { height: 100dvh; }
133
+ .topbar { min-height: 3.75rem; padding-inline: .7rem; }
134
+ .topbar .text-button { display: none; }
135
+ .transcript-inner { padding: 1rem .8rem 1.5rem; }
136
+ .message { max-width: 92%; }
137
+ .composer-shell { padding-inline: .55rem; }
138
+ .composer-meta span:last-child { display: none; }
139
+ }
140
+
141
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } }
31
142
  </style>
32
143
  </head>
33
- <body>
34
- <header>
35
- <h1>Kward Pan Mode</h1>
36
- <div class="meta">Workspace: <%= ERB::Util.html_escape(@workspace_root) %></div>
37
- <div class="meta">Session: <%= ERB::Util.html_escape(@session_path) %></div>
38
- </header>
39
-
40
- <main id="transcript" aria-live="polite"></main>
41
-
42
- <form id="prompt-form">
43
- <label for="prompt">Prompt</label>
44
- <textarea id="prompt" name="prompt" autocomplete="off" placeholder="Give Kward an order..."></textarea>
45
- <div class="controls">
46
- <button type="submit">Send</button>
47
- <span id="status">Connecting…</span>
48
- </div>
49
- </form>
144
+ <body data-assistant-label="<%= ERB::Util.html_escape(@assistant_label) %>">
145
+ <div class="app">
146
+ <aside class="sidebar" aria-label="Sessions">
147
+ <div class="brand">
148
+ <img class="brand-logo" src="/kward-logo.png" alt="">
149
+ <div class="brand-copy"><strong><%= ERB::Util.html_escape(@assistant_label) %></strong><span>Pan mode</span></div>
150
+ <button class="icon-button" id="close-sessions" type="button" aria-label="Close sessions">×</button>
151
+ </div>
152
+ <button class="new-session" id="new-session" type="button"><span>+</span> New session</button>
153
+ <div class="session-heading">Recent sessions</div>
154
+ <nav class="session-list" id="session-list"></nav>
155
+ <div class="sidebar-footer">Workspace: <%= ERB::Util.html_escape(@workspace_root) %></div>
156
+ </aside>
157
+
158
+ <div class="backdrop" id="backdrop"></div>
159
+
160
+ <section class="main">
161
+ <header class="topbar">
162
+ <button class="icon-button" id="open-sessions" type="button" aria-label="Open sessions">☰</button>
163
+ <div class="title-block">
164
+ <div class="title-row"><span class="live-dot"></span><h1 id="conversation-title">New session</h1></div>
165
+ <div class="top-meta" id="session-meta">Secure LAN workspace</div>
166
+ </div>
167
+ <button class="text-button" id="rename-session" type="button">Rename</button>
168
+ <button class="text-button danger" id="delete-session" type="button">Delete</button>
169
+ </header>
170
+
171
+ <main id="transcript" aria-live="polite">
172
+ <div class="transcript-inner" id="transcript-inner"></div>
173
+ </main>
174
+
175
+ <div class="composer-shell">
176
+ <form id="prompt-form">
177
+ <div class="composer">
178
+ <textarea id="prompt" name="prompt" rows="1" autocomplete="off" enterkeyhint="send" aria-label="Prompt" placeholder="Give <%= ERB::Util.html_escape(@assistant_label) %> an order…"></textarea>
179
+ <button class="send" id="send" type="submit" aria-label="Send prompt">↑</button>
180
+ </div>
181
+ <div class="composer-meta"><span id="status">Connecting…</span><span>Return to send · Shift Return for a new line</span></div>
182
+ </form>
183
+ </div>
184
+ </section>
185
+ </div>
50
186
 
51
187
  <script>
188
+ const assistantLabel = document.body.dataset.assistantLabel;
52
189
  const transcript = document.getElementById('transcript');
190
+ const transcriptInner = document.getElementById('transcript-inner');
53
191
  const form = document.getElementById('prompt-form');
54
192
  const promptBox = document.getElementById('prompt');
193
+ const sendButton = document.getElementById('send');
55
194
  const statusEl = document.getElementById('status');
195
+ const sessionList = document.getElementById('session-list');
196
+ const titleEl = document.getElementById('conversation-title');
197
+ const sessionMeta = document.getElementById('session-meta');
198
+ let activeSession = null;
199
+ let sessions = [];
56
200
  let currentAssistant = null;
57
201
  let currentReasoning = null;
58
202
 
203
+ function emptyState() {
204
+ transcriptInner.innerHTML = `
205
+ <div class="empty">
206
+ <section class="startup" aria-label="Kward startup information">
207
+ <img class="startup-logo" src="/kward-logo.png" alt="Kward">
208
+ <div class="startup-status"><span class="live-dot"></span><strong><%= ERB::Util.html_escape(@assistant_label) %> v<%= ERB::Util.html_escape(@version) %> is online.</strong></div>
209
+ <dl class="startup-info">
210
+ <dt>Workspace</dt><dd><%= ERB::Util.html_escape(@workspace_label) %></dd>
211
+ <dt>Interface</dt><dd>Pan mode</dd>
212
+ <dt>Session</dt><dd>New session</dd>
213
+ </dl>
214
+ <p class="startup-business">State your business.</p>
215
+ </section>
216
+ </div>`;
217
+ }
218
+
59
219
  function entry(role, label, text) {
220
+ transcriptInner.querySelector('.empty')?.remove();
60
221
  const node = document.createElement('section');
61
222
  node.className = `entry ${role}`;
223
+ const message = document.createElement('div');
224
+ message.className = 'message';
62
225
  const labelNode = document.createElement('div');
63
226
  labelNode.className = 'label';
64
227
  labelNode.textContent = label;
65
228
  const textNode = document.createElement('div');
66
229
  textNode.className = 'text';
67
- textNode.textContent = text || '';
68
- node.append(labelNode, textNode);
69
- transcript.appendChild(node);
230
+ renderMarkdown(textNode, text || '');
231
+ message.append(labelNode, textNode);
232
+ node.appendChild(message);
233
+ transcriptInner.appendChild(node);
70
234
  transcript.scrollTop = transcript.scrollHeight;
71
235
  return textNode;
72
236
  }
73
237
 
238
+ function appendTranscriptText(node, text) {
239
+ renderMarkdown(node, `${node.markdownSource || ''}${text || ''}`);
240
+ }
241
+
242
+ function renderMarkdown(node, source) {
243
+ node.markdownSource = source;
244
+ node.replaceChildren();
245
+ const lines = source.split(/\r\n|\r|\n/);
246
+ let index = 0;
247
+
248
+ while (index < lines.length) {
249
+ if (!lines[index]) { index += 1; continue; }
250
+
251
+ const fence = lines[index].match(/^\s*```([^`]*)\s*$/);
252
+ if (fence) {
253
+ const code = [];
254
+ index += 1;
255
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) code.push(lines[index++]);
256
+ if (index < lines.length) index += 1;
257
+ const pre = document.createElement('pre');
258
+ pre.className = 'markdown-code';
259
+ const codeNode = document.createElement('code');
260
+ codeNode.textContent = code.join('\n');
261
+ if (fence[1].trim()) codeNode.dataset.language = fence[1].trim();
262
+ pre.appendChild(codeNode);
263
+ node.appendChild(pre);
264
+ continue;
265
+ }
266
+
267
+ const heading = lines[index].match(/^(#{1,6})\s+(.+)$/);
268
+ if (heading) {
269
+ const level = heading[1].length;
270
+ const element = document.createElement(`h${level}`);
271
+ element.className = `markdown-heading level-${level}`;
272
+ appendInlineMarkdown(element, heading[2]);
273
+ node.appendChild(element);
274
+ index += 1;
275
+ continue;
276
+ }
277
+
278
+ if (/^>\s?/.test(lines[index])) {
279
+ const quote = document.createElement('blockquote');
280
+ quote.className = 'markdown-quote';
281
+ while (index < lines.length && /^>\s?/.test(lines[index])) {
282
+ if (quote.childNodes.length) quote.appendChild(document.createElement('br'));
283
+ appendInlineMarkdown(quote, lines[index].replace(/^>\s?/, ''));
284
+ index += 1;
285
+ }
286
+ node.appendChild(quote);
287
+ continue;
288
+ }
289
+
290
+ const list = lines[index].match(/^\s*((?:[-+*])|\d+[.)])\s+(.+)$/);
291
+ if (list) {
292
+ const ordered = /^\d/.test(list[1]);
293
+ const element = document.createElement(ordered ? 'ol' : 'ul');
294
+ element.className = 'markdown-list';
295
+ while (index < lines.length) {
296
+ const item = lines[index].match(/^\s*((?:[-+*])|\d+[.)])\s+(.+)$/);
297
+ if (!item || /^\d/.test(item[1]) !== ordered) break;
298
+ const entry = document.createElement('li');
299
+ const task = item[2].match(/^\[([ xX])\]\s+(.+)$/);
300
+ if (task) {
301
+ const marker = document.createElement('span');
302
+ marker.className = 'markdown-task';
303
+ marker.textContent = task[1].toLowerCase() === 'x' ? '☑' : '☐';
304
+ entry.appendChild(marker);
305
+ appendInlineMarkdown(entry, task[2]);
306
+ } else {
307
+ appendInlineMarkdown(entry, item[2]);
308
+ }
309
+ element.appendChild(entry);
310
+ index += 1;
311
+ }
312
+ node.appendChild(element);
313
+ continue;
314
+ }
315
+
316
+ const paragraph = document.createElement('p');
317
+ paragraph.className = 'markdown-paragraph';
318
+ while (index < lines.length && lines[index]) {
319
+ if (paragraph.childNodes.length) paragraph.appendChild(document.createElement('br'));
320
+ appendInlineMarkdown(paragraph, lines[index]);
321
+ index += 1;
322
+ }
323
+ node.appendChild(paragraph);
324
+ }
325
+ }
326
+
327
+ function appendInlineMarkdown(node, source) {
328
+ const pattern = /(`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\)|\*\*[^*\n]+\*\*|~~[^~\n]+~~|(?<!\*)\*[^*\n]+\*(?!\*)|(?<!\w)_[^_\n]+_(?!\w))/g;
329
+ let lastIndex = 0;
330
+ for (const match of source.matchAll(pattern)) {
331
+ node.appendChild(document.createTextNode(source.slice(lastIndex, match.index)));
332
+ const token = match[0];
333
+ if (token.startsWith('`')) {
334
+ const code = document.createElement('code');
335
+ code.textContent = token.slice(1, -1);
336
+ node.appendChild(code);
337
+ } else if (token.startsWith('[')) {
338
+ const link = token.match(/^\[([^\]]+)\]\(([^\s)]+)\)$/);
339
+ const href = safeLinkHref(link[2]);
340
+ if (href) {
341
+ const anchor = document.createElement('a');
342
+ anchor.className = 'markdown-link';
343
+ anchor.href = href;
344
+ anchor.target = '_blank';
345
+ anchor.rel = 'noopener noreferrer';
346
+ anchor.textContent = link[1];
347
+ node.appendChild(anchor);
348
+ } else {
349
+ node.appendChild(document.createTextNode(token));
350
+ }
351
+ } else {
352
+ const element = document.createElement(token.startsWith('**') ? 'strong' : token.startsWith('~~') ? 's' : 'em');
353
+ if (token.startsWith('~~')) element.className = 'markdown-strike';
354
+ element.textContent = token.startsWith('**') ? token.slice(2, -2) : token.slice(1, -1);
355
+ node.appendChild(element);
356
+ }
357
+ lastIndex = match.index + token.length;
358
+ }
359
+ node.appendChild(document.createTextNode(source.slice(lastIndex)));
360
+ }
361
+
362
+ function safeLinkHref(value) {
363
+ try {
364
+ const url = new URL(value);
365
+ return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null;
366
+ } catch (_) {
367
+ return null;
368
+ }
369
+ }
370
+
74
371
  function setStatus(payload) {
75
372
  const queued = payload && Number(payload.queued || 0);
76
373
  const active = payload && payload.active;
77
- statusEl.textContent = active ? `Working${queued ? ` ${queued} queued` : ''}` : (queued ? `${queued} queued` : 'Ready');
374
+ statusEl.textContent = active ? `${assistantLabel} is working${queued ? ` · ${queued} queued` : ''}` : (queued ? `${queued} queued` : 'Ready');
375
+ statusEl.dataset.state = active || queued ? 'working' : 'ready';
376
+ }
377
+
378
+ function showError(message) {
379
+ entry('tool', 'Error', message);
380
+ statusEl.textContent = message;
381
+ statusEl.dataset.state = 'error';
78
382
  }
79
383
 
80
- function resetStreamBlocks() {
81
- currentAssistant = null;
82
- currentReasoning = null;
384
+ function resetStreamBlocks() { currentAssistant = null; currentReasoning = null; }
385
+ function closeSessions() { document.body.classList.remove('sessions-open'); }
386
+ function formatDate(value) {
387
+ if (!value) return 'Just now';
388
+ return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value));
83
389
  }
84
390
 
85
- fetch('/transcript')
86
- .then(response => response.json())
87
- .then(data => {
88
- (data.transcript || []).forEach(item => entry(item.role || 'system', item.label || item.role || 'Message', item.text || ''));
89
- })
90
- .catch(error => entry('tool', 'Error', error.message));
391
+ function renderSessions() {
392
+ sessionList.replaceChildren();
393
+ sessions.forEach(session => {
394
+ const button = document.createElement('button');
395
+ button.type = 'button';
396
+ button.className = `session-item${session.active ? ' active' : ''}`;
397
+ button.dataset.id = session.id;
398
+ const dot = document.createElement('span');
399
+ dot.className = 'session-dot';
400
+ const copy = document.createElement('span');
401
+ copy.className = 'session-copy';
402
+ const title = document.createElement('div');
403
+ title.className = 'session-title';
404
+ title.textContent = session.title || 'New session';
405
+ const meta = document.createElement('div');
406
+ meta.className = 'session-meta';
407
+ meta.textContent = `${formatDate(session.modifiedAt || session.createdAt)} · ${session.messageCount || 0} messages`;
408
+ copy.append(title, meta);
409
+ button.append(dot, copy);
410
+ button.addEventListener('click', () => changeSession('resume', { id: session.id }));
411
+ sessionList.appendChild(button);
412
+ });
413
+ }
414
+
415
+ async function loadSessions() {
416
+ const response = await fetch('/sessions', { cache: 'no-store' });
417
+ const data = await response.json();
418
+ sessions = data.sessions || [];
419
+ activeSession = sessions.find(session => session.id === data.activeSessionId) || activeSession;
420
+ renderSessions();
421
+ renderSessionHeader();
422
+ }
423
+
424
+ async function loadTranscript() {
425
+ const response = await fetch('/transcript', { cache: 'no-store' });
426
+ const data = await response.json();
427
+ activeSession = data.session;
428
+ transcriptInner.replaceChildren();
429
+ (data.transcript || []).forEach(item => entry(item.role || 'system', item.label || item.role || 'Message', item.text || ''));
430
+ if (!(data.transcript || []).length) emptyState();
431
+ renderSessionHeader();
432
+ transcript.scrollTop = transcript.scrollHeight;
433
+ }
434
+
435
+ function renderSessionHeader() {
436
+ if (!activeSession) return;
437
+ titleEl.textContent = activeSession.title || activeSession.name || 'New session';
438
+ const details = [activeSession.model, activeSession.messageCount != null ? `${activeSession.messageCount} messages` : null].filter(Boolean);
439
+ sessionMeta.textContent = details.join(' · ') || 'Secure LAN workspace';
440
+ }
441
+
442
+ async function changeSession(action, values = {}) {
443
+ try {
444
+ const response = await fetch('/sessions', {
445
+ method: 'POST',
446
+ headers: { 'Content-Type': 'application/json' },
447
+ body: JSON.stringify({ action, ...values })
448
+ });
449
+ const data = await response.json();
450
+ if (!data.ok) throw new Error(data.error || 'Session could not be changed.');
451
+ closeSessions();
452
+ await Promise.all([loadSessions(), loadTranscript()]);
453
+ } catch (error) { showError(error.message); }
454
+ }
455
+
456
+ document.getElementById('open-sessions').addEventListener('click', () => document.body.classList.add('sessions-open'));
457
+ document.getElementById('close-sessions').addEventListener('click', closeSessions);
458
+ document.getElementById('backdrop').addEventListener('click', closeSessions);
459
+ document.getElementById('new-session').addEventListener('click', () => changeSession('new'));
460
+ document.getElementById('rename-session').addEventListener('click', () => {
461
+ if (!activeSession) return;
462
+ const name = window.prompt('Session name', activeSession.name || '');
463
+ if (name !== null) changeSession('rename', { id: activeSession.id, name });
464
+ });
465
+ document.getElementById('delete-session').addEventListener('click', () => {
466
+ if (!activeSession || !window.confirm('Delete this session? This cannot be undone from Pan.')) return;
467
+ changeSession('delete', { id: activeSession.id });
468
+ });
469
+
470
+ promptBox.addEventListener('input', () => {
471
+ promptBox.style.height = 'auto';
472
+ promptBox.style.height = `${Math.min(promptBox.scrollHeight, 160)}px`;
473
+ });
474
+ promptBox.addEventListener('keydown', event => {
475
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
476
+ event.preventDefault();
477
+ form.requestSubmit();
478
+ }
479
+ });
480
+
481
+ Promise.all([loadTranscript(), loadSessions()]).catch(error => showError(error.message));
91
482
 
92
483
  const events = new EventSource('/events');
93
484
  events.addEventListener('ready', event => setStatus(JSON.parse(event.data)));
94
485
  events.addEventListener('queue', event => setStatus(JSON.parse(event.data)));
95
486
  events.addEventListener('turn_started', event => { resetStreamBlocks(); setStatus(JSON.parse(event.data)); });
96
- events.addEventListener('turn_finished', event => { resetStreamBlocks(); setStatus(JSON.parse(event.data)); });
487
+ events.addEventListener('turn_finished', event => { resetStreamBlocks(); setStatus(JSON.parse(event.data)); loadSessions().catch(() => {}); });
488
+ events.addEventListener('session_changed', () => Promise.all([loadSessions(), loadTranscript()]).catch(error => showError(error.message)));
97
489
  events.addEventListener('user', event => entry('user', 'You', JSON.parse(event.data).text));
98
490
  events.addEventListener('reasoning_delta', event => {
99
491
  if (!currentReasoning) currentReasoning = entry('reasoning', 'Reasoning', '');
100
- currentReasoning.textContent += JSON.parse(event.data).delta;
492
+ appendTranscriptText(currentReasoning, JSON.parse(event.data).delta);
101
493
  transcript.scrollTop = transcript.scrollHeight;
102
494
  });
103
495
  events.addEventListener('reasoning_boundary', () => { currentReasoning = null; });
104
496
  events.addEventListener('assistant_delta', event => {
105
- if (!currentAssistant) currentAssistant = entry('assistant', 'Assistant', '');
106
- currentAssistant.textContent += JSON.parse(event.data).delta;
497
+ if (!currentAssistant) currentAssistant = entry('assistant', assistantLabel, '');
498
+ appendTranscriptText(currentAssistant, JSON.parse(event.data).delta);
107
499
  transcript.scrollTop = transcript.scrollHeight;
108
500
  });
109
501
  events.addEventListener('tool_call', event => {
@@ -115,17 +507,21 @@
115
507
  entry('tool', 'Tool output', `${data.name}: ${data.content}`);
116
508
  });
117
509
  events.addEventListener('answer', event => {
118
- if (!currentAssistant) entry('assistant', 'Assistant', JSON.parse(event.data).content);
510
+ if (!currentAssistant) entry('assistant', assistantLabel, JSON.parse(event.data).content);
119
511
  });
120
512
  events.addEventListener('retry', event => entry('tool', 'Retry', JSON.parse(event.data).message));
121
- events.addEventListener('error', event => entry('tool', 'Error', JSON.parse(event.data).message));
122
- events.onerror = () => { statusEl.textContent = 'Disconnected'; };
513
+ events.addEventListener('error', event => {
514
+ if (typeof event.data === 'string' && event.data.startsWith('{')) showError(JSON.parse(event.data).message);
515
+ });
516
+ events.onerror = () => { statusEl.textContent = 'Reconnecting…'; statusEl.dataset.state = 'error'; };
123
517
 
124
518
  form.addEventListener('submit', async event => {
125
519
  event.preventDefault();
126
520
  const prompt = promptBox.value;
127
521
  if (!prompt.trim()) return;
128
522
  promptBox.value = '';
523
+ promptBox.style.height = 'auto';
524
+ sendButton.disabled = true;
129
525
  try {
130
526
  const response = await fetch('/turn', {
131
527
  method: 'POST',
@@ -133,11 +529,10 @@
133
529
  body: JSON.stringify({ prompt })
134
530
  });
135
531
  const data = await response.json();
136
- if (!data.ok) entry('tool', 'Error', data.error || 'Prompt was not accepted.');
532
+ if (!data.ok) throw new Error(data.error || 'Prompt was not accepted.');
137
533
  setStatus(data);
138
- } catch (error) {
139
- entry('tool', 'Error', error.message);
140
- }
534
+ } catch (error) { showError(error.message); }
535
+ finally { sendButton.disabled = false; promptBox.focus(); }
141
536
  });
142
537
  </script>
143
538
  </body>
Binary file