返回 DeepSeek-Reasonix
terminal.go
根目录 / desktop / terminal.go
1 package main
2
3 import (
4 "crypto/rand"
5 "encoding/base64"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "sync"
16 "time"
17
18 "reasonix/internal/config"
19 "reasonix/internal/localeenv"
20 "reasonix/internal/secrets"
21 )
22
23 const (
24 terminalOutputChannel = "terminal:output"
25 terminalExitChannel = "terminal:exit"
26 maxTerminalsPerWorkspace = 10
27 terminalCloseWait = 2 * time.Second
28 defaultTerminalColumns = 80
29 defaultTerminalRows = 24
30 maxTerminalColumns = 1000
31 maxTerminalRows = 500
32 maxTerminalSnapshotBytes = 128 * 1024
33 )
34
35 var (
36 errTerminalStaleTab = errors.New("terminal request is no longer for the active tab")
37 errTerminalRemote = errors.New("integrated terminal is unavailable for remote workspaces")
38 errTerminalOutside = errors.New("terminal directory is outside the workspace")
39 errTerminalManagerOff = errors.New("terminal manager is not available")
40 )
41
42 // TerminalSessionView is the renderer-safe snapshot of an interactive shell.
43 type TerminalSessionView struct {
44 ID string `json:"id"`
45 Title string `json:"title"`
46 Shell string `json:"shell"`
47 Cwd string `json:"cwd"`
48 CreatedAt int64 `json:"createdAt"`
49 ExitCode *int `json:"exitCode,omitempty"`
50 Running bool `json:"running"`
51 }
52
53 // TerminalShellView is a backend-approved shell choice. The renderer sends the
54 // stable ID back; it never sends an executable path.
55 type TerminalShellView struct {
56 ID string `json:"id"`
57 Label string `json:"label"`
58 }
59
60 // TerminalWorkspaceView describes terminal capability for the active tab. All
61 // slices are initialized so the desktop bridge encodes empty values as [] rather than null.
62 type TerminalWorkspaceView struct {
63 Available bool `json:"available"`
64 ReadOnly bool `json:"readOnly"`
65 Reason string `json:"reason,omitempty"`
66 Sessions []TerminalSessionView `json:"sessions"`
67 Shells []TerminalShellView `json:"shells"`
68 }
69
70 type terminalTarget struct {
71 tabID string
72 workspaceRoot string
73 workspaceKey string
74 readOnly bool
75 }
76
77 type terminalCommand struct {
78 path string
79 args []string
80 label string
81 }
82
83 type terminalStartSpec struct {
84 command terminalCommand
85 dir string
86 env []string
87 cols int
88 rows int
89 }
90
91 type terminalProcess interface {
92 io.ReadWriteCloser
93 Resize(cols, rows int) error
94 Wait() (int, error)
95 }
96
97 type terminalSession struct {
98 view TerminalSessionView
99 tabID string
100 workspaceKey string
101 process terminalProcess
102 readDone chan struct{}
103 done chan struct{}
104 output []byte
105 }
106
107 type terminalManager struct {
108 app *App
109
110 mu sync.Mutex
111 sessions map[string]*terminalSession
112 byWorkspace map[string][]string
113 starting map[string]int
114 tabGeneration map[string]uint64
115 closedTabIDs map[string]struct{}
116 closed bool
117 start func(terminalStartSpec) (terminalProcess, error)
118 }
119
120 func newTerminalManager(app *App) *terminalManager {
121 return &terminalManager{
122 app: app,
123 sessions: make(map[string]*terminalSession),
124 byWorkspace: make(map[string][]string),
125 starting: make(map[string]int),
126 tabGeneration: make(map[string]uint64),
127 closedTabIDs: make(map[string]struct{}),
128 start: startTerminalProcess,
129 }
130 }
131
132 func emptyTerminalWorkspaceView() TerminalWorkspaceView {
133 return TerminalWorkspaceView{
134 Sessions: []TerminalSessionView{},
135 Shells: []TerminalShellView{},
136 }
137 }
138
139 // TerminalWorkspaceForTab returns the terminal state for the currently active
140 // tab. The backend owns workspace resolution; renderer-supplied filesystem roots
141 // are never accepted.
142 func (a *App) TerminalWorkspaceForTab(tabID string) (TerminalWorkspaceView, error) {
143 view := emptyTerminalWorkspaceView()
144 target, err := a.terminalTargetForTab(tabID, false)
145 if err != nil {
146 if errors.Is(err, errTerminalRemote) {
147 view.Reason = err.Error()
148 return view, nil
149 }
150 return view, err
151 }
152 view.ReadOnly = target.readOnly
153 available, reason := terminalPlatformAvailable()
154 view.Available = available
155 view.Reason = reason
156 view.Shells = terminalShellOptions()
157 if a.terminals != nil {
158 view.Sessions = a.terminals.list(target.workspaceKey)
159 }
160 return view, nil
161 }
162
163 // TerminalOutputForTab returns a bounded snapshot of the selected session's
164 // output. It is an explicit user action for adding terminal context to chat;
165 // terminal output is never injected into provider prompts automatically.
166 func (a *App) TerminalOutputForTab(tabID, sessionID string) (string, error) {
167 target, err := a.terminalTargetForTab(tabID, false)
168 if err != nil {
169 return "", err
170 }
171 if a.terminals == nil {
172 return "", errTerminalManagerOff
173 }
174 return a.terminals.snapshot(target.workspaceKey, sessionID), nil
175 }
176
177 // CreateTerminalForTab starts an interactive shell at a workspace-relative
178 // file or directory. Files resolve to their parent directory after an os.Stat;
179 // symlinked directories are checked against the canonical workspace root.
180 func (a *App) CreateTerminalForTab(tabID, rel, shellID string) (TerminalSessionView, error) {
181 target, err := a.terminalTargetForTab(tabID, true)
182 if err != nil {
183 return TerminalSessionView{}, err
184 }
185 releaseAdmission, err := a.beginWorkspaceRuntimeAdmission(target.workspaceRoot)
186 if err != nil {
187 return TerminalSessionView{}, err
188 }
189 defer releaseAdmission()
190 available, reason := terminalPlatformAvailable()
191 if !available {
192 return TerminalSessionView{}, errors.New(reason)
193 }
194 dir, err := resolveTerminalStartDir(target.workspaceRoot, rel)
195 if err != nil {
196 return TerminalSessionView{}, err
197 }
198 command, err := resolveTerminalCommand(target.workspaceRoot, shellID)
199 if err != nil {
200 return TerminalSessionView{}, err
201 }
202 if err := a.revalidateTerminalTarget(target, true); err != nil {
203 return TerminalSessionView{}, err
204 }
205 if a.terminals == nil {
206 return TerminalSessionView{}, errTerminalManagerOff
207 }
208 return a.terminals.create(target.tabID, target.workspaceKey, dir, command)
209 }
210
211 func (a *App) ResizeTerminalForTab(tabID, sessionID string, cols, rows int) error {
212 target, err := a.terminalTargetForTab(tabID, true)
213 if err != nil {
214 return err
215 }
216 if a.terminals == nil {
217 return errTerminalManagerOff
218 }
219 return a.terminals.resize(target.workspaceKey, sessionID, cols, rows)
220 }
221
222 func (a *App) CloseTerminalForTab(tabID, sessionID string) error {
223 target, err := a.terminalTargetForTab(tabID, true)
224 if err != nil {
225 return err
226 }
227 if a.terminals == nil {
228 return errTerminalManagerOff
229 }
230 return a.terminals.closeTerminal(target.workspaceKey, sessionID)
231 }
232
233 func (a *App) RenameTerminalForTab(tabID, sessionID, title string) error {
234 target, err := a.terminalTargetForTab(tabID, true)
235 if err != nil {
236 return err
237 }
238 if a.terminals == nil {
239 return errTerminalManagerOff
240 }
241 return a.terminals.rename(target.workspaceKey, sessionID, title)
242 }
243
244 func (a *App) terminalTargetForTab(tabID string, requireWritable bool) (terminalTarget, error) {
245 tabID = strings.TrimSpace(tabID)
246 a.mu.RLock()
247 activeID := a.activeTabID
248 if tabID == "" {
249 tabID = activeID
250 }
251 if tabID == "" || tabID != activeID {
252 a.mu.RUnlock()
253 return terminalTarget{}, errTerminalStaleTab
254 }
255 tab := a.tabByIDLocked(tabID)
256 if tab == nil {
257 a.mu.RUnlock()
258 return terminalTarget{}, errTerminalStaleTab
259 }
260 root := tab.WorkspaceRoot
261 readOnly := terminalReadOnlyForTab(tab)
262 a.mu.RUnlock()
263
264 if requireWritable && readOnly {
265 return terminalTarget{}, readOnlyChannelErr()
266 }
267 base, err := workspaceBaseFromRoot(root)
268 if err != nil {
269 return terminalTarget{}, err
270 }
271 base, err = canonicalDirectory(base)
272 if err != nil {
273 return terminalTarget{}, fmt.Errorf("resolve terminal workspace: %w", err)
274 }
275 return terminalTarget{
276 tabID: tabID,
277 workspaceRoot: base,
278 workspaceKey: tabID + "\x00" + filepath.Clean(base),
279 readOnly: readOnly,
280 }, nil
281 }
282
283 func (a *App) revalidateTerminalTarget(target terminalTarget, requireWritable bool) error {
284 a.mu.RLock()
285 tab := a.tabByIDLocked(target.tabID)
286 valid := tab != nil && a.activeTabID == target.tabID
287 readOnly := valid && terminalReadOnlyForTab(tab)
288 root := ""
289 if valid {
290 root = tab.WorkspaceRoot
291 }
292 a.mu.RUnlock()
293 if !valid {
294 return errTerminalStaleTab
295 }
296 if requireWritable && readOnly {
297 return readOnlyChannelErr()
298 }
299 base, err := workspaceBaseFromRoot(root)
300 if err != nil {
301 return err
302 }
303 base, err = canonicalDirectory(base)
304 if err != nil || filepath.Clean(base) != target.workspaceRoot {
305 return errTerminalStaleTab
306 }
307 return nil
308 }
309
310 func canonicalDirectory(path string) (string, error) {
311 abs, err := filepath.Abs(path)
312 if err != nil {
313 return "", err
314 }
315 real, err := filepath.EvalSymlinks(abs)
316 if err != nil {
317 return "", err
318 }
319 info, err := os.Stat(real)
320 if err != nil {
321 return "", err
322 }
323 if !info.IsDir() {
324 return "", fmt.Errorf("%s is not a directory", real)
325 }
326 return filepath.Clean(real), nil
327 }
328
329 func resolveTerminalStartDir(workspaceRoot, rel string) (string, error) {
330 workspaceRoot, err := canonicalDirectory(workspaceRoot)
331 if err != nil {
332 return "", err
333 }
334 rel = strings.TrimSpace(rel)
335 if rel == "" {
336 rel = "."
337 }
338 if filepath.IsAbs(rel) {
339 return "", errTerminalOutside
340 }
341 target, ok, err := workspacePathForBase(workspaceRoot, rel)
342 if err != nil || !ok {
343 return "", errTerminalOutside
344 }
345 info, err := os.Stat(target)
346 if err != nil {
347 return "", fmt.Errorf("resolve terminal directory: %w", err)
348 }
349 if !info.IsDir() {
350 target = filepath.Dir(target)
351 }
352 target, err = canonicalDirectory(target)
353 if err != nil {
354 return "", err
355 }
356 relToRoot, err := filepath.Rel(workspaceRoot, target)
357 if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) {
358 return "", errTerminalOutside
359 }
360 return target, nil
361 }
362
363 func terminalShellOptions() []TerminalShellView {
364 options := []TerminalShellView{{ID: "default", Label: "Default shell"}}
365 seen := map[string]bool{"default": true}
366 add := func(id, label, binary string) {
367 if seen[id] {
368 return
369 }
370 if _, err := exec.LookPath(binary); err == nil {
371 seen[id] = true
372 options = append(options, TerminalShellView{ID: id, Label: label})
373 }
374 }
375 if runtime.GOOS == "windows" {
376 add("powershell", "PowerShell", "pwsh.exe")
377 add("windows-powershell", "Windows PowerShell", "powershell.exe")
378 add("cmd", "Command Prompt", "cmd.exe")
379 add("bash", "Bash", "bash.exe")
380 return options
381 }
382 add("zsh", "zsh", "zsh")
383 add("bash", "bash", "bash")
384 add("fish", "fish", "fish")
385 add("sh", "sh", "sh")
386 return options
387 }
388
389 func resolveTerminalCommand(_ string, shellID string) (terminalCommand, error) {
390 shellID = strings.ToLower(strings.TrimSpace(shellID))
391 if shellID == "" || shellID == "auto" {
392 shellID = "default"
393 }
394 if shellID == "default" {
395 if cfg, err := config.LoadUserConfigReadOnly(); err == nil {
396 if command, ok := terminalCommandFromConfig(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path); ok {
397 return command, nil
398 }
399 }
400 return defaultTerminalCommand()
401 }
402 return namedTerminalCommand(shellID)
403 }
404
405 func defaultTerminalCommand() (terminalCommand, error) {
406 if runtime.GOOS != "windows" {
407 if path := strings.TrimSpace(os.Getenv("SHELL")); path != "" {
408 if resolved, err := exec.LookPath(path); err == nil {
409 return commandForShellPath(resolved, filepath.Base(resolved)), nil
410 }
411 }
412 for _, id := range []string{"zsh", "bash", "fish", "sh"} {
413 if command, err := namedTerminalCommand(id); err == nil {
414 return command, nil
415 }
416 }
417 return terminalCommand{}, errors.New("no interactive shell was found")
418 }
419 for _, id := range []string{"powershell", "windows-powershell", "cmd", "bash"} {
420 if command, err := namedTerminalCommand(id); err == nil {
421 return command, nil
422 }
423 }
424 return terminalCommand{}, errors.New("no interactive shell was found")
425 }
426
427 func namedTerminalCommand(shellID string) (terminalCommand, error) {
428 var binary, label string
429 switch shellID {
430 case "bash":
431 binary, label = "bash", "bash"
432 case "zsh":
433 binary, label = "zsh", "zsh"
434 case "fish":
435 binary, label = "fish", "fish"
436 case "sh":
437 binary, label = "sh", "sh"
438 case "powershell", "pwsh":
439 binary, label = "pwsh", "PowerShell"
440 if runtime.GOOS == "windows" {
441 binary = "pwsh.exe"
442 }
443 case "windows-powershell":
444 binary, label = "powershell.exe", "Windows PowerShell"
445 case "cmd":
446 binary, label = "cmd.exe", "Command Prompt"
447 default:
448 return terminalCommand{}, fmt.Errorf("unsupported terminal shell %q", shellID)
449 }
450 path, err := exec.LookPath(binary)
451 if err != nil {
452 return terminalCommand{}, fmt.Errorf("terminal shell %q is not installed", shellID)
453 }
454 return commandForShellPath(path, label), nil
455 }
456
457 func commandForShellPath(path, label string) terminalCommand {
458 base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
459 args := []string{}
460 switch base {
461 case "bash", "zsh", "sh", "ksh", "fish":
462 args = []string{"-l"}
463 case "pwsh", "powershell":
464 args = []string{"-NoLogo"}
465 case "cmd":
466 args = []string{"/Q"}
467 }
468 return terminalCommand{path: path, args: args, label: label}
469 }
470
471 func terminalEnvironment(base []string) []string {
472 base = localeenv.DefaultUTF8(base)
473 env := make([]string, 0, len(base)+2)
474 for _, item := range base {
475 key, _, ok := strings.Cut(item, "=")
476 if ok && (strings.EqualFold(key, "TERM") || strings.EqualFold(key, "COLORTERM")) {
477 continue
478 }
479 env = append(env, item)
480 }
481 return append(env, "TERM=xterm-256color", "COLORTERM=truecolor")
482 }
483
484 func (m *terminalManager) create(tabID, workspaceKey, dir string, command terminalCommand) (TerminalSessionView, error) {
485 tabID = strings.TrimSpace(tabID)
486 m.mu.Lock()
487 if m.closed {
488 m.mu.Unlock()
489 return TerminalSessionView{}, errTerminalManagerOff
490 }
491 if tabID == "" {
492 m.mu.Unlock()
493 return TerminalSessionView{}, errTerminalStaleTab
494 }
495 if _, closed := m.closedTabIDs[tabID]; closed {
496 m.mu.Unlock()
497 return TerminalSessionView{}, errTerminalStaleTab
498 }
499 generation := m.tabGeneration[tabID]
500 if len(m.byWorkspace[workspaceKey])+m.starting[workspaceKey] >= maxTerminalsPerWorkspace {
501 m.mu.Unlock()
502 return TerminalSessionView{}, fmt.Errorf("terminal session limit reached (%d)", maxTerminalsPerWorkspace)
503 }
504 m.starting[workspaceKey]++
505 m.mu.Unlock()
506
507 defer func() {
508 m.mu.Lock()
509 m.starting[workspaceKey]--
510 if m.starting[workspaceKey] == 0 {
511 delete(m.starting, workspaceKey)
512 }
513 m.mu.Unlock()
514 }()
515
516 id, err := newTerminalID()
517 if err != nil {
518 return TerminalSessionView{}, err
519 }
520 proc, err := m.start(terminalStartSpec{
521 command: command,
522 dir: dir,
523 env: terminalEnvironment(secrets.ProcessEnv()),
524 cols: defaultTerminalColumns,
525 rows: defaultTerminalRows,
526 })
527 if err != nil {
528 return TerminalSessionView{}, fmt.Errorf("start terminal: %w", err)
529 }
530
531 session := &terminalSession{
532 view: TerminalSessionView{
533 ID: id,
534 Title: command.label,
535 Shell: command.label,
536 Cwd: dir,
537 CreatedAt: time.Now().UnixMilli(),
538 Running: true,
539 },
540 tabID: tabID,
541 workspaceKey: workspaceKey,
542 process: proc,
543 readDone: make(chan struct{}),
544 done: make(chan struct{}),
545 }
546
547 m.mu.Lock()
548 if m.closed {
549 m.mu.Unlock()
550 _ = proc.Close()
551 return TerminalSessionView{}, errTerminalManagerOff
552 }
553 if _, closed := m.closedTabIDs[tabID]; closed {
554 m.mu.Unlock()
555 _ = proc.Close()
556 return TerminalSessionView{}, errTerminalStaleTab
557 }
558 if m.tabGeneration[tabID] != generation {
559 m.mu.Unlock()
560 _ = proc.Close()
561 return TerminalSessionView{}, errTerminalStaleTab
562 }
563 m.sessions[id] = session
564 m.byWorkspace[workspaceKey] = append(m.byWorkspace[workspaceKey], id)
565 view := session.view
566 m.mu.Unlock()
567
568 go m.readLoop(session)
569 go m.waitLoop(session)
570 return view, nil
571 }
572
573 func (m *terminalManager) snapshot(workspaceKey, sessionID string) string {
574 m.mu.Lock()
575 defer m.mu.Unlock()
576 session := m.sessions[strings.TrimSpace(sessionID)]
577 if session == nil || session.workspaceKey != workspaceKey {
578 return ""
579 }
580 return string(session.output)
581 }
582
583 func appendTerminalSnapshot(current, data []byte) []byte {
584 if len(data) >= maxTerminalSnapshotBytes {
585 return append([]byte(nil), data[len(data)-maxTerminalSnapshotBytes:]...)
586 }
587 if over := len(current) + len(data) - maxTerminalSnapshotBytes; over > 0 {
588 if over >= len(current) {
589 current = current[:0]
590 } else {
591 current = append([]byte(nil), current[over:]...)
592 }
593 }
594 return append(current, data...)
595 }
596
597 func (m *terminalManager) list(workspaceKey string) []TerminalSessionView {
598 m.mu.Lock()
599 defer m.mu.Unlock()
600 ids := m.byWorkspace[workspaceKey]
601 out := make([]TerminalSessionView, 0, len(ids))
602 for _, id := range ids {
603 if session := m.sessions[id]; session != nil {
604 out = append(out, session.view)
605 }
606 }
607 return out
608 }
609
610 func (m *terminalManager) sessionLocked(workspaceKey, sessionID string) (*terminalSession, error) {
611 session := m.sessions[strings.TrimSpace(sessionID)]
612 if session == nil || session.workspaceKey != workspaceKey {
613 return nil, errors.New("terminal session not found in the active workspace")
614 }
615 return session, nil
616 }
617
618 func (m *terminalManager) write(workspaceKey, sessionID string, data []byte) error {
619 m.mu.Lock()
620 session, err := m.sessionLocked(workspaceKey, sessionID)
621 if err == nil && !session.view.Running {
622 err = errors.New("terminal session has exited")
623 }
624 var proc terminalProcess
625 if err == nil {
626 proc = session.process
627 }
628 m.mu.Unlock()
629 if err != nil {
630 return err
631 }
632 _, err = proc.Write(data)
633 return err
634 }
635
636 func (m *terminalManager) resize(workspaceKey, sessionID string, cols, rows int) error {
637 if cols <= 0 || rows <= 0 {
638 return nil
639 }
640 if cols > maxTerminalColumns {
641 cols = maxTerminalColumns
642 }
643 if rows > maxTerminalRows {
644 rows = maxTerminalRows
645 }
646 m.mu.Lock()
647 session, err := m.sessionLocked(workspaceKey, sessionID)
648 var proc terminalProcess
649 if err == nil && session.view.Running {
650 proc = session.process
651 }
652 m.mu.Unlock()
653 if err != nil || proc == nil {
654 return err
655 }
656 return proc.Resize(cols, rows)
657 }
658
659 func (m *terminalManager) rename(workspaceKey, sessionID, title string) error {
660 title = strings.TrimSpace(title)
661 if title == "" {
662 return errors.New("terminal title is required")
663 }
664 if len([]rune(title)) > 80 {
665 return errors.New("terminal title is too long")
666 }
667 m.mu.Lock()
668 defer m.mu.Unlock()
669 session, err := m.sessionLocked(workspaceKey, sessionID)
670 if err != nil {
671 return err
672 }
673 session.view.Title = title
674 return nil
675 }
676
677 func (m *terminalManager) closeTerminal(workspaceKey, sessionID string) error {
678 m.mu.Lock()
679 session, err := m.sessionLocked(workspaceKey, sessionID)
680 if err != nil {
681 m.mu.Unlock()
682 return err
683 }
684 m.removeSessionLocked(session)
685 m.mu.Unlock()
686
687 _ = session.process.Close()
688 select {
689 case <-session.done:
690 case <-time.After(terminalCloseWait):
691 }
692 return nil
693 }
694
695 func (m *terminalManager) closeForTab(tabID string) {
696 m.closeSessions(m.detachForTab(tabID))
697 }
698
699 // detachForTab closes the creation gate and removes every registered session
700 // without waiting on process I/O. Callers can use it while serializing an App
701 // capability transition, then close the returned processes after releasing
702 // App.mu.
703 func (m *terminalManager) detachForTab(tabID string) []*terminalSession {
704 if m == nil {
705 return nil
706 }
707 tabID = strings.TrimSpace(tabID)
708 if tabID == "" {
709 return nil
710 }
711 m.mu.Lock()
712 m.closedTabIDs[tabID] = struct{}{}
713 m.tabGeneration[tabID]++
714 sessions := make([]*terminalSession, 0)
715 for _, session := range m.sessions {
716 if session.tabID != tabID {
717 continue
718 }
719 m.removeSessionLocked(session)
720 sessions = append(sessions, session)
721 }
722 m.mu.Unlock()
723 return sessions
724 }
725
726 func (m *terminalManager) closeSessions(sessions []*terminalSession) {
727 if m == nil || len(sessions) == 0 {
728 return
729 }
730 for _, session := range sessions {
731 _ = session.process.Close()
732 }
733 deadline := time.NewTimer(terminalCloseWait)
734 defer deadline.Stop()
735 for _, session := range sessions {
736 select {
737 case <-session.done:
738 case <-deadline.C:
739 return
740 }
741 }
742 }
743
744 func (m *terminalManager) reopenForTab(tabID string) {
745 if m == nil {
746 return
747 }
748 tabID = strings.TrimSpace(tabID)
749 if tabID == "" {
750 return
751 }
752 m.mu.Lock()
753 if !m.closed {
754 delete(m.closedTabIDs, tabID)
755 }
756 m.mu.Unlock()
757 }
758
759 func (m *terminalManager) removeSessionLocked(session *terminalSession) {
760 delete(m.sessions, session.view.ID)
761 ids := m.byWorkspace[session.workspaceKey]
762 filtered := ids[:0]
763 for _, id := range ids {
764 if id != session.view.ID {
765 filtered = append(filtered, id)
766 }
767 }
768 if len(filtered) == 0 {
769 delete(m.byWorkspace, session.workspaceKey)
770 } else {
771 m.byWorkspace[session.workspaceKey] = filtered
772 }
773 }
774
775 func (m *terminalManager) closeAll() {
776 if m == nil {
777 return
778 }
779 m.mu.Lock()
780 m.closed = true
781 sessions := make([]*terminalSession, 0, len(m.sessions))
782 for _, session := range m.sessions {
783 sessions = append(sessions, session)
784 }
785 m.sessions = make(map[string]*terminalSession)
786 m.byWorkspace = make(map[string][]string)
787 m.mu.Unlock()
788
789 for _, session := range sessions {
790 _ = session.process.Close()
791 }
792 deadline := time.NewTimer(terminalCloseWait)
793 defer deadline.Stop()
794 for _, session := range sessions {
795 select {
796 case <-session.done:
797 case <-deadline.C:
798 return
799 }
800 }
801 }
802
803 func (m *terminalManager) readLoop(session *terminalSession) {
804 defer close(session.readDone)
805 buf := make([]byte, 8*1024)
806 for {
807 n, err := session.process.Read(buf)
808 if n > 0 {
809 active := false
810 m.mu.Lock()
811 if current := m.sessions[session.view.ID]; current == session {
812 session.output = appendTerminalSnapshot(session.output, buf[:n])
813 active = true
814 }
815 m.mu.Unlock()
816 if active {
817 m.emitOutput(session.view.ID, buf[:n])
818 }
819 }
820 if err != nil {
821 return
822 }
823 }
824 }
825
826 func (m *terminalManager) waitLoop(session *terminalSession) {
827 exitCode, waitErr := session.process.Wait()
828 select {
829 case <-session.readDone:
830 case <-time.After(terminalCloseWait):
831 }
832 _ = session.process.Close()
833 if waitErr != nil && exitCode == 0 {
834 exitCode = -1
835 }
836 m.mu.Lock()
837 removed := true
838 if current := m.sessions[session.view.ID]; current == session {
839 current.view.Running = false
840 current.view.ExitCode = &exitCode
841 removed = false
842 }
843 m.mu.Unlock()
844 close(session.done)
845 m.emitExit(session.view.ID, exitCode, removed)
846 }
847
848 func (m *terminalManager) emitOutput(id string, data []byte) {
849 if m.app == nil || len(data) == 0 {
850 return
851 }
852 m.app.emitRuntimeEvent(terminalOutputChannel, map[string]any{
853 "id": id,
854 "data": base64.StdEncoding.EncodeToString(data),
855 })
856 }
857
858 func (m *terminalManager) emitExit(id string, exitCode int, removed bool) {
859 if m.app == nil {
860 return
861 }
862 m.app.emitRuntimeEvent(terminalExitChannel, map[string]any{
863 "id": id,
864 "exitCode": exitCode,
865 "removed": removed,
866 })
867 }
868
869 func newTerminalID() (string, error) {
870 var raw [8]byte
871 if _, err := rand.Read(raw[:]); err != nil {
872 return "", err
873 }
874 return "term-" + hex.EncodeToString(raw[:]), nil
875 }
876
876 lines GO