pi-web-ui 0.6.2 → 0.7.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.
@@ -490,6 +490,42 @@ class ClientStateStore {
490
490
  this.save();
491
491
  }
492
492
  }
493
+ /** Cap on simultaneously open conversations (each keeps a full runtime alive). */
494
+ const MAX_OPEN_CONVERSATIONS = 8;
495
+ const DEFAULT_CONV_TITLE = "新对话";
496
+ /** First user text in a session, truncated for the conversation list. */
497
+ function conversationTitle(session) {
498
+ try {
499
+ for (const m of session.agent.state.messages) {
500
+ if (m.role !== "user")
501
+ continue;
502
+ const content = m.content;
503
+ let text = "";
504
+ if (typeof content === "string") {
505
+ text = content;
506
+ }
507
+ else if (Array.isArray(content)) {
508
+ for (const p of content) {
509
+ if (p &&
510
+ typeof p === "object" &&
511
+ p.type === "text" &&
512
+ typeof p.text === "string") {
513
+ text = p.text;
514
+ break;
515
+ }
516
+ }
517
+ }
518
+ const trimmed = text.trim().replace(/\s+/g, " ");
519
+ if (trimmed.length > 0) {
520
+ return trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
521
+ }
522
+ }
523
+ }
524
+ catch {
525
+ // best-effort
526
+ }
527
+ return DEFAULT_CONV_TITLE;
528
+ }
493
529
  export class ClientSession {
494
530
  clientId;
495
531
  cwd;
@@ -500,8 +536,31 @@ export class ClientSession {
500
536
  agentDir;
501
537
  /** Persisted per-client UI state (last workspace + recent projects). */
502
538
  stateStore;
503
- runtime;
504
- session;
539
+ /** Open conversations — each owns its OWN runtime, so starting a new chat
540
+ * or switching chats never interrupts an in-flight run. `runtime` and
541
+ * `session` accessors below target the ACTIVE conversation. */
542
+ convs = new Map();
543
+ activeId = "";
544
+ convSeq = 0;
545
+ /** One ModelRuntime shared by all conversations — the model chosen in the
546
+ * top bar applies to every chat, not just the one that set it. Seeded by
547
+ * the first conversation and reused by later ones. */
548
+ sharedModelRuntime;
549
+ /** The active conversation (all session operations target it). */
550
+ get conv() {
551
+ const conv = this.convs.get(this.activeId);
552
+ if (!conv)
553
+ throw new Error("no active conversation");
554
+ return conv;
555
+ }
556
+ /** Runtime of the active conversation. */
557
+ get runtime() {
558
+ return this.conv.runtime;
559
+ }
560
+ /** Session of the active conversation. */
561
+ get session() {
562
+ return this.conv.session;
563
+ }
505
564
  /** PTY terminals for this client (killed when the last socket detaches). */
506
565
  terminals = new TerminalManager((msg) => this.emit(msg));
507
566
  /** Web-facing extension UI context (widgets, notifications). */
@@ -510,48 +569,40 @@ export class ClientSession {
510
569
  /** Connected sockets for this client (multiple tabs share the session). */
511
570
  sinks = new Set();
512
571
  pendingNotices = [];
513
- unsubscribe;
514
572
  snapshotTimer = null;
515
573
  sessionsTimer = null;
516
574
  version = 0;
517
575
  /**
518
- * Stable per-message ids: assigned once per (role, timestamp) so snapshot ids
519
- * don't change every 60ms changing ids would remount the whole list in
520
- * React (collapse open thinking blocks, reset scroll, jank on long chats).
576
+ * Per-conversation serialization caches (stable message ids, UiMessage
577
+ * object cache, message-array signature, queue counts) live inside each
578
+ * Conversation see Conversation above.
521
579
  */
522
- msgIds = new Map();
523
- nextMsgId = 1;
524
- /** Serialized UiMessage cache — object-reference-stable across snapshots, so
525
- * the frontend's React.memo can skip unchanged messages entirely. */
526
- uiMessageCache = new Map();
527
- /** Reused when the message set didn't change, keeping state.messages
528
- * reference-stable so the frontend can memoize derived maps. */
529
- lastMessagesSig = "";
530
- lastMessagesArray = [];
531
- queueSteering = 0;
532
- queueFollowUp = 0;
533
580
  disposed = false;
534
581
  /** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
535
582
  piCheckCache = null;
536
- constructor(clientId, cwd, sessionDir, agentDir, runtime, stateStore) {
583
+ constructor(clientId, cwd, sessionDir, agentDir, stateStore) {
537
584
  this.clientId = clientId;
538
585
  this.cwd = cwd;
539
586
  this.sessionDir = sessionDir;
540
587
  this.agentDir = agentDir;
541
- this.runtime = runtime;
542
- this.session = runtime.session;
543
588
  this.stateStore = stateStore;
544
589
  }
545
590
  static async create(clientId, cwd, sessionDir, stateStore) {
546
591
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
547
- const runtime = await createAgentSessionRuntime(ClientSession.runtimeFactory, {
592
+ const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, stateStore);
593
+ const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
548
594
  cwd,
549
595
  agentDir,
550
596
  // Resume the most recent session for this client's private session dir,
551
597
  // or start a fresh one on first visit.
552
598
  sessionManager: SessionManager.continueRecent(cwd, sessionDir),
553
599
  });
554
- const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, runtime, stateStore);
600
+ // First conversation = the resumed session; it also seeds the shared
601
+ // ModelRuntime that every later conversation reuses.
602
+ cs.sharedModelRuntime = runtime.services.modelRuntime;
603
+ const conv = cs.makeConversation(runtime);
604
+ cs.convs.set(conv.id, conv);
605
+ cs.activeId = conv.id;
555
606
  for (const d of runtime.diagnostics) {
556
607
  if (d.type !== "info") {
557
608
  cs.pendingNotices.push({
@@ -564,15 +615,42 @@ export class ClientSession {
564
615
  await cs.bindSession();
565
616
  return cs;
566
617
  }
567
- /** Builds a full cwd-bound runtime for the given working directory. */
568
- static runtimeFactory = async ({ cwd: effectiveCwd, sessionManager, }) => {
569
- const services = await createAgentSessionServices({ cwd: effectiveCwd });
618
+ /**
619
+ * Factory for cwd-bound runtimes. All conversations share ONE ModelRuntime
620
+ * (the model choice is client-wide), so later conversations reuse the
621
+ * instance created with the first one.
622
+ */
623
+ makeRuntimeFactory() {
624
+ return async ({ cwd: effectiveCwd, sessionManager }) => {
625
+ const services = await createAgentSessionServices({
626
+ cwd: effectiveCwd,
627
+ modelRuntime: this.sharedModelRuntime,
628
+ });
629
+ return {
630
+ ...(await createAgentSessionFromServices({ services, sessionManager })),
631
+ services,
632
+ diagnostics: services.diagnostics,
633
+ };
634
+ };
635
+ }
636
+ /** Wrap a fresh runtime as a new conversation record. */
637
+ makeConversation(runtime) {
570
638
  return {
571
- ...(await createAgentSessionFromServices({ services, sessionManager })),
572
- services,
573
- diagnostics: services.diagnostics,
639
+ id: `c${++this.convSeq}`,
640
+ title: conversationTitle(runtime.session),
641
+ runtime,
642
+ session: runtime.session,
643
+ cwd: runtime.cwd,
644
+ createdAt: Date.now(),
645
+ msgIds: new Map(),
646
+ nextMsgId: 1,
647
+ uiMessageCache: new Map(),
648
+ lastMessagesSig: "",
649
+ lastMessagesArray: [],
650
+ queueSteering: 0,
651
+ queueFollowUp: 0,
574
652
  };
575
- };
653
+ }
576
654
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
577
655
  attachSink(send) {
578
656
  this.sinks.add(send);
@@ -587,6 +665,9 @@ export class ClientSession {
587
665
  const statuses = this.webUi.statusSnapshot();
588
666
  if (statuses.length > 0)
589
667
  send({ type: "statuses", statuses });
668
+ // Reconnect: push the open-conversation list so the left panel shows
669
+ // every chat (a fresh socket never got the newChat/switch pushes).
670
+ this.emitConversations();
590
671
  }
591
672
  detachSink(send) {
592
673
  this.sinks.delete(send);
@@ -602,18 +683,19 @@ export class ClientSession {
602
683
  for (const sink of [...this.sinks])
603
684
  sink(msg);
604
685
  }
605
- /** (Re)attach event plumbing to the active session — also used after new_chat. */
686
+ /** (Re)attach event plumbing to the ACTIVE conversation's session. */
606
687
  async bindSession() {
607
- this.unsubscribe?.();
608
- this.session = this.runtime.session;
609
- await this.session.bindExtensions({
688
+ const conv = this.conv;
689
+ conv.unsubscribe?.();
690
+ conv.session = conv.runtime.session;
691
+ await conv.session.bindExtensions({
610
692
  mode: "rpc",
611
693
  uiContext: this.webUi,
612
694
  onError: (err) => {
613
695
  this.emit({ type: "notice", level: "error", text: err.error });
614
696
  },
615
697
  });
616
- this.unsubscribe = this.session.subscribe((event) => this.onEvent(event));
698
+ conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
617
699
  this.scheduleSnapshot();
618
700
  this.webUi.refresh();
619
701
  this.startWidgetsTimer();
@@ -627,7 +709,7 @@ export class ClientSession {
627
709
  this.webUi.refresh();
628
710
  }, WIDGET_REFRESH_MS);
629
711
  }
630
- onEvent(event) {
712
+ onEvent(conv, event) {
631
713
  switch (event.type) {
632
714
  case "bash_execution_update": {
633
715
  if (event.id) {
@@ -653,8 +735,8 @@ export class ClientSession {
653
735
  break;
654
736
  }
655
737
  case "queue_update":
656
- this.queueSteering = event.steering.length;
657
- this.queueFollowUp = event.followUp.length;
738
+ conv.queueSteering = event.steering.length;
739
+ conv.queueFollowUp = event.followUp.length;
658
740
  break;
659
741
  // A run finished or a new entry was persisted — keep the session list fresh
660
742
  // (new chat + first message, completed turns, compaction, etc.).
@@ -667,37 +749,41 @@ export class ClientSession {
667
749
  }
668
750
  this.scheduleSnapshot();
669
751
  }
670
- /** Debounced push of the persisted session list to the client. */
752
+ /** Debounced push of the persisted session list + open conversations. */
671
753
  scheduleSessionsRefresh() {
672
754
  if (this.sessionsTimer)
673
755
  return;
674
756
  this.sessionsTimer = setTimeout(() => {
675
757
  this.sessionsTimer = null;
676
- if (!this.disposed)
677
- void this.pushSessions();
758
+ if (this.disposed)
759
+ return;
760
+ this.emitConversations();
761
+ void this.pushSessions();
678
762
  }, 800);
679
763
  }
680
764
  /** Serialize a persisted message with a STABLE id + cached object reference. */
681
765
  serializeCached(m) {
766
+ const conv = this.conv;
682
767
  const key = m.role === "toolResult"
683
768
  ? `t:${m.toolCallId}`
684
769
  : `${m.role}:${m.timestamp}`;
685
- let n = this.msgIds.get(key);
770
+ let n = conv.msgIds.get(key);
686
771
  if (n === undefined) {
687
- n = this.nextMsgId++;
688
- this.msgIds.set(key, n);
772
+ n = conv.nextMsgId++;
773
+ conv.msgIds.set(key, n);
689
774
  }
690
775
  const cacheKey = `${key}#${n}`;
691
- const cached = this.uiMessageCache.get(cacheKey);
776
+ const cached = conv.uiMessageCache.get(cacheKey);
692
777
  if (cached)
693
778
  return cached;
694
779
  const msg = serializeMessage(m, n);
695
780
  if (msg)
696
- this.uiMessageCache.set(cacheKey, msg);
781
+ conv.uiMessageCache.set(cacheKey, msg);
697
782
  return msg;
698
783
  }
699
784
  snapshot() {
700
- const state = this.session.agent.state;
785
+ const conv = this.conv;
786
+ const state = conv.session.agent.state;
701
787
  const model = state.model;
702
788
  let stats = {
703
789
  totalMessages: 0,
@@ -730,14 +816,15 @@ export class ClientSession {
730
816
  // cached (reference-stable) anyway, and a stable array reference lets the
731
817
  // frontend memoize derived maps instead of rebuilding them every 60ms.
732
818
  const sig = rawMessages.map((m) => m.id).join("\u0001");
733
- const messages = sig === this.lastMessagesSig ? this.lastMessagesArray : rawMessages;
734
- this.lastMessagesSig = sig;
735
- this.lastMessagesArray = rawMessages;
819
+ const messages = conv.lastMessagesSig === sig ? conv.lastMessagesArray : rawMessages;
820
+ conv.lastMessagesSig = sig;
821
+ conv.lastMessagesArray = rawMessages;
736
822
  return {
737
823
  clientId: this.clientId,
738
824
  cwd: this.cwd,
739
825
  sessionId: this.session.sessionId,
740
826
  sessionFile: this.session.sessionFile,
827
+ conversationId: this.activeId,
741
828
  messages,
742
829
  // The in-progress assistant message lives in state.streamingMessage
743
830
  // (the SDK only pushes it into state.messages at message_end). Surfacing
@@ -751,7 +838,7 @@ export class ClientSession {
751
838
  ? { id: model.id, name: model.name, provider: model.provider }
752
839
  : null,
753
840
  thinkingLevel: state.thinkingLevel,
754
- queue: { steering: this.queueSteering, followUp: this.queueFollowUp },
841
+ queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
755
842
  errorMessage: state.errorMessage,
756
843
  tools: state.tools.map((t) => t.name),
757
844
  version: ++this.version,
@@ -1288,6 +1375,13 @@ export class ClientSession {
1288
1375
  text: `提示发送失败:${err.message}`,
1289
1376
  });
1290
1377
  }
1378
+ // Name the conversation after its first user prompt.
1379
+ const conv = this.conv;
1380
+ if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1381
+ const trimmed = text.trim().replace(/\s+/g, " ");
1382
+ conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1383
+ this.emitConversations();
1384
+ }
1291
1385
  this.flushSnapshot();
1292
1386
  }
1293
1387
  /**
@@ -1574,9 +1668,25 @@ export class ClientSession {
1574
1668
  this.flushSnapshot();
1575
1669
  }
1576
1670
  async newChat() {
1671
+ if (this.convs.size >= MAX_OPEN_CONVERSATIONS) {
1672
+ this.emit({
1673
+ type: "notice",
1674
+ level: "warning",
1675
+ text: `打开的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先切换或重载页面关闭`,
1676
+ });
1677
+ return;
1678
+ }
1577
1679
  try {
1578
- await this.runtime.newSession();
1680
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
1681
+ cwd: this.cwd,
1682
+ agentDir: this.agentDir,
1683
+ sessionManager: SessionManager.create(this.cwd, this.sessionDir),
1684
+ });
1685
+ const conv = this.makeConversation(runtime);
1686
+ this.convs.set(conv.id, conv);
1687
+ this.activeId = conv.id;
1579
1688
  await this.bindSession();
1689
+ this.emitConversations();
1580
1690
  }
1581
1691
  catch (err) {
1582
1692
  this.emit({
@@ -1587,6 +1697,44 @@ export class ClientSession {
1587
1697
  }
1588
1698
  this.flushSnapshot();
1589
1699
  }
1700
+ /** Switch the ACTIVE conversation without interrupting any other chat. */
1701
+ async switchConversation(id) {
1702
+ if (!this.convs.has(id) || id === this.activeId)
1703
+ return;
1704
+ this.activeId = id;
1705
+ this.cwd = this.conv.cwd;
1706
+ this.webUi.refresh();
1707
+ this.emitConversations();
1708
+ // Workspace-bound panels (session list / file tree / commands) follow
1709
+ // the active conversation's cwd.
1710
+ void this.refreshSessions();
1711
+ void this.listFiles(undefined);
1712
+ void this.listCommands();
1713
+ this.flushSnapshot();
1714
+ }
1715
+ /** Push the open-conversation list to the client. */
1716
+ emitConversations() {
1717
+ const conversations = [];
1718
+ for (const conv of this.convs.values()) {
1719
+ let messageCount = 0;
1720
+ let isStreaming = false;
1721
+ try {
1722
+ messageCount = conv.session.getSessionStats().totalMessages;
1723
+ isStreaming = conv.session.isStreaming;
1724
+ }
1725
+ catch {
1726
+ // session being replaced — report defaults
1727
+ }
1728
+ conversations.push({
1729
+ id: conv.id,
1730
+ title: conv.title,
1731
+ cwd: conv.cwd,
1732
+ messageCount,
1733
+ isStreaming,
1734
+ });
1735
+ }
1736
+ this.emit({ type: "conversations", conversations, activeId: this.activeId });
1737
+ }
1590
1738
  /** List persisted sessions for this client, newest first. */
1591
1739
  /** Push the persisted session list to the client (client-requested). */
1592
1740
  async refreshSessions() {
@@ -1642,6 +1790,12 @@ export class ClientSession {
1642
1790
  try {
1643
1791
  await this.runtime.switchSession(path);
1644
1792
  await this.bindSession();
1793
+ // The resumed session carries its own cwd — sync it into the ACTIVE
1794
+ // conversation (other open conversations are untouched).
1795
+ this.conv.cwd = this.runtime.cwd;
1796
+ this.cwd = this.runtime.cwd;
1797
+ this.conv.title = conversationTitle(this.runtime.session);
1798
+ this.emitConversations();
1645
1799
  }
1646
1800
  catch (err) {
1647
1801
  this.emit({
@@ -2018,20 +2172,24 @@ export class ClientSession {
2018
2172
  this.flushSnapshot();
2019
2173
  return;
2020
2174
  }
2021
- // Build the new runtime first — only swap on success.
2022
- const newRuntime = await createAgentSessionRuntime(ClientSession.runtimeFactory, {
2175
+ // Build the new runtime first — only swap the ACTIVE conversation on
2176
+ // success (other open conversations keep their own cwd + runtime).
2177
+ const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
2023
2178
  cwd: abs,
2024
2179
  agentDir: this.agentDir,
2025
2180
  sessionManager: SessionManager.continueRecent(abs, this.sessionDir),
2026
2181
  });
2027
- const oldRuntime = this.runtime;
2028
- this.runtime = newRuntime;
2182
+ const conv = this.conv;
2183
+ const oldRuntime = conv.runtime;
2184
+ conv.runtime = newRuntime;
2185
+ conv.session = newRuntime.session;
2186
+ conv.cwd = abs;
2029
2187
  this.cwd = abs;
2030
2188
  // Remember the new workspace (restore target + recent-project entry).
2031
2189
  this.stateStore.remember(this.clientId, abs);
2032
2190
  void this.pushProjects();
2033
- this.unsubscribe?.();
2034
- this.unsubscribe = undefined;
2191
+ conv.unsubscribe?.();
2192
+ conv.unsubscribe = undefined;
2035
2193
  await this.bindSession();
2036
2194
  await oldRuntime.dispose().catch(() => { });
2037
2195
  for (const d of newRuntime.diagnostics) {
@@ -2044,6 +2202,7 @@ export class ClientSession {
2044
2202
  level: "info",
2045
2203
  text: `已切换到工作目录:${abs}`,
2046
2204
  });
2205
+ this.emitConversations();
2047
2206
  void this.refreshSessions();
2048
2207
  void this.listFiles(undefined);
2049
2208
  // Commands are per-project (.pi/commands.json in the current cwd).
@@ -2164,13 +2323,14 @@ export class ClientSession {
2164
2323
  this.widgetsTimer = null;
2165
2324
  }
2166
2325
  this.webUi.dispose();
2167
- this.unsubscribe?.();
2168
- this.unsubscribe = undefined;
2169
- try {
2170
- await this.runtime.dispose();
2171
- }
2172
- catch {
2173
- // best effort
2326
+ for (const conv of this.convs.values()) {
2327
+ conv.unsubscribe?.();
2328
+ try {
2329
+ await conv.runtime.dispose();
2330
+ }
2331
+ catch {
2332
+ // best effort
2333
+ }
2174
2334
  }
2175
2335
  }
2176
2336
  }
@@ -148,6 +148,9 @@ wss.on("connection", (ws) => {
148
148
  case "switch_session":
149
149
  void cs.switchSession(msg.path);
150
150
  break;
151
+ case "switch_conversation":
152
+ void cs.switchConversation(msg.id);
153
+ break;
151
154
  case "list_files":
152
155
  void cs.listFiles(msg.path);
153
156
  break;
@@ -192,6 +192,28 @@ function brokenSpawnHelper() {
192
192
  }
193
193
  return "";
194
194
  }
195
+ // ---------------------------------------------------------------------------
196
+ // macOS TCC camera/mic warning (launchd-spawned servers)
197
+ // ---------------------------------------------------------------------------
198
+ // TCC attributes camera/mic access to the process chain's "responsible
199
+ // process". When pi-web-ui runs as a launchd LaunchAgent (node ← launchd),
200
+ // the responsible process is node itself — a bare CLI binary with no app
201
+ // bundle / Info.plist / NSCameraUsageDescription — so TCC silently denies
202
+ // camera access (no prompt, nothing to tick in System Settings) and
203
+ // ffmpeg-style grabbers hang on frame capture. The identical command works
204
+ // from a terminal app that already holds the camera grant. Detect the
205
+ // "no GUI ancestor" case (ppid === 1 on macOS) and warn in the terminal.
206
+ const TCC_HINT = [
207
+ "\x1b[33m[提示] 本终端由后台服务(launchd)启动,macOS 隐私权限(相机/麦克风/屏幕录制等)对此类进程不可用。\x1b[0m",
208
+ "\x1b[90m · 需要隐私权限的命令会被系统静默拒绝:不弹授权窗,系统设置里也无法勾选,表现多为卡死或无输出。",
209
+ " · 这类任务请在你自己已授权的前台终端里运行。",
210
+ " · 本终端内可运行不需要隐私权限的命令(如文件处理、网络请求、远程设备流)。",
211
+ " · 若改在前台终端里运行 pi-web-ui,本提示即不再出现。\x1b[0m",
212
+ ].join("\r\n") + "\r\n";
213
+ /** True when this server was spawned by launchd (or orphaned) on macOS — no GUI app in the ancestry, so camera/mic TCC grants are unavailable. */
214
+ function launchdSpawnedOnMac() {
215
+ return process.platform === "darwin" && process.ppid === 1;
216
+ }
195
217
  /**
196
218
  * Owns one or more PTYs for a client. All output is forwarded as
197
219
  * `terminal_output` messages through the provided emit (broadcast to every
@@ -202,6 +224,7 @@ export class TerminalManager {
202
224
  emit;
203
225
  terms = new Map();
204
226
  seq = 0;
227
+ tccHintShown = false;
205
228
  constructor(emit) {
206
229
  this.emit = emit;
207
230
  }
@@ -209,7 +232,16 @@ export class TerminalManager {
209
232
  create(id, cwd, cols, rows, fallbackCwd) {
210
233
  if (this.terms.has(id))
211
234
  return;
212
- this.spawnShell(id, cwd || fallbackCwd, cols, rows, `终端 ${++this.seq}`);
235
+ if (this.spawnShell(id, cwd || fallbackCwd, cols, rows, `终端 ${++this.seq}`)) {
236
+ this.maybeEmitTccHint(id);
237
+ }
238
+ }
239
+ /** Warn about unavailable camera/mic TCC grants in a fresh terminal, once per client. */
240
+ maybeEmitTccHint(id) {
241
+ if (this.tccHintShown || !launchdSpawnedOnMac())
242
+ return;
243
+ this.tccHintShown = true;
244
+ this.writeOut(id, TCC_HINT);
213
245
  }
214
246
  /**
215
247
  * Start a shell in the command's directory and run the command in it.
@@ -247,6 +279,7 @@ export class TerminalManager {
247
279
  // (the PTY input buffer holds it until the shell is ready).
248
280
  this.writeOut(id, "\x1b[2J\x1b[3J\x1b[H");
249
281
  this.writeOut(id, `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`);
282
+ this.maybeEmitTccHint(id);
250
283
  if (command)
251
284
  this.input(id, command + "\r");
252
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",