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